Files
msd-core/docs/how-to/add-or-update-a-host-integration.md
Tom Boucher c0fd2e3f4c feat(#3673): add dispatch.maxConcurrency axis and dispatch-capacity query (#4162)
* test(#3673): add failing tests for dispatch.maxConcurrency axis and dispatch-capacity CLI route

Extends tests/host-integration.test.cjs with negotiateHostCapabilities
maxConcurrency negotiation coverage (test matrix rows 1-15, including a
fast-check property test) and a new #3673 dispatch-capacity CLI route
describe block spawning the real gsd-tools.cjs (rows 16-25). Extends
tests/host-integration-validator-parity.test.cjs with an all-19-descriptor
maxConcurrency presence/validity sweep (row 26) and adds a hostile-input
validator test to host-integration.test.cjs (row 27).

The dispatch.maxConcurrency field does not exist yet, so these tests fail.

* feat(#3673): add dispatch.maxConcurrency axis, negotiation, validator parity, and the dispatch-capacity query

Adds a numeric dispatch.maxConcurrency sub-field to the Host-Integration
Interface (ADR-1239 Phase 1), following the existing dispatch.isolation
sub-field pattern: DispatchCapability interface, SAFE_DEFAULTS/PROFILE_BASELINES
floors, and a negotiateHostCapabilities branch that passes through a positive
safe integer and fails closed to 1 otherwise (no engine-side reduction, per
the design doc's explicit rejection of a min(host,engine) rule).

capability-validator.cjs gains parity validation for the new field (optional,
positive safe integer or the "undocumented" sentinel — mirroring isolation's
"added after existing descriptors" treatment).

gsd-tools.cjs gains a new `query dispatch-capacity` route, a pure-read sibling
of `dispatch-isolation` with no side effects: live env
(GSD_DISPATCH_MAX_CONCURRENCY) > descriptor > fallback-to-1 precedence.

All 19 capabilities/*/capability.json descriptors now declare
dispatch.maxConcurrency: claude carries the one cited value (20, per
code.claude.com/docs/en/sub-agents); the other 18 carry "undocumented"
(not yet researched for this axis).

* docs(#3673): document dispatch.maxConcurrency and add its citation row to the capability matrix

Updates docs/reference/host-integration-interface.md's dispatch struct entry
(also backfilling the previously-undocumented isolation/backgroundDispatch
sub-fields found stale in the same table) and adds fail-closed/live-transport
precedence prose for the new maxConcurrency field.

Adds a dispatch.maxConcurrency row (with citation) to all 19 host sections in
docs/reference/host-integration-capability-matrix.md — required for
tests/host-integration-descriptors.test.cjs's kimi-code matrix-parity check,
which asserts every declared dispatch sub-axis is documented there.

Updates docs/how-to/add-or-update-a-host-integration.md's dispatch checklist
and example descriptor block to mention maxConcurrency (and, likewise
backfilling a stale gap, isolation/backgroundDispatch).

* fix(#3673): extract shared maxConcurrency validator, drop dead reserved-name check

Exports isPositiveSafeInteger from src/host-integration.cts as the single
source of truth for the dispatch.maxConcurrency positive-safe-integer
contract; negotiateHostCapabilities and gsd-tools.cjs's routeDispatchCapacity
now both call it instead of independently reimplementing the same predicate.

Also removes the __proto__/constructor/prototype reserved-name branch from
capability-validator.cjs's maxConcurrency check — copy-pasted from the
string-enum fields above it, but unreachable for a numeric field (the
generic positive-safe-integer branch already rejects any string) and absent
from maxDepth, the field the code's own comment claims to mirror.

---------

Co-authored-by: sim <sim@local>
2026-09-01 21:39:12 -04:00

12 KiB
Raw Blame History

How to add or update a host's integration capabilities

This guide is for GSD maintainers adding a new host CLI, or updating an existing host's host-integration axes (ADR-1239 Phase A). It covers the documentation-sourcing rule, the nine runtime.hostIntegration axes, the undocumented sentinel, and how to validate.

The governing rule for this whole process: every axis value must come from the host's own authoritative documentation. Never infer, guess, or assume. Where the docs do not state an axis, record the explicit undocumented sentinel — not a plausible default. The reference matrix (docs/reference/host-integration-capability-matrix.md) is the source of truth, and every value in it carries a citation and an evidence quote.


1. Find the host's authoritative documentation

In order of preference:

  1. Context7 — resolve-library-id for the host, then query-docs for "plugins / subagents / hooks / commands / MCP / model API".
  2. Official dev docs / source repo — the host's documentation site or GitHub repo (plugin API, agents, hooks, MCP, command authoring).

Capture the exact source (Context7 library id + query, or the doc URL) and a short verbatim quote for each value you determine. You will paste these into the matrix in step 4.

2. Determine each of the nine axes from the docs

Read the docs and map them to the closed vocabulary. Do not pick a value unless a source states it.

Axis What to look for in the docs
embeddingMode An in-process programmatic plugin/extension API (imperative) vs. configuration files only (declarative).
commandSurface How custom commands are authored/invoked: slash-file (.md), slash-toml, slash-programmatic, palette, prose-only.
dispatch Sub-agent delegation: namedDispatch, nested, maxDepth (int; -1 = documented-unbounded), background, subagentToolkit (full/read-only/built-in-only), backgroundDispatch, isolation (harness-worktree/orchestrator-worktree/none, #2584), maxConcurrency (positive integer — how many same-wave executors this host can run concurrently; no engine-side ceiling, unlike maxDepth; #3673).
modelMode A programmatic model request/provider API (active) vs. instruction/per-agent-field only (passive).
hookBus The host fires lifecycle events a plugin subscribes to (host), an extension host owns the bus (engine), or no bus (none). Independent of hooksSurface — e.g. opencode has hooksSurface: none but hookBus: host.
stateIO filesystem, sandboxed-storage (web IDE, no arbitrary FS), or session-log-append.
transport mcp (native MCP support) vs. native-extension (MCP needs a community extension).
runtime The plugin/extension runtime: node, bun, sandboxed-web, python, go, rust, electron, other.
effortSurface How reasoning effort reaches the host: argv (a flag on the host's own invocation) or none (no reasoning-effort mechanism). Added by #2481. There is deliberately no config-file member — do not invent one; use undocumented when the host's docs state no reasoning setting.

3. Write the runtime.hostIntegration block

In capabilities/<id>/capability.json, inside the runtime object, add (or edit) the block. Use a documented closed-vocabulary value, or the literal string "undocumented" for any axis the docs do not state:

"hostIntegration": {
  "embeddingMode": "declarative",
  "commandSurface": "slash-file",
  "dispatch": { "namedDispatch": true, "nested": false, "maxDepth": 1, "background": false, "subagentToolkit": "undocumented", "backgroundDispatch": false, "isolation": "undocumented", "maxConcurrency": "undocumented" },
  "modelMode": "passive",
  "hookBus": "host",
  "stateIO": "filesystem",
  "transport": "mcp",
  "runtime": "node"
}

When to use undocumented: only when you searched and the host's docs genuinely do not state the axis. It validates, but negotiateHostCapabilities fail-closes on it (degrades to the most restrictive known value) — so it is always safe and never a silent capability claim. A dispatch boolean, maxDepth, isolation, or maxConcurrency may also be "undocumented".

Do not conflate the orthogonal axes: commandStyle (GSD's emission style) is not commandSurface (the host's surface type); the hookEvents dialect is not hookBus (bus ownership); runtimeCompat (which features run on a host) is independent of these runtime→engine axes.

4. Record the citations in the reference matrix

Add (or update) the host's section in docs/reference/host-integration-capability-matrix.md with a row per axis: Axis | Value | Source | Evidence. For an undocumented value, put the search trail in the Source column. This file is the deployment source of truth — a value without a citation here is not allowed.

5. Validate

npm run build:lib
npm run gen:capability-registry   # validateRuntimeBody runs on every descriptor

gen:capability-registry must succeed with zero errors. The validator (gsd-core/bin/lib/capability-validator.cjs) rejects out-of-vocabulary values, malformed dispatch structs, and reserved keys (__proto__/constructor/prototype).

Then run the host-integration tests and the full cross-platform suite:

node --test tests/host-integration-descriptors.test.cjs   # asserts every descriptor validates + profiles
gsd-test-both                                              # Mac + Linux Docker (run before any PR)

6. If you need a vocabulary value that does not exist yet

The vocabulary is intentionally closed (ADR-857 Decision 8): a genuinely new host shape requires a first-party primitive, reviewed. To add one (e.g. a new runtime kind):

  1. Add the value to the relevant axis in HOST_INTEGRATION_AXES in src/host-integration.cts.
  2. Add the same value to the matching VALID_* set in capability-validator.cjs.

The parity guard (tests/host-integration-validator-parity.test.cjs) fails if these two drift, so they must be updated together. Document the new value's meaning in the matrix legend.

7. Fold an already-hardcoded host into the interface (worked example: claude)

Sections 1–6 cover a green-field host (pi, antigravity — a fresh descriptor + reference binding). This section covers the other case: a host that already has a real production install driven by scattered runtime === '<id>' string-equality branches in bin/install.js, which you want to move onto the Host-Integration Interface without changing a single installed byte. claude (the tier-1 reference host, #2086) is the worked example.

The pattern is byte-parity-safe by construction — each string check becomes a descriptor lookup that yields the same truth value, so behavior is unchanged and only the brittle coupling is removed:

  1. Inventory the branches. Find every runtime === '<id>' / runtime !== '<id>' in bin/install.js for the host (grep -nE "runtime\s*[!=]==\s*'claude'"). Each is a host behavior encoded as a string comparison rather than a declared capability.

  2. Declare the behaviors on the descriptor. Add a runtime.hostBehaviors object to the host's capability.json. Each key names one behavior the branches gated on — e.g. for claude: permissionsSchema: "claude", settingsFileByScope: { local: "settings.local.json", global: "settings.json" }, sourceMarkerFile: ".gsd-source", agentFrontmatterExtensions: ["effort"], localInstallStyle: "legacy-flat", authorsCanonicalWorkflow: true, ownsClaudePaths: true, nativeModelAliases: true, skillsGlobalOnboarding: true, attributionSource: "settings-json-commit". The validator (validateRuntimeBody) is lenient toward these host-behavior keys; they carry install policy, not the closed negotiated axes.

  3. Replace each branch with a descriptor read. bin/install.js exposes a _hostBehaviors(runtime) helper (reads _capabilityRegistry.runtimes[runtime].runtime.hostBehaviors, {} if absent). Rewrite if (runtime === 'claude') → if (_hostBehaviors(runtime).permissionsSchema === 'claude'), and if (runtime !== 'claude') → if (!_hostBehaviors(runtime).authorsCanonicalWorkflow). Only the host declares the key, so every other runtime keeps the generic path.

  4. Route install/uninstall through the public adapter. Replace the direct installRuntimeArtifacts(...) / uninstallRuntimeArtifacts(...) calls with createImperativeAdapter({ runtime }).install({...}) / .uninstall({...}). The imperative adapter delegates to the same engine functions, so the output is byte-identical — that is the point: the host is now driven through the interface, not around it.

  5. Prove parity, both scopes. The differential attribution check (tests/emitted-attribution.test.cjs, ADR-2719) compares the emitted manifest built from your branch against next's recorded state and requires every moved hash to be attributable to a path your PR changed — no fixture to regenerate by hand. Confirm the host's install is unchanged for global and local scopes. Exclude only genuinely volatile / platform-varying files (settings.json, settings.local.json, .gsd-source).

  6. Guard against regression. Add a *-imperative-reference.test.cjs asserting the adapter classifies the host correctly, negotiation fails closed on a corrupted descriptor, and — with a source-grep behind an // allow-test-rule: exemption — that no runtime === '<id>' branch remains in bin/install.js.

Another completed worked example: copilot (#2099). Copilot was already installing through the declarative artifactLayout (not the direct installRuntimeArtifacts calls step 4 describes), so its migration folded the residual hardcoded branches rather than the whole install path: the .agent.md destination-suffix rename in src/install-engine.cts (→ hostBehaviors.agentFileExtension), two uninstall side-effect branches in bin/install.js (→ resolveInstallPlan(runtime).installSurface === 'copilot-instructions', already a live descriptor field elsewhere in the same file), and two skipSharedHooksInstall gates (→ hostBehaviors.skipSharedHooksInstall: true). A dead legacy agent-converter dispatch arm — unreachable because copilot was a member of the then-existing _DESCRIPTOR_AGENTS_RUNTIMES allow-list — was deleted outright rather than re-gated, mirroring step 6's guard: tests/declarative-reference-copilot.test.cjs source-greps both files for the retired isCopilot reads. See the copilot section of the reference matrix for the full EoS migration note, including the two upgrades (multi-event hook bus; negotiated dispatch.background) this PR adds.

_DESCRIPTOR_AGENTS_RUNTIMES no longer exists (#2875). It was an allow-list naming the runtimes whose agents came from the descriptor; everything absent from it fell through to an inline _hostBehaviors() dispatch loop in bin/install.js. That loop and the set are both gone — the descriptor is now authoritative for agents on every runtime, so there is no longer an opt-in list to join. Declare an agents entry under artifactLayout and it is installed.

If your host needs a per-agent transform the descriptor cannot yet express, extend the pipeline rather than reintroducing an inline branch. The three extension points added when the loop was removed are the pattern to follow: hostBehaviors.agentFrontmatterExtensions for injected frontmatter keys, per-agent model-override resolution threaded through the converter's options, and a named converter driven by descriptor data (hermes's branding rewrites are declared in capability.json, not hardcoded). All three exist because the descriptor pipeline lacked one thing: per-agent resolution context (targetDir + agentName).

Declaring an agents entry also takes effect on the surface path (/gsd-surface --materialize) immediately, not only on install — the two paths are intentionally converged.