feat(#2103): drive VS Code through the Embeddable Orchestration System (ADR-1239)

VS Code is a net-new EoS runtime that — unlike every prior migration — is NOT
CLI-installed (Marketplace/VSIX extension). It has zero runtime==='vscode'
branches in bin/install.js and stays that way (regression-guarded); it is driven
entirely through the negotiated imperative Host-Integration adapter.

Registry + validator (the hard part):
- capabilities/vscode/capability.json (role:runtime): full hostIntegration block
  (imperative / palette / active vscode.lm model / engine hook bus /
  sandboxed-storage / mcp transport / sandboxed-web runtime; dispatch nested,
  maxDepth 5 per VS Code's documented subagent depth).
- capability-validator.cjs extended so a role:runtime capability can legitimately
  declare "extension-distributed, no config directory": new configHome.kind:'none'
  + installSurface:'none' (+ GATE-A pairing + the parity maps), with localConfigDir
  and configHome.name made conditional on kind!=='none'. All 18 runtimes still
  validate; getDirName returns a distinct sentinel (not '.claude') for a no-config
  runtime.
- The add-a-registry-runtime tax: NON_INSTALLABLE_RUNTIMES exemption in the
  runtime-flags drift guard, vscode added to global-config-home SPECIAL_CASED,
  EXPECTED_PROFILES.vscode='ide', and the config-adapter/derivation/pin-count
  guards updated. No golden-install fixture, model-catalog, or CONFIGURATION rows
  (vscode never enters allRuntimes).

Dispatch + extension surface:
- Fixed vscode/extension.js's createHub()-no-args bug (every dispatch was
  UnknownCommand, masked by a vacuous reachability test) — now reuses the shared
  dispatchGsdCommand subprocess-shim (Node/desktop); the reachability test is
  tightened to assert real dispatch.
- Promoted the #1933 host binding to a shipped vscode/host-binding.js; activate()
  now composes the model/hookBus/stateIO seams through it. Corrected the model
  seam to VS Code's real API (vscode.lm.selectChatModels() -> model.sendRequest();
  vscode.lm.sendRequest does not exist) so the binding actually composes on real
  desktop VS Code instead of throwing.
- New vscode/browser.js Web Extension entry with ZERO Node APIs (the engine's
  config/capability loading is Node-bound, so the web entry registers the surface
  and directs full dispatch to the native MCP server — honestly documented).
- UPGRADE 1: GSD skills as native Language Model Tools (contributes.languageModelTools
  + vscode.lm.registerTool), invoke() dispatching through the hub.
- UPGRADE 2: native subagent dispatch wired onto #runSubagent /
  chat.subagents.allowInvocationsFromSubagents (fail-soft on API availability,
  maxDepth 5 enforced).
- vscode/package.json: browser entry, engines.vscode ^1.105, chatParticipants +
  languageModelTools contributions; fixed a stale activationPoints->activationEvents
  manifest key. Added "vscode" to the package files array.

Docs (## vscode matrix section) + changeset (Added).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Tom Boucher
2026-07-11 22:35:18 -04:00
parent 97297d7e3f
commit a0fafedfa0
28 changed files with 1973 additions and 157 deletions

View File

@@ -0,0 +1,5 @@
---
type: Added
pr: 2210
---
**GSD now drives VS Code through the Embeddable Orchestration System** — the VS Code extension is rewired through the negotiated imperative Host-Integration adapter (active `vscode.lm` model, engine hook bus, sandboxed storage), gains native Language Model Tools (GSD skills as `#gsd-*` tools) and `#runSubagent` dispatch, and runs as a Web Extension (no Node APIs). (#2103)

View File

@@ -0,0 +1,51 @@
{
"id": "vscode",
"role": "runtime",
"version": "1.7.0-rc.5",
"title": "VS Code",
"description": "VS Code — Marketplace/VSIX extension; no file-projected config directory; IDE-profile reference host (active vscode.lm model, engine-owned hook bus, sandboxed globalState/workspaceState stateIO).",
"tier": "core",
"requires": [],
"engines": {
"gsd": ">=1.7.0"
},
"runtime": {
"configHome": {
"kind": "none",
"name": "vscode",
"env": []
},
"localConfigDir": null,
"configFormat": "none",
"artifactLayout": {
"global": [],
"local": []
},
"commandStyle": "slash-hyphen",
"hooksSurface": "none",
"extensionEvents": "none",
"sandboxTier": "none",
"supportTier": 1,
"installSurface": "none",
"writesSharedSettings": false,
"permissionWriter": null,
"extendedHookEvents": [],
"hostIntegration": {
"embeddingMode": "imperative",
"commandSurface": "palette",
"dispatch": {
"namedDispatch": true,
"nested": true,
"maxDepth": 5,
"background": true,
"subagentToolkit": "undocumented",
"backgroundDispatch": "undocumented"
},
"modelMode": "active",
"hookBus": "engine",
"stateIO": "sandboxed-storage",
"transport": "mcp",
"runtime": "sandboxed-web"
}
}
}

View File

@@ -72,7 +72,7 @@ points.
| `tdd` | feature | full | `>=1.6.0` | `plan:pre`, `execute:post` | contribution, gate | first-party |
| `ui` | feature | full | `>=1.6.0` | `plan:pre`, `execute:wave:post`, `verify:post` | step, gate | first-party |
### Runtime capabilities (role: runtime) — 17
### Runtime capabilities (role: runtime) — 18
Runtime capabilities adapt GSD to a specific AI runtime or IDE — emitting
skills, agents, hooks configuration, and surface files for that host. They
@@ -96,6 +96,7 @@ emission), so their extension-point and hook-kind cells are `—`.
| `pi` | runtime | core | `>=1.7.0` | — | — | first-party |
| `qwen` | runtime | core | `>=1.6.0` | — | — | first-party |
| `trae` | runtime | core | `>=1.6.0` | — | — | first-party |
| `vscode` | runtime | core | `>=1.7.0` | — | — | first-party |
| `windsurf` | runtime | core | `>=1.6.0` | — | — | first-party |
| `zcode` | runtime | core | `>=1.6.0` | — | — | first-party |

View File

@@ -691,3 +691,80 @@ EoS migration status (#2102 Stage 2, ADR-1239): Stage 1's "in-process `gsd-core`
**Adversarial-review correction (#2102 Stage 2, post-review):** the event bridges above and the `/gsd` tokenizer's `hooks/lib/git-cmd.js` require were DEAD in a real install — Stage 1's `hostBehaviors.skipSharedHooksInstall:true` meant pi shipped NO `hooks/` directory at all, so `runHook('gsd-ensure-canonical-path.js', ...)` etc. always hit the "hook file absent → silent no-op" branch, and the tokenizer always fell back to plain whitespace-splitting. The tests masked this because they run against the dev tree, where `hooks/` genuinely exists. **Fix:** `capabilities/pi/capability.json` no longer sets `skipSharedHooksInstall` — pi is architecturally identical to OpenCode here (`hooksSurface: "none"` + a native extension that spawns the staged hooks), not to Kilo/ZCode (`hooksSurface: "none"` with NO plugin surface, where the same hooks genuinely are dead weight). pi now installs `hooks/` + `hooks/lib/` (27 entries: the same `INSTALLED_HOOK_FILES` set OpenCode gets) alongside `extensions/gsd.cjs`, verified end-to-end via a real `node bin/install.js --pi --global`/`--local` — `resolveEngineRoot`'s walk-up from the installed extension's own directory finds `ENGINE_ROOT/hooks/{gsd-ensure-canonical-path.js,gsd-workflow-guard.js,gsd-context-monitor.js,lib/git-cmd.js}`, and each bridge/`runHook` call exits 0 against the real installed files. `hooksSurface: "none"` + `configFormat: "none"` + `writesSharedSettings: false` are unaffected — no settings/hooks.json/config.toml is written for pi; the extension spawns hooks by absolute path, not via a config-file hook bus. `tests/fixtures/golden-install-parity/pi.json` grew from 292 → 320 entries (the 28 new `hooks/`/`hooks/lib/` files); `commands/`, `agents/`, `skills/` remain absent (`pluginOnlyInstall` is untouched — it only gates the declarative-markdown surfaces, not hooks). `tests/install-minimal-hooks.test.cjs`'s #1821 suite moved pi from the Kilo/ZCode (no-hooks) group into the OpenCode (ships-hooks) group accordingly.
## vscode
> VS Code is the IDE-profile reference host: a Marketplace/VSIX-distributed extension, NOT
> file-projected onto a config directory — it has no `runtime.localConfigDir` in the usual sense
> (`configHome.kind: "none"`, `localConfigDir: null`) and no CLI install surface at all
> (`installSurface: "none"`; it is never installed by `bin/install.js` — no `--vscode` flag, no
> `allRuntimes` membership; see `capabilities/vscode/capability.json`). The extension IS the host.
> **Sourcing note:** the citations below are the VS Code extension API documentation pages named
> in ADR-1239 (#2103) as the source for each axis; this environment did not have live Context7/
> web-fetch access at authoring time, so the Evidence column is a paraphrase of VS Code's
> documented extension model rather than a verbatim excerpt — a maintainer with Context7/web
> access should verify the exact wording before treating this section as fully cited (same caveat
> already flagged for the pi section above).
| Axis | Value | Source | Evidence |
|---|---|---|---|
| embeddingMode | imperative | https://code.visualstudio.com/api/references/vscode-api | The extension is loaded in-process by the extension host and calls the `vscode` namespace API directly (`vscode.commands.registerCommand`, `vscode.chat.createChatParticipant`, `vscode.lm.registerTool`) — an in-process programmatic API, not a config-file-only integration. |
| commandSurface | palette | https://code.visualstudio.com/api/extension-guides/command | Commands are contributed via `contributes.commands` in package.json and registered with `vscode.commands.registerCommand`, surfaced through the Command Palette (and the Chat view via the chat participant) — not a markdown/TOML slash-command file format. |
| modelMode | active | https://code.visualstudio.com/api/extension-guides/ai/language-model | The `vscode.lm` namespace lets an extension actively select a model (`vscode.lm.selectChatModels`) and send requests to it programmatically, rather than only reading a static config value. |
| hookBus | engine | https://code.visualstudio.com/api/references/activation-events | VS Code has no cross-extension lifecycle-hook bus that GSD subscribes to; the extension host (the "engine" here, per this axis's own `host`/`engine`/`none` vocabulary) owns activation events, and GSD's own hook lifecycle runs fully in-process/engine-owned inside the extension. |
| stateIO | sandboxed-storage | https://code.visualstudio.com/api/references/vscode-api#Memento | `context.globalState`/`context.workspaceState` (both `Memento`) are the extension's persistent storage surface — sandboxed key/value storage scoped to the extension, not unrestricted local filesystem access. |
| transport | mcp | https://code.visualstudio.com/api/extension-guides/ai/mcp | VS Code 1.99 added native MCP client support; on the Web (webworker) entry, full GSD command dispatch is available through VS Code's native MCP client connecting to the GSD companion MCP server (`gsd-mcp-server`), not an in-process Node dispatch (which the web entry cannot run at all). |
| runtime | sandboxed-web | https://code.visualstudio.com/api/extension-guides/web-extensions | The `browser` entry point (`vscode/browser.js`) runs in a webworker context with no Node core modules — the Web Extension execution model VS Code documents for extensions that must run in vscode.dev/github.dev. |
| dispatch.namedDispatch | true | https://code.visualstudio.com/docs/copilot/chat/chat-agent-mode#_agent-mode-tools | Registered `languageModelTools` (and the chat participant) are addressable by name — the primary agent references a tool/participant by its declared name/`toolReferenceName`, not only positionally. |
| dispatch.nested | true | https://code.visualstudio.com/docs/copilot/copilot-chat-agents (subagents) | VS Code's chat subagent model (`#runSubagent`) explicitly supports a subagent invoking further subagents, gated by `chat.subagents.allowInvocationsFromSubagents`. |
| dispatch.maxDepth | 5 | https://code.visualstudio.com/docs/copilot/copilot-chat-agents (subagents) | Documented as VS Code's maximum nesting depth for `#runSubagent` chains — also matches this repo's existing `PROFILE_BASELINES.ide.dispatch.maxDepth` baseline. |
| dispatch.background | true | https://code.visualstudio.com/api/extension-guides/ai/tools | Language Model Tools can be invoked as part of an asynchronous agent turn (the primary agent does not block synchronously on a single extension call). |
| dispatch.subagentToolkit | undocumented | no authoritative doc found at authoring time | VS Code's subagent documentation does not state whether a subagent's tool surface is restricted to read-only tools or the full set an extension registers; recorded `undocumented` (fails closed to `read-only` in negotiation) rather than guessed. |
| dispatch.backgroundDispatch | undocumented | no authoritative doc found at authoring time | Whether a background-dispatched subagent can itself spawn further NAMED subagents (the #853 discriminator) is not stated in the sources reviewed; recorded `undocumented` (fails closed to `false`) rather than guessed. |
Sources consulted:
- https://code.visualstudio.com/api/references/vscode-api
- https://code.visualstudio.com/api/extension-guides/command
- https://code.visualstudio.com/api/extension-guides/ai/language-model
- https://code.visualstudio.com/api/extension-guides/ai/tools
- https://code.visualstudio.com/api/extension-guides/ai/mcp
- https://code.visualstudio.com/api/extension-guides/web-extensions
- https://code.visualstudio.com/api/references/activation-events
- https://code.visualstudio.com/docs/copilot/copilot-chat-agents
Documentation gaps:
- dispatch.subagentToolkit / dispatch.backgroundDispatch — the reviewed sources document that
`#runSubagent` exists (v1.105+, `chat.subagents.allowInvocationsFromSubagents`, max nesting
depth 5) but do not state the subagent tool-restriction model or whether a background-dispatched
subagent can itself spawn further named subagents; both stay `undocumented` and negotiation
fails closed.
- This section's Evidence-column wording was authored without live Context7/web-fetch access (see
the sourcing note above the table) — verify against the cited pages before relying on it for a
future capability upgrade, same caveat as the pi section above.
EoS migration status (#2103): vscode lands as a registry runtime (role:runtime) for
validator/host-integration coverage ONLY — it is deliberately NOT a CLI-installable runtime
(`installSurface: "none"`, never in `bin/install.js`'s `allRuntimes`; see the
`NON_INSTALLABLE_RUNTIMES` carve-out in `tests/runtime-flags.test.cjs`). The extension surface
(`vscode/extension.js`, `vscode/browser.js`, `vscode/host-binding.js`, `vscode/package.json`) is
distributed via the Marketplace/VSIX, not `npx --vscode` — there is no `docs/how-to/install-on-
your-runtime.md` entry for it. Dispatch is SUBPROCESS REUSE on desktop (the same shared
`dispatchGsdCommand` in `gsd-core/bin/lib/shell-command-projection.cjs` the pi extension and the
companion MCP server use) via `vscode/extension.js`'s `main` entry (Node). The `browser` entry
(`vscode/browser.js`) is a SEPARATE, independently zero-Node-API file: it does NOT require
`host-binding.js` because that module's engine-lib dependencies (`state-io.cjs`,
`adapter-imperative.cjs` → `install-engine.cjs`/`capability-loader.cjs`,
`model-adapter.cjs` → `model-resolver.cjs` → `config-loader.cjs`/`configuration.cjs`) all pull in
Node's `fs`/`os`/`path` at module-load time — requiring any of them from a webworker context would
throw immediately. `browser.js` instead composes its own minimal surface directly against
`vscode.lm`, and its command/tool/chat handlers surface an honest "full dispatch is unavailable on
web; configure the GSD MCP server" message rather than a silent failure. The chat participant
(`@gsd`) and Language Model Tools (a representative 3-tool set — `gsd_progress`, `gsd_workstreams`,
`gsd_plan_phase` — matching real shipped skills that map onto a single, safe, read-only
`gsd-tools.cjs` command) are registered on BOTH entries identically; only the dispatch behavior
differs. `#runSubagent` wiring (`registerSubagentDispatch`/`dispatchAsSubagent`, gated on
`chat.subagents.allowInvocationsFromSubagents` availability, fail-soft on older/Insiders-gated
hosts) adds a belt-and-suspenders `maxDepth: 5` ceiling independent of whatever VS Code's own chat
engine enforces natively — there is no separate extension-side "subagent contribution"
registration API beyond the chat participant + Language Model Tools already registered; VS Code's
chat engine surfaces them to `#runSubagent` on its own.

View File

@@ -2756,6 +2756,57 @@ const capabilities = {
}
]
},
"vscode": {
"id": "vscode",
"role": "runtime",
"version": "1.7.0-rc.5",
"title": "VS Code",
"description": "VS Code — Marketplace/VSIX extension; no file-projected config directory; IDE-profile reference host (active vscode.lm model, engine-owned hook bus, sandboxed globalState/workspaceState stateIO).",
"tier": "core",
"requires": [],
"engines": {
"gsd": ">=1.7.0"
},
"runtime": {
"configHome": {
"kind": "none",
"name": "vscode",
"env": []
},
"localConfigDir": null,
"configFormat": "none",
"artifactLayout": {
"global": [],
"local": []
},
"commandStyle": "slash-hyphen",
"hooksSurface": "none",
"extensionEvents": "none",
"sandboxTier": "none",
"supportTier": 1,
"installSurface": "none",
"writesSharedSettings": false,
"permissionWriter": null,
"extendedHookEvents": [],
"hostIntegration": {
"embeddingMode": "imperative",
"commandSurface": "palette",
"dispatch": {
"namedDispatch": true,
"nested": true,
"maxDepth": 5,
"background": true,
"subagentToolkit": "undocumented",
"backgroundDispatch": "undocumented"
},
"modelMode": "active",
"hookBus": "engine",
"stateIO": "sandboxed-storage",
"transport": "mcp",
"runtime": "sandboxed-web"
}
}
},
"windsurf": {
"id": "windsurf",
"role": "runtime",
@@ -5290,6 +5341,57 @@ const runtimes = {
}
}
},
"vscode": {
"id": "vscode",
"role": "runtime",
"version": "1.7.0-rc.5",
"title": "VS Code",
"description": "VS Code — Marketplace/VSIX extension; no file-projected config directory; IDE-profile reference host (active vscode.lm model, engine-owned hook bus, sandboxed globalState/workspaceState stateIO).",
"tier": "core",
"requires": [],
"engines": {
"gsd": ">=1.7.0"
},
"runtime": {
"configHome": {
"kind": "none",
"name": "vscode",
"env": []
},
"localConfigDir": null,
"configFormat": "none",
"artifactLayout": {
"global": [],
"local": []
},
"commandStyle": "slash-hyphen",
"hooksSurface": "none",
"extensionEvents": "none",
"sandboxTier": "none",
"supportTier": 1,
"installSurface": "none",
"writesSharedSettings": false,
"permissionWriter": null,
"extendedHookEvents": [],
"hostIntegration": {
"embeddingMode": "imperative",
"commandSurface": "palette",
"dispatch": {
"namedDispatch": true,
"nested": true,
"maxDepth": 5,
"background": true,
"subagentToolkit": "undocumented",
"backgroundDispatch": "undocumented"
},
"modelMode": "active",
"hookBus": "engine",
"stateIO": "sandboxed-storage",
"transport": "mcp",
"runtime": "sandboxed-web"
}
}
},
"windsurf": {
"id": "windsurf",
"role": "runtime",
@@ -5667,6 +5769,7 @@ const _requiresGraph = {
"tdd": [],
"trae": [],
"ui": [],
"vscode": [],
"windsurf": [],
"zcode": []
};

View File

@@ -703,7 +703,9 @@ const VALID_CONVERTER_NAMES = new Set([
// C3: Validate role:runtime body
const VALID_CONFIG_FORMATS = new Set(['settings-json', 'toml', 'markdown', 'markdown-dir', 'none']);
const VALID_CONFIG_HOME_KINDS = new Set(['dot-home', 'dot-home-nested', 'xdg', 'generic-agents-root']);
// 'none' added #2103 — Marketplace/VSIX-distributed hosts (e.g. VS Code) with
// no file-projected config directory at all.
const VALID_CONFIG_HOME_KINDS = new Set(['dot-home', 'dot-home-nested', 'xdg', 'generic-agents-root', 'none']);
const VALID_COMMAND_STYLES = new Set(['slash-hyphen', 'shell-var']);
const VALID_HOOKS_SURFACES = new Set(['settings-json', 'codex-hooks-json', 'cursor-hooks-json', 'copilot-inline', 'cline-rules', 'kimi-hooks-toml', 'windsurf-hooks-json', 'none']);
const VALID_HOOK_EVENTS = new Set(['claude', 'gemini']);
@@ -716,7 +718,9 @@ const VALID_SANDBOX_TIERS = new Set(['none', 'codex-agent-sandbox']);
const VALID_ARTIFACT_KIND_NAMES = new Set(['commands', 'agents', 'skills', 'kimi-agents']);
const VALID_ARTIFACT_NESTINGS = new Set(['flat', 'nested']);
const FEATURE_FIELDS_FORBIDDEN_ON_RUNTIME = ['skills', 'agents', 'steps', 'contributions', 'gates', 'hooks', 'activationKey'];
const VALID_INSTALL_SURFACES = new Set(['settings-json', 'codex-toml', 'copilot-instructions', 'cline-rules', 'cursor-hooks-json', 'profile-marker-only']);
// 'none' added #2103 — Marketplace/VSIX-distributed hosts (e.g. VS Code) that
// are never CLI-installed (no allRuntimes membership, no install flag).
const VALID_INSTALL_SURFACES = new Set(['settings-json', 'codex-toml', 'copilot-instructions', 'cline-rules', 'cursor-hooks-json', 'profile-marker-only', 'none']);
// 'antigravity' added #2096 Phase B Upgrade 1 — settings.json permissions.allow writer.
const VALID_PERMISSION_WRITERS = new Set(['opencode', 'kilo', 'antigravity']);
// SubagentStart added #2092 Phase B Upgrade 2 (qwen-only today — see
@@ -742,6 +746,9 @@ const INSTALL_SURFACE_TO_ALLOWED_HOOKS_SURFACES = new Map([
['cline-rules', new Set(['cline-rules'])],
['cursor-hooks-json', new Set(['cursor-hooks-json'])],
['profile-marker-only', new Set(['none', 'kimi-hooks-toml', 'windsurf-hooks-json'])],
// 'none' added #2103 — VS Code has no CLI install surface at all; its only
// valid hooksSurface pairing is the other 'none' (engine owns the hook bus).
['none', new Set(['none'])],
]);
// GATE B: extended hook event families → required hookEvents value
@@ -778,9 +785,18 @@ function validateConfigHome(capId, ch) {
);
}
// name — required string
if (typeof ch.name !== 'string' || ch.name.length === 0) {
errors.push(ctx + '.name must be a non-empty string');
// name — required string, except when kind === 'none': the runtime has no
// file-projected config directory at all, so a descriptive name is
// optional (a carve-out mirroring the dot-home-nested⇒parent conditional
// below, not a new validation mechanism). If present it must still be a
// non-empty string (e.g. vscode's configHome.name stays a descriptive
// "vscode" string even though it is never used to build a path).
if (ch.kind !== 'none') {
if (typeof ch.name !== 'string' || ch.name.length === 0) {
errors.push(ctx + '.name must be a non-empty string');
}
} else if (ch.name !== undefined && (typeof ch.name !== 'string' || ch.name.length === 0)) {
errors.push(ctx + '.name must be a non-empty string if present when kind is "none"');
}
// parent — required when kind == dot-home-nested
@@ -1061,15 +1077,27 @@ function validateRuntimeBody(cap) {
// localConfigDir — REQUIRED non-empty dot-dir string (ADR-1239 Phase B #1679)
// Must start with '.' (e.g. ".claude", ".cursor"). Validated here so the registry
// generator catches any descriptor missing the field before regenerating.
if (typeof r.localConfigDir !== 'string' || r.localConfigDir.length === 0) {
//
// #2103: conditional on configHome.kind !== 'none' — a Marketplace/VSIX
// host with no file-projected config directory (e.g. VS Code) has no
// local dir to name; localConfigDir may be null/absent for such runtimes.
const configHomeKind = (r.configHome && typeof r.configHome === 'object') ? r.configHome.kind : undefined;
if (configHomeKind !== 'none') {
if (typeof r.localConfigDir !== 'string' || r.localConfigDir.length === 0) {
errors.push(
'runtime.localConfigDir is required and must be a non-empty string (e.g. ".claude"); ' +
'got: ' + JSON.stringify(r.localConfigDir),
);
} else if (!r.localConfigDir.startsWith('.')) {
errors.push(
'runtime.localConfigDir must start with "." (a dot-dir); got: ' + JSON.stringify(r.localConfigDir),
);
}
} else if (r.localConfigDir !== null && r.localConfigDir !== undefined) {
errors.push(
'runtime.localConfigDir is required and must be a non-empty string (e.g. ".claude"); ' +
'runtime.localConfigDir must be null or absent when configHome.kind is "none"; ' +
'got: ' + JSON.stringify(r.localConfigDir),
);
} else if (!r.localConfigDir.startsWith('.')) {
errors.push(
'runtime.localConfigDir must start with "." (a dot-dir); got: ' + JSON.stringify(r.localConfigDir),
);
}
// extendedHookEvents — required array; every element must be in closed enum
@@ -2175,6 +2203,9 @@ const INSTALL_SURFACE_TO_CONFIG_FORMAT = new Map([
['cline-rules', 'markdown-dir'],
['cursor-hooks-json', 'none'],
['profile-marker-only', 'none'],
// 'none' added #2103 — a runtime with NO CLI install surface at all (e.g.
// VS Code) has no config-file format to write either.
['none', 'none'],
]);
/**

View File

@@ -21,7 +21,8 @@
"GEMINI.md",
"hooks",
"scripts",
"pi"
"pi",
"vscode"
],
"keywords": [
"claude",

View File

@@ -46,7 +46,11 @@ type ConfigInstallSurface =
| 'copilot-instructions'
| 'cline-rules'
| 'cursor-hooks-json'
| 'profile-marker-only';
| 'profile-marker-only'
// #2103 — Marketplace/VSIX-distributed hosts (e.g. VS Code) with no CLI
// install surface at all. Never dispatched through install()/finishInstall()
// (see the ALLOWED_CONFIG_RUNTIMES filter below, which excludes it).
| 'none';
type FinishPermissionWriter = 'opencode' | 'kilo' | 'antigravity' | null;
@@ -89,10 +93,20 @@ interface InstallPlan extends RuntimeConfigIntent {
type RuntimeDescriptorMap = Record<string, { runtime: Record<string, unknown> | undefined }>;
/** The complete set of 16 supported runtimes for config-adapter dispatch. */
/**
* The complete set of 16 supported runtimes for config-adapter dispatch.
*
* Excludes runtimes whose installSurface is 'none' (#2103 — e.g. VS Code): a
* 'none' installSurface means the runtime has NO CLI install surface at all
* (Marketplace/VSIX-distributed, never dispatched through
* install()/finishInstall()), so it is not a "config-adapter runtime" by
* definition. This keeps this set in lockstep with bin/install.js's
* `allRuntimes` (see tests/issue-57-runtime-install-no-drift.test.cjs) without
* needing a separate hand-kept exclusion list.
*/
const ALLOWED_CONFIG_RUNTIMES: ReadonlySet<string> = new Set(
Object.entries(runtimes)
.filter(([, cap]) => cap && cap.runtime && typeof cap.runtime['installSurface'] === 'string')
.filter(([, cap]) => cap && cap.runtime && typeof cap.runtime['installSurface'] === 'string' && cap.runtime['installSurface'] !== 'none')
.map(([id]) => id),
);
@@ -104,6 +118,7 @@ const INSTALL_SURFACES: ReadonlyArray<ConfigInstallSurface> = Object.freeze([
'cline-rules',
'cursor-hooks-json',
'profile-marker-only',
'none',
]);
/**

View File

@@ -145,11 +145,38 @@ export function getProjectInstructionFile(runtime: unknown): string {
return 'AGENTS.md';
}
/**
* Sentinel returned by {@link getDirName} for a runtime whose
* `runtime.configHome.kind === 'none'` (#2103 — a Marketplace/VSIX-distributed
* host with NO file-projected config directory at all, e.g. VS Code).
*
* A plain fallback to `.claude` would be actively wrong here — it would read
* as "this runtime installs into .claude", which is false. This sentinel is
* a string (not `null`) so `getDirName`'s return type and every existing
* template-literal call site (`` `${getDirName(runtime)}` `` in bin/install.js
* / runtime-artifact-conversion.cjs / install-engine.cjs) are unaffected —
* widening the return type to `string | null` would require auditing every
* call site for a null-check, which is out of scope for a runtime that is
* never actually dispatched through those installer paths (vscode has no
* install surface — see capabilities/vscode/capability.json). The value is
* deliberately NOT a plausible dot-dir name (parens are not valid in a
* directory-name token GSD would ever generate) so a future caller that
* mistakenly interpolates it into a path fails obviously rather than
* silently colliding with a real directory.
*/
export const NO_LOCAL_CONFIG_DIR_SENTINEL = '(no-local-config-dir)';
/**
* Map a canonical runtime id to its on-disk local config directory name
* (e.g. `cursor` -> `.cursor`, `windsurf` -> `.windsurf`). Unknown/empty inputs
* fall back to `.claude`.
*
* #2103: a runtime whose descriptor declares `configHome.kind === 'none'`
* (no file-projected config directory at all) returns
* {@link NO_LOCAL_CONFIG_DIR_SENTINEL} instead of falling through to
* `.claude` — it has no local config dir, and `.claude` would be a wrong
* answer, not just an imprecise one.
*
* Pure runtime-identity projection. Relocated from `bin/install.js` per
* ADR-1508 (epic #1507, #1510 Phase 1) so the Runtime Artifact Conversion
* Module's rewrite engine can consume it without importing the installer.
@@ -159,10 +186,12 @@ export function getDirName(runtime: string): string {
if (!runtime) return '.claude';
// eslint-disable-next-line @typescript-eslint/no-require-imports
const { runtimes } = require('./capability-registry.cjs') as {
runtimes: Record<string, { runtime?: { localConfigDir?: string } } | undefined>;
runtimes: Record<string, { runtime?: { localConfigDir?: string; configHome?: { kind?: string } } } | undefined>;
};
const dir = runtimes[runtime]?.runtime?.localConfigDir;
const entry = runtimes[runtime]?.runtime;
const dir = entry?.localConfigDir;
if (typeof dir === 'string' && dir.length > 0) return dir;
if (entry?.configHome?.kind === 'none') return NO_LOCAL_CONFIG_DIR_SENTINEL;
return '.claude';
}
@@ -211,6 +240,11 @@ const RUNTIME_LABELS: Readonly<Record<string, string>> = {
cline: 'Cline',
zcode: 'ZCode',
pi: 'pi',
// #2103: vscode is a registered (role:runtime) capability for validator +
// host-integration coverage, even though it is never CLI-installed (no
// --vscode flag — see NON_INSTALLABLE_RUNTIMES in tests/runtime-flags.test.cjs).
// A distinct label is still required by the drift guard below.
vscode: 'VS Code',
};
/**

View File

@@ -3952,12 +3952,14 @@ describe('FIX 3: tightened runtime validator — probeExists optional string', (
// ── 24e. Closed-vocab set exports are correct ─────────────────────────────────
describe('ADR-1016 phase 5a: closed-vocab set exports', () => {
test('VALID_CONFIG_HOME_KINDS has exactly the 4 expected values', () => {
test('VALID_CONFIG_HOME_KINDS has exactly the 5 expected values', () => {
assert.ok(VALID_CONFIG_HOME_KINDS instanceof Set);
for (const v of ['dot-home', 'dot-home-nested', 'xdg', 'generic-agents-root']) {
// 'none' added #2103 — a runtime with NO file-projected config directory
// at all (e.g. vscode — Marketplace/VSIX-distributed).
for (const v of ['dot-home', 'dot-home-nested', 'xdg', 'generic-agents-root', 'none']) {
assert.ok(VALID_CONFIG_HOME_KINDS.has(v), 'VALID_CONFIG_HOME_KINDS must contain "' + v + '"');
}
assert.strictEqual(VALID_CONFIG_HOME_KINDS.size, 4, 'VALID_CONFIG_HOME_KINDS must have exactly 4 members');
assert.strictEqual(VALID_CONFIG_HOME_KINDS.size, 5, 'VALID_CONFIG_HOME_KINDS must have exactly 5 members');
});
test('VALID_COMMAND_STYLES has exactly 2 values', () => {
@@ -4261,15 +4263,18 @@ describe('ADR-857 phase 5e: configFormat ↔ installSurface parity gate', () =>
});
// INSTALL_SURFACE_TO_CONFIG_FORMAT export check
test('INSTALL_SURFACE_TO_CONFIG_FORMAT covers all 6 installSurface values with correct mappings', () => {
test('INSTALL_SURFACE_TO_CONFIG_FORMAT covers all 7 installSurface values with correct mappings', () => {
assert.ok(INSTALL_SURFACE_TO_CONFIG_FORMAT instanceof Map, 'Must be a Map');
assert.strictEqual(INSTALL_SURFACE_TO_CONFIG_FORMAT.size, 6, 'Must cover 6 installSurface values');
assert.strictEqual(INSTALL_SURFACE_TO_CONFIG_FORMAT.size, 7, 'Must cover 7 installSurface values');
assert.strictEqual(INSTALL_SURFACE_TO_CONFIG_FORMAT.get('settings-json'), 'settings-json');
assert.strictEqual(INSTALL_SURFACE_TO_CONFIG_FORMAT.get('codex-toml'), 'toml');
assert.strictEqual(INSTALL_SURFACE_TO_CONFIG_FORMAT.get('copilot-instructions'), 'markdown');
assert.strictEqual(INSTALL_SURFACE_TO_CONFIG_FORMAT.get('cline-rules'), 'markdown-dir');
assert.strictEqual(INSTALL_SURFACE_TO_CONFIG_FORMAT.get('cursor-hooks-json'), 'none');
assert.strictEqual(INSTALL_SURFACE_TO_CONFIG_FORMAT.get('profile-marker-only'), 'none');
// 'none' added #2103 — a runtime with no CLI install surface at all (e.g.
// vscode) has no config-file format to write either.
assert.strictEqual(INSTALL_SURFACE_TO_CONFIG_FORMAT.get('none'), 'none');
});
// Feature capabilities (role:feature) are silently ignored by the gate
@@ -4432,10 +4437,71 @@ describe('ADR-857 phase 5f: cross-field consistency gate rejection tests (DEFECT
);
});
// #2103: configHome.kind==='none' carve-out (VS Code — Marketplace/VSIX, no
// file-projected config directory). Adversarial review flagged AC4's
// "accept vscode AND reject a faked configHome" requirement as untested —
// the real vscode descriptor validating clean (tests/capability-registry.test.cjs's
// "ADR-1016 phase 5a" suite, capability-validator-parity tests, etc.) only
// proves the ACCEPT half. These three tests are the REJECT half: they prove
// the two new branches in validateRuntimeBody/validateConfigHome actually
// fire, quoting their EXACT error strings so a future edit that silently
// weakens either branch fails loudly here.
describe('#2103 configHome.kind==="none" carve-out (vscode) — accept/reject pair', () => {
test('ACCEPT: configHome.kind:"none" + localConfigDir:null + installSurface:"none" + hooksSurface:"none" validates with ZERO errors', () => {
const cap = makeValidRuntimeCap({
runtime: {
configHome: { kind: 'none', name: 'test-none-rt', env: [] },
localConfigDir: null,
configFormat: 'none',
installSurface: 'none',
hooksSurface: 'none',
},
});
const errors = validateRuntimeBody(cap);
assert.deepEqual(errors, [], 'a genuinely kind:"none" descriptor (vscode-shaped) must validate clean, got: ' + JSON.stringify(errors));
});
test('REJECT #1: localConfigDir non-null while configHome.kind==="none" → exact validateRuntimeBody error', () => {
const cap = makeValidRuntimeCap({
runtime: {
configHome: { kind: 'none', name: 'test-none-rt', env: [] },
localConfigDir: '.vscode', // faked — a kind:'none' descriptor must not also claim a local dir
configFormat: 'none',
installSurface: 'none',
hooksSurface: 'none',
},
});
const errors = validateRuntimeBody(cap);
assert.deepEqual(
errors,
['runtime.localConfigDir must be null or absent when configHome.kind is "none"; got: ".vscode"'],
'expected the exact localConfigDir-vs-kind:"none" rejection, got: ' + JSON.stringify(errors),
);
});
test('REJECT #2: configHome.name present-but-empty while configHome.kind==="none" → exact validateConfigHome error', () => {
const cap = makeValidRuntimeCap({
runtime: {
configHome: { kind: 'none', name: '', env: [] }, // faked — present name must still be non-empty
localConfigDir: null,
configFormat: 'none',
installSurface: 'none',
hooksSurface: 'none',
},
});
const errors = validateRuntimeBody(cap);
assert.deepEqual(
errors,
['capability "test-runtime" runtime.configHome.name must be a non-empty string if present when kind is "none"'],
'expected the exact configHome.name-vs-kind:"none" rejection, got: ' + JSON.stringify(errors),
);
});
});
// Verify the new constants are well-formed
test('INSTALL_SURFACE_TO_ALLOWED_HOOKS_SURFACES covers all 6 installSurface values', () => {
test('INSTALL_SURFACE_TO_ALLOWED_HOOKS_SURFACES covers all 7 installSurface values', () => {
assert.ok(INSTALL_SURFACE_TO_ALLOWED_HOOKS_SURFACES instanceof Map, 'Must be a Map');
assert.strictEqual(INSTALL_SURFACE_TO_ALLOWED_HOOKS_SURFACES.size, 6, 'Must cover 6 installSurface values');
assert.strictEqual(INSTALL_SURFACE_TO_ALLOWED_HOOKS_SURFACES.size, 7, 'Must cover 7 installSurface values');
for (const installSurface of VALID_INSTALL_SURFACES) {
assert.ok(
INSTALL_SURFACE_TO_ALLOWED_HOOKS_SURFACES.has(installSurface),
@@ -6509,7 +6575,10 @@ describe('enh-1055 descriptor-drive: ALLOWED_CONFIG_RUNTIMES completeness', () =
test('equals the registry runtimes that declare an installSurface', () => {
const descriptorAllowed = new Set(
Object.entries(enh1055Registry.runtimes)
.filter(([, cap]) => cap && cap.runtime && typeof cap.runtime.installSurface === 'string')
// installSurface:'none' (#2103 vscode — extension-distributed, no config
// directory) is NOT a config-adapter runtime: production ALLOWED_CONFIG_RUNTIMES
// excludes it so `allRuntimes === ALLOWED_CONFIG_RUNTIMES` (issue-57) stays true.
.filter(([, cap]) => cap && cap.runtime && typeof cap.runtime.installSurface === 'string' && cap.runtime.installSurface !== 'none')
.map(([id]) => id),
);
assert.deepStrictEqual(new Set(ALLOWED_CONFIG_RUNTIMES), descriptorAllowed);

View File

@@ -1,69 +1,12 @@
'use strict';
/**
* Reference VS Code IDE host binding for GSD (ADR-1239 Phase D / #1933).
* Thin re-export of the SHIPPED VS Code host binding (#2103).
*
* VS Code is the IDE-profile reference host. It composes the Phase-3 engine
* seams for the negotiated `ide` profile (host-integration.cts PROFILE_BASELINES):
*
* - modelMode: 'active' → createModelAdapter({modelMode:'active'}, {sendRequest})
* backed by `vscode.lm` (LanguageModelChat). VS Code rejects
* system-role messages, so the request mapper uses User role only.
* - hookBus: 'engine' → createHookBus({bus:'engine'}) — VS Code has NO host event bus,
* so GSD owns the bus in-process (full subscribe + emit).
* - stateIO: 'sandboxed-storage' → createStateIO({io:'sandboxed-storage'}, {backend}) bound to a
* host-supplied storage (no arbitrary FS — web/no-child_process safe).
* - embeddingMode: 'imperative' → createImperativeAdapter({runtime:'vscode'}) — engine-as-library.
*
* Distribution: VS Code is shipped as an EXTENSION (Marketplace), NOT file-projected onto a
* config dir, so it intentionally has NO runtime descriptor / `--vscode` installer entry — the
* extension IS the host. This module is the binding the extension's activate() runs.
*
* Mock-friendly: takes `vscode` (with `vscode.lm`) + `hostStorage` ({read,write}) so it is
* behaviorally testable without a live VS Code host.
*
* @param {{ lm: { sendRequest: (req: unknown) => unknown } }} vscode VS Code namespace (vscode.lm)
* @param {{ read: (path: string) => string, write: (path: string, content: string) => void }} hostStorage
* sandboxed-storage backend (e.g. globalState/workspaceState/secrets).
* @returns {object} the composed IDE host surface: { runtime, model, hookBus, stateIO, adapter, commands }
* `bindGsdToVscode` moved to `vscode/host-binding.js` (the module the real
* extension's `activate()` requires) so it ships with the extension instead of
* living only under tests/. This fixture re-exports it so every existing test
* that requires `./fixtures/vscode-host-binding.cjs` keeps resolving without
* edits (tests/vscode-ide-reference.test.cjs and any other consumer).
*/
module.exports = function bindGsdToVscode(vscode, hostStorage) {
if (!vscode || !vscode.lm || typeof vscode.lm.sendRequest !== 'function') {
throw new TypeError('bindGsdToVscode: vscode.lm.sendRequest is required (active model provider)');
}
if (!hostStorage || typeof hostStorage.read !== 'function' || typeof hostStorage.write !== 'function') {
throw new TypeError('bindGsdToVscode: hostStorage {read,write} is required (sandboxed-storage backend)');
}
const { createImperativeAdapter } = require('../../gsd-core/bin/lib/adapter-imperative.cjs');
const { createModelAdapter } = require('../../gsd-core/bin/lib/model-adapter.cjs');
const { createHookBus } = require('../../gsd-core/bin/lib/hook-bus.cjs');
const { createStateIO } = require('../../gsd-core/bin/lib/state-io.cjs');
// Active model: GSD model calls route through vscode.lm. (No system-role
// messages — VS Code rejects them; a full extension builds LanguageModelChatMessages
// with User role only and selects a model via vscode.lm.selectChatModels.)
const model = createModelAdapter({ modelMode: 'active' }, {
sendRequest(req) {
return vscode.lm.sendRequest(req);
},
});
// Engine-owned hook bus: VS Code has no host bus, so GSD owns it in-process.
const hookBus = createHookBus({ bus: 'engine' });
// Sandboxed-storage stateIO bound to the host storage backend (no fs / no child_process).
const stateIO = createStateIO({ io: 'sandboxed-storage' }, { backend: hostStorage });
// Imperative adapter: the engine-as-library for the VS Code runtime.
const adapter = createImperativeAdapter({ runtime: 'vscode' });
// Command surface: Command Palette + Chat participant entries bound to the
// GSD command-routing hub via the imperative adapter (interface point 1).
const commands = Object.freeze({
'gsd.invoke': Object.freeze({ description: 'Invoke a GSD command via the embedded engine (palette/chat).' }),
'gsd.help': Object.freeze({ description: 'List GSD commands available in the IDE host.' }),
});
return Object.freeze({ runtime: 'vscode', model, hookBus, stateIO, adapter, commands });
};
module.exports = require('../../vscode/host-binding.js');

View File

@@ -164,15 +164,21 @@ describe('#1928 gemini removed from every runtime-name-policy surface', () => {
assert.strictEqual(getRuntimeNewProjectCommand('gemini'), '/gsd-new-project', 'new-project override removed → default');
});
test('runtimeFlags has no isGemini and covers exactly the non-claude registry runtimes (count-agnostic)', () => {
test('runtimeFlags has no isGemini and covers exactly the non-claude, CLI-installable registry runtimes (count-agnostic)', () => {
const flags = runtimeFlags('claude');
assert.ok(!('isGemini' in flags), 'isGemini flag must be gone');
// The flag set tracks the non-claude registry runtimes (one is<Runtime> per
// id), so adding a runtime updates the count automatically — no hand-pinned
// number that would break on the next runtime addition.
const expectedNonClaudeCount = Object.keys(registry.runtimes).filter((id) => id !== 'claude').length;
// #2103: registry runtimes with installSurface === 'none' (e.g. vscode —
// Marketplace/VSIX-distributed, never CLI-installed) have no --<rt> flag
// by design (see tests/runtime-flags.test.cjs's NON_INSTALLABLE_RUNTIMES)
// and are excluded from this count too.
const expectedNonClaudeCount = Object.keys(registry.runtimes)
.filter((id) => id !== 'claude' && registry.runtimes[id].runtime.installSurface !== 'none')
.length;
assert.strictEqual(Object.keys(flags).length, expectedNonClaudeCount,
'flag count must equal the non-claude registry runtime count');
'flag count must equal the non-claude, CLI-installable registry runtime count');
});
test('gemini no longer maps to GEMINI.md (defaults to AGENTS.md)', () => {

View File

@@ -14,6 +14,13 @@
* - a structural cross-check that every descriptor's localConfigDir is a
* non-empty dot-dir string.
*
* #2103: vscode's `configHome.kind === 'none'` (no file-projected config
* directory at all) is the first descriptor with `localConfigDir: null` — it
* is carved out of the "non-empty dot-dir string" invariant below and
* asserted against getDirName's documented NO_LOCAL_CONFIG_DIR_SENTINEL
* instead (mirrors the carve-out in
* tests/non-claude-runtimes-registry-derivation.test.cjs).
*
* ADR-1239 Phase B (#1679). Behavioral tests only: assert on returned values.
*/
@@ -22,14 +29,36 @@ const assert = require('node:assert/strict');
const runtimeNamePolicy = require('../gsd-core/bin/lib/runtime-name-policy.cjs');
const registry = require('../gsd-core/bin/lib/capability-registry.cjs');
const { getDirName } = runtimeNamePolicy;
const { getDirName, NO_LOCAL_CONFIG_DIR_SENTINEL } = runtimeNamePolicy;
const RUNTIME_IDS = Object.keys(registry.runtimes);
// Runtimes whose configHome.kind === 'none' have NO file-projected config
// directory at all (localConfigDir is legitimately null) — carved out of the
// "non-empty dot-dir string" invariant, the same way dot-home-nested's
// .parent is a conditional carve-out in the validator.
const NO_LOCAL_CONFIG_DIR_RUNTIMES = new Set(
RUNTIME_IDS.filter((id) => {
const desc = registry.runtimes[id] && registry.runtimes[id].runtime;
return !!(desc && desc.configHome && desc.configHome.kind === 'none');
}),
);
test('getDirName(id) projects each descriptor runtime.localConfigDir (derivation contract, count-agnostic)', () => {
assert.ok(RUNTIME_IDS.length > 0, 'registry must contain at least one runtime');
for (const id of RUNTIME_IDS) {
const desc = registry.runtimes[id] && registry.runtimes[id].runtime;
if (NO_LOCAL_CONFIG_DIR_RUNTIMES.has(id)) {
// #2103: no file-projected config dir — getDirName must return the
// documented sentinel, not the '.claude' default and not null.
assert.strictEqual(desc.localConfigDir, null,
`${id}: configHome.kind === 'none' runtimes must declare localConfigDir: null`);
assert.strictEqual(
getDirName(id),
NO_LOCAL_CONFIG_DIR_SENTINEL,
`getDirName('${id}') must equal the documented no-local-config-dir sentinel`);
continue;
}
const expected = desc && desc.localConfigDir;
assert.ok(typeof expected === 'string' && expected.length > 0,
`registry.runtimes['${id}'].runtime.localConfigDir must be a non-empty string`);
@@ -46,11 +75,16 @@ test('getDirName fallback: unknown / empty runtime returns ".claude" (fail-close
assert.strictEqual(getDirName('__nonexistent_runtime__'), '.claude');
});
test('registry cross-check: every runtimes[id].runtime.localConfigDir is a non-empty dot-dir string', () => {
test('registry cross-check: every runtimes[id].runtime.localConfigDir is a non-empty dot-dir string, except configHome.kind==="none" runtimes (localConfigDir: null)', () => {
for (const [id, entry] of Object.entries(registry.runtimes)) {
if (!entry || typeof entry !== 'object') continue;
const runtimeBlock = entry.runtime;
if (!runtimeBlock || typeof runtimeBlock !== 'object') continue;
if (NO_LOCAL_CONFIG_DIR_RUNTIMES.has(id)) {
assert.strictEqual(runtimeBlock.localConfigDir, null,
`registry.runtimes['${id}'].runtime.localConfigDir must be null (configHome.kind === 'none')`);
continue;
}
const dir = runtimeBlock.localConfigDir;
assert.strictEqual(typeof dir, 'string',
`registry.runtimes['${id}'].runtime.localConfigDir must be a string (got: ${typeof dir})`);

View File

@@ -51,8 +51,10 @@ const GOLDEN_FRAGMENT_MAP = {
};
// Runtimes intentionally NOT in the table: claude is the default; antigravity is
// resolved dynamically by the caller (resolveAntigravityGlobalDir + path.relative).
const SPECIAL_CASED = new Set(['claude', 'antigravity']);
// resolved dynamically by the caller (resolveAntigravityGlobalDir + path.relative);
// vscode (#2103) is extension-distributed with no file-projected config home at
// all — getGlobalConfigHomeFragment is never invoked for it (no install surface).
const SPECIAL_CASED = new Set(['claude', 'antigravity', 'vscode']);
test('getGlobalConfigHomeFragment: golden map matches for all 13 table runtimes', () => {
for (const [id, expected] of Object.entries(GOLDEN_FRAGMENT_MAP)) {

View File

@@ -40,7 +40,7 @@ const RUNTIME_IDS = Object.keys(registry.runtimes);
// Contract-pinned profile split (derived from .host-cli-final.json):
// programmatic-cli: claude, cline, cursor, hermes, kilo, kimi, opencode, pi, qwen, trae (10)
// declarative-cli: antigravity, augment, codebuddy, codex, copilot, windsurf, zcode (7)
// ide: 0
// ide: vscode (1) — #2103, the first installed ide-profile host.
const EXPECTED_PROFILES = {
claude: 'programmatic-cli',
cline: 'programmatic-cli',
@@ -59,6 +59,7 @@ const EXPECTED_PROFILES = {
copilot: 'declarative-cli',
windsurf: 'declarative-cli',
zcode: 'declarative-cli',
vscode: 'ide',
};
describe('ADR-1239 Phase A: hostIntegration descriptors', () => {
@@ -215,7 +216,9 @@ describe('ADR-1239 Phase A: hostIntegration descriptors', () => {
test('contract-pin: profile split is internally consistent with EXPECTED_PROFILES (count-agnostic)', () => {
// The counts are DERIVED from the curated EXPECTED_PROFILES map rather than
// hand-pinned, so adding a runtime + its profile entry updates the counts
// automatically. ide must remain 0 (no installed ide-profile host yet).
// automatically. #2103: vscode is now the first installed ide-profile host,
// so 'ide' is no longer pinned at a hardcoded 0 — it is derived below like
// the other two profiles.
const counts = { 'programmatic-cli': 0, 'declarative-cli': 0, 'ide': 0 };
for (const id of RUNTIME_IDS) {
const cap = registry.runtimes[id];
@@ -236,7 +239,8 @@ describe('ADR-1239 Phase A: hostIntegration descriptors', () => {
}
assert.strictEqual(counts['programmatic-cli'], expectedCounts['programmatic-cli']);
assert.strictEqual(counts['declarative-cli'], expectedCounts['declarative-cli']);
assert.strictEqual(counts['ide'], 0, 'no installed host may carry the ide profile yet');
assert.strictEqual(counts['ide'], expectedCounts['ide'],
'ide-profile count must match EXPECTED_PROFILES (#2103: vscode is the first ide-profile host)');
});
// ─── backgroundDispatch presence ─────────────────────────────────────────────
@@ -291,6 +295,10 @@ describe('ADR-1239 Phase A: hostIntegration descriptors', () => {
trae: true,
windsurf: true,
zcode: true,
// #2103: vscode's dispatch.backgroundDispatch is 'undocumented' (no
// documented background-subagent primitive) → fails closed to false →
// force-flattened, mirroring the pi (#2102) precedent above.
vscode: true,
};
for (const id of RUNTIME_IDS) {

View File

@@ -184,6 +184,36 @@ describe('issue-57 AC2 — config-mutation dispatch is closed over the explicit
);
});
// allow-test-rule: structural guard over bin/install.js source (#2103). VS Code
// (capabilities/vscode/capability.json) is a registry runtime (role:runtime, for
// validator/host-integration coverage) but is NEVER CLI-installed — it is a
// Marketplace/VSIX extension with no --vscode flag and no allRuntimes membership
// (see NON_INSTALLABLE_RUNTIMES in tests/runtime-flags.test.cjs). It must stay
// fully descriptor-driven: bin/install.js must never special-case it by name.
// This is a stricter, clearer-failure-message sibling of the generic
// "every inline runtime === ..." guard above (which would also catch this, but
// with a misleading "register it in the adapter registry" suggestion — vscode
// must never be registered there at all, see the ALLOWED_CONFIG_RUNTIMES filter
// in src/runtime-config-adapter-registry.cts).
test('#2103: bin/install.js has ZERO runtime === "vscode" / isVscode branches (vscode stays fully descriptor-driven)', () => {
const src = fs.readFileSync(path.join(ROOT, 'bin', 'install.js'), 'utf8');
const runtimeComparisons = [...src.matchAll(/runtime === (?:'vscode'|"vscode")/g)];
assert.deepStrictEqual(
runtimeComparisons.map((m) => m[0]),
[],
'bin/install.js must not special-case vscode via `runtime === "vscode"` — vscode has no '
+ 'install surface at all (installSurface: "none") and is never CLI-installed; any '
+ 'vscode-specific behavior belongs in capabilities/vscode/capability.json, not an inline branch.',
);
const isVscodeRefs = [...src.matchAll(/\bisVscode\b/g)];
assert.deepStrictEqual(
isVscodeRefs.map((m) => m[0]),
[],
'bin/install.js must not introduce an isVscode flag — vscode is intentionally excluded '
+ 'from runtimeFlags (Marketplace-distributed, never CLI-installed).',
);
});
// allow-test-rule: delegation-presence guard. Catches wholesale removal of the registry
// dispatch (a regression to scattered per-runtime config branching). Presence-style, not
// absence-grep, so it does not bite on incidental non-config `runtime === '...'` checks.

View File

@@ -23,7 +23,7 @@ const registry = require('../gsd-core/bin/lib/capability-registry.cjs');
const runtimeNamePolicy = require('../gsd-core/bin/lib/runtime-name-policy.cjs');
const { NON_CLAUDE_RUNTIMES } = conversion;
const { getDirName } = runtimeNamePolicy;
const { getDirName, NO_LOCAL_CONFIG_DIR_SENTINEL } = runtimeNamePolicy;
// Golden oracle: hardcoded sorted known-good list of all non-Claude runtimes.
// A pinned expected value in a TEST is correct — the test IS the oracle.
@@ -78,3 +78,17 @@ test('DRIFT GUARD: every registry-declared non-Claude runtime has an explicit ge
);
}
});
// #2103: vscode is a registry runtime (role:runtime) whose configHome.kind is
// 'none' — it has NO file-projected config directory at all (Marketplace/VSIX
// extension). It is covered by the generic loop above (its dir must not be
// '.claude'), but that assertion alone would ALSO pass for a plain string
// typo, so this pins the actual documented sentinel value honestly rather
// than riding on the generic "not .claude" check.
test('#2103: getDirName("vscode") returns the documented no-local-config-dir sentinel, not .claude and not a real dot-dir', () => {
assert.ok(NON_CLAUDE_RUNTIMES.includes('vscode'), 'vscode must be a registered non-Claude runtime');
const dir = getDirName('vscode');
assert.equal(dir, NO_LOCAL_CONFIG_DIR_SENTINEL);
assert.notEqual(dir, '.claude');
assert.ok(!dir.startsWith('.'), 'the sentinel must not look like a plausible dot-dir name');
});

View File

@@ -197,15 +197,26 @@ describe('resolveRuntimeConfigIntent — fresh object each call', () => {
// ---------------------------------------------------------------------------
describe('ALLOWED_CONFIG_RUNTIMES completeness', () => {
test('ALLOWED_CONFIG_RUNTIMES equals the registry runtimes that declare an installSurface', () => {
test('ALLOWED_CONFIG_RUNTIMES equals the registry runtimes that declare a real (non-"none") installSurface', () => {
// #2103: installSurface 'none' means "no CLI install surface at all"
// (e.g. vscode — Marketplace/VSIX-distributed, never CLI-installed), so
// it is excluded from the config-adapter runtime set by definition — this
// mirrors the exclusion already baked into the production
// ALLOWED_CONFIG_RUNTIMES filter (src/runtime-config-adapter-registry.cts).
const descriptorAllowed = new Set(
Object.entries(registry.runtimes)
.filter(([, cap]) => cap && cap.runtime && typeof cap.runtime.installSurface === 'string')
.filter(([, cap]) => cap && cap.runtime && typeof cap.runtime.installSurface === 'string' && cap.runtime.installSurface !== 'none')
.map(([id]) => id),
);
assert.deepStrictEqual(new Set(ALLOWED_CONFIG_RUNTIMES), descriptorAllowed);
});
test('#2103: vscode declares installSurface "none" and is registered but intentionally excluded from ALLOWED_CONFIG_RUNTIMES', () => {
assert.strictEqual(registry.runtimes.vscode.runtime.installSurface, 'none');
assert.ok(!ALLOWED_CONFIG_RUNTIMES.has('vscode'),
'vscode must not be a config-adapter runtime — it has no CLI install surface');
});
test('every member of ALLOWED_CONFIG_RUNTIMES resolves without throwing', () => {
for (const runtime of ALLOWED_CONFIG_RUNTIMES) {
assert.doesNotThrow(() => resolveRuntimeConfigIntent(runtime), `${runtime} should resolve without throwing`);
@@ -225,9 +236,11 @@ describe('INSTALL_SURFACES export', () => {
'cline-rules',
'cursor-hooks-json',
'profile-marker-only',
// 'none' added #2103 — vscode has no CLI install surface at all.
'none',
]);
test('INSTALL_SURFACES contains exactly the 6 surface strings', () => {
test('INSTALL_SURFACES contains exactly the 7 surface strings', () => {
assert.deepStrictEqual(new Set(INSTALL_SURFACES), EXPECTED_SURFACES);
});
});

View File

@@ -17,6 +17,14 @@ const EXPECTED_FLAGS = [
'isCodebuddy', 'isCline', 'isKimi', 'isZcode', 'isPi',
];
// #2103: registry runtimes that are NEVER CLI-installed via bin/install.js
// (Marketplace/VSIX-distributed, no --<rt> flag, not in allRuntimes) — these
// have no runtimeFlags entry by design, not by drift. vscode is the first
// (and, today, only) member: it enters `registry.runtimes` (role:runtime, for
// validator/host-integration coverage) but never enters bin/install.js's
// allRuntimes, so it must not be required to have an isVscode flag here.
const NON_INSTALLABLE_RUNTIMES = new Set(['vscode']);
test('runtimeFlags: every known non-claude runtime sets exactly its own flag true', () => {
const ids = EXPECTED_FLAGS.map((f) => f.slice(2).toLowerCase());
for (const id of ids) {
@@ -44,11 +52,27 @@ test('runtimeFlags: all 16 flags present + boolean + the object is frozen', () =
assert.ok(Object.isFrozen(flags), 'flags object must be frozen');
});
test('runtimeFlags drift guard: covers every registry runtime except claude', () => {
test('runtimeFlags drift guard: covers every registry runtime except claude and the non-installable set', () => {
// Adding a registry runtime that is not claude must get a flag or be added to
// RUNTIME_FLAG_IDS — pin the set so a new runtime forces a deliberate update.
const registryNonClaude = Object.keys(registry.runtimes).filter((r) => r !== 'claude').sort();
// NON_INSTALLABLE_RUNTIMES (#2103) is filtered out first: a runtime that is
// never CLI-installed (e.g. vscode — Marketplace/VSIX only) has no --<rt>
// flag by design and must not trip this guard.
const registryNonClaude = Object.keys(registry.runtimes)
.filter((r) => r !== 'claude' && !NON_INSTALLABLE_RUNTIMES.has(r))
.sort();
const flagIds = EXPECTED_FLAGS.map((f) => f.slice(2).toLowerCase()).sort();
const missing = registryNonClaude.filter((r) => !flagIds.includes(r));
assert.deepEqual(missing, [], `registry runtimes missing a runtimeFlags entry: ${missing.join(', ')} — add to RUNTIME_FLAG_IDS`);
});
test('#2103: vscode is registered but intentionally excluded from runtimeFlags (Marketplace-distributed, never CLI-installed)', () => {
assert.ok(registry.runtimes.vscode, 'vscode must be present in the registry (role:runtime)');
assert.ok(NON_INSTALLABLE_RUNTIMES.has('vscode'));
const flags = runtimeFlags('vscode');
for (const f of EXPECTED_FLAGS) {
assert.strictEqual(flags[f], false, `runtime 'vscode': ${f} must be false (no isVscode flag exists)`);
}
assert.deepStrictEqual(Object.keys(flags).sort(), [...EXPECTED_FLAGS].sort(),
'runtimeFlags(\'vscode\') must NOT introduce a new isVscode key — still exactly the 16 flags');
});

View File

@@ -0,0 +1,128 @@
// allow-test-rule: source-text-is-the-product (#2103). browser.js's entire
// contract IS "zero Node APIs" — a VS Code Web/webworker host has no Node
// core modules at all, so this is a runtime-contract source check, not a
// behavioral proxy for something observable another way (there is no Node
// runtime to observe failing against in CI). Mirrors the existing
// phase6-capstone-conformance.test.cjs precedent for the same exemption class.
'use strict';
/**
* VS Code browser (Web Extension) entry static guard — #2103.
*
* `vscode/browser.js` is the `browser` field entry VS Code Web / vscode.dev
* loads in a webworker context, which has NO Node core modules at all
* (`fs`, `path`, `child_process`) and no Node globals (`process`, `Buffer`,
* `__dirname`, `__filename`). Requiring one throws immediately at load time.
*
* This is the reason browser.js does NOT require `./host-binding.js` (whose
* transitive engine-lib dependencies pull in `node:fs` — see host-binding.js's
* and browser.js's own header comments for the full chain) — it implements an
* independent, minimal composition directly against `vscode.lm`.
*
* No real browser or VS Code host is available in CI (mock vscode only, per
* every other vscode-*.test.cjs in this suite).
*/
const { test } = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const BROWSER_PATH = path.join(__dirname, '..', 'vscode', 'browser.js');
test('vscode/browser.js source contains ZERO require("fs" | "path" | "child_process") (or "node:" prefixed forms)', () => {
const src = fs.readFileSync(BROWSER_PATH, 'utf8');
const bannedRequires = [
/require\(\s*['"]fs['"]\s*\)/,
/require\(\s*['"]node:fs['"]\s*\)/,
/require\(\s*['"]path['"]\s*\)/,
/require\(\s*['"]node:path['"]\s*\)/,
/require\(\s*['"]child_process['"]\s*\)/,
/require\(\s*['"]node:child_process['"]\s*\)/,
];
const offenders = [];
for (const line of src.split(/\r?\n/)) {
// Skip comment lines (this file's own header documents the constraint in
// prose, which legitimately contains the string `require('fs')` etc.).
const trimmed = line.trim();
if (trimmed.startsWith('*') || trimmed.startsWith('//')) continue;
for (const re of bannedRequires) {
if (re.test(line)) offenders.push(line.trim());
}
}
assert.deepEqual(offenders, [],
`vscode/browser.js must never require a Node core module outside a comment; found: ${JSON.stringify(offenders)}`);
});
test('vscode/browser.js does NOT require ./host-binding.js or ./extension.js (both pull in fs-heavy engine-lib modules transitively)', () => {
const src = fs.readFileSync(BROWSER_PATH, 'utf8');
const codeLines = src.split(/\r?\n/).filter((l) => {
const t = l.trim();
return !(t.startsWith('*') || t.startsWith('//'));
}).join('\n');
assert.doesNotMatch(codeLines, /require\(\s*['"]\.\/(host-binding|extension)\.js['"]\s*\)/,
'browser.js must compose its own zero-Node-API surface, not require a module with fs-pulling transitive deps');
});
test('vscode/browser.js source contains no Node-only globals (process/Buffer/__dirname/__filename) outside comments', () => {
const src = fs.readFileSync(BROWSER_PATH, 'utf8');
const offenders = [];
for (const line of src.split(/\r?\n/)) {
const trimmed = line.trim();
if (trimmed.startsWith('*') || trimmed.startsWith('//')) continue;
if (/\bprocess\.|\bBuffer\.|__dirname\b|__filename\b/.test(line)) offenders.push(line.trim());
}
assert.deepEqual(offenders, []);
});
test('vscode/browser.js is valid, loadable JS (node --check equivalent — require does not throw)', () => {
assert.doesNotThrow(() => require(BROWSER_PATH));
});
test('REACHABILITY: browser.js activate() runs against a mock vscode host without throwing (no real VS Code/browser in CI)', () => {
const Module = require('module');
const originalLoad = Module._load;
const registeredCommands = {};
let chatCreated = false;
const toolNames = [];
const mockVscode = {
commands: {
registerCommand(id, handler) {
registeredCommands[id] = handler;
return { dispose() {} };
},
},
chat: {
createChatParticipant(id, handler) {
chatCreated = true;
return { id, handler, dispose() {} };
},
},
lm: {
registerTool(name) {
toolNames.push(name);
return { dispose() {} };
},
},
workspace: { getConfiguration: () => ({ get: () => undefined }) },
LanguageModelTextPart: class { constructor(t) { this.text = t; } },
LanguageModelToolResult: class { constructor(p) { this.parts = p; } },
};
Module._load = function (request, ...rest) {
if (request === 'vscode') return mockVscode;
return originalLoad.call(this, request, ...rest);
};
try {
delete require.cache[BROWSER_PATH];
const browser = require(BROWSER_PATH);
const context = { subscriptions: [] };
assert.doesNotThrow(() => browser.activate(context));
assert.ok(registeredCommands['gsd.invoke'], 'gsd.invoke command registered on web too');
assert.ok(chatCreated, 'chat participant registered on web');
assert.ok(toolNames.length > 0, 'LM tools registered on web');
assert.ok(context.subscriptions.length > 0);
} finally {
Module._load = originalLoad;
delete require.cache[BROWSER_PATH];
}
});

View File

@@ -1,18 +1,34 @@
'use strict';
/**
* VS Code extension reachability test — ADR-1239 Phase D / #1942.
* VS Code extension reachability test — ADR-1239 Phase D / #1942, upgraded #2103.
*
* Proves the VS Code extension is keystone-WIRED: the gsd.invoke handler
* dispatches through the GSD command-routing hub and returns a result (not just
* a stub). The handler is exported separately from activate() so it is testable
* WITHOUT a VS Code host.
* dispatches through gsd-tools.cjs (subprocess-reuse — the shared
* `dispatchGsdCommand` in gsd-core/bin/lib/shell-command-projection.cjs) and
* returns REAL output, not just a registration on a stub. The handler is
* exported separately from activate() so it is testable WITHOUT a VS Code host.
*
* The original cut called `createHub()` with NO args — no hub factory in the
* tree fully populates a hub, so every dispatch silently answered
* UnknownCommand. The prior version of this test only asserted the result was
* "a JSON object" — a VACUOUS assertion that passed whether or not dispatch
* actually worked. Dispatch is now exercised with a real read-only
* family/subcommand (progress/json) against a real temp project, matching the
* sibling tests/pi-extension-reachability.test.cjs pattern — no fake
* dispatcher injected, because the whole point of "reachability" is that the
* real engine is reached.
*/
const { test } = require('node:test');
const assert = require('node:assert/strict');
const Module = require('node:module');
const path = require('node:path');
const { activate, dispatchGsdCommand, resolveEngineRoot } = require('../vscode/extension.js');
const extension = require('../vscode/extension.js');
const { activate, dispatchGsdCommand, resolveEngineRoot, resolveWorkspaceCwd } = extension;
const { createTempDir, cleanup } = require('./helpers.cjs');
const shellCommandProjection = require('../gsd-core/bin/lib/shell-command-projection.cjs');
test('the extension exports activate + dispatchGsdCommand + resolveEngineRoot', () => {
assert.equal(typeof activate, 'function');
@@ -20,18 +36,43 @@ test('the extension exports activate + dispatchGsdCommand + resolveEngineRoot',
assert.equal(typeof resolveEngineRoot, 'function');
});
test('REACHABILITY: dispatchGsdCommand dispatches through the engine hub (keystone wired)', async () => {
const result = await dispatchGsdCommand({ family: 'query', subcommand: 'help' });
assert.equal(typeof result, 'string', 'returns a string result');
const parsed = JSON.parse(result);
assert.ok(parsed !== null && typeof parsed === 'object',
'dispatch produced a result object (the engine was reached)');
test('REACHABILITY: dispatchGsdCommand dispatches a real family/subcommand through gsd-tools.cjs and returns REAL output (keystone wired, not UnknownCommand)', async () => {
const dir = createTempDir();
try {
const result = await dispatchGsdCommand({ family: 'progress', subcommand: 'json', cwd: dir });
assert.equal(typeof result, 'string', 'returns a string result');
const parsed = JSON.parse(result);
assert.ok(parsed !== null && typeof parsed === 'object', 'dispatch produced a result object');
assert.equal(parsed.ok, true, `expected ok:true (real dispatch), got: ${result}`);
assert.equal(typeof parsed.stdout, 'string');
assert.ok(parsed.stdout.length > 0, 'stdout must be non-empty');
const inner = JSON.parse(parsed.stdout);
assert.equal(typeof inner.percent, 'number',
'the real progress command ran (proves the engine was reached — not UnknownCommand)');
assert.equal(parsed.code, 0);
} finally {
cleanup(dir);
}
});
test('dispatchGsdCommand works with default args (no args → query/help)', async () => {
test('REACHABILITY: an unknown family surfaces ok:false without throwing (not a silent UnknownCommand success)', async () => {
const dir = createTempDir();
try {
const result = await dispatchGsdCommand({ family: 'no-such-family-8675309', cwd: dir });
const parsed = JSON.parse(result);
assert.equal(parsed.ok, false);
assert.match(parsed.stderr, /no-such-family-8675309|Unknown command/);
} finally {
cleanup(dir);
}
});
test('dispatchGsdCommand works with default args (no args → gsd-tools.cjs --help, a real working default)', async () => {
const result = await dispatchGsdCommand();
assert.equal(typeof result, 'string');
JSON.parse(result); // must be valid JSON
const parsed = JSON.parse(result); // must be valid JSON
assert.equal(parsed.ok, true, `expected the --help default to be ok:true, got: ${result}`);
assert.match(parsed.stdout, /Usage: gsd-tools/, 'the --help default produced real usage output');
});
test('resolveEngineRoot finds the gsd-core/ dir from the extension location', () => {
@@ -49,3 +90,108 @@ test('the extension manifest declares the gsd.invoke command', () => {
assert.ok(pkg.engines && pkg.engines.vscode, 'manifest declares VS Code engine');
assert.equal(pkg.main, './extension.js', 'manifest main points to extension.js');
});
// ── #2103 FIX (adversarial review, MAJOR): all three desktop dispatch
// surfaces previously called dispatchGsdCommand WITHOUT a cwd, silently
// defaulting to the extension host's own process.cwd() instead of the user's
// project — meaning GSD ran against the wrong directory. resolveWorkspaceCwd
// resolves vscode.workspace.workspaceFolders[0].uri.fsPath, computed fresh
// per-invocation (never cached at activate() time). ─────────────────────────
test('resolveWorkspaceCwd resolves workspaceFolders[0].uri.fsPath when a workspace is open', () => {
const mockVscode = { workspace: { workspaceFolders: [{ uri: { fsPath: '/tmp/some-workspace' } }] } };
assert.equal(resolveWorkspaceCwd(mockVscode), '/tmp/some-workspace');
});
test('resolveWorkspaceCwd falls back to process.cwd() when no workspace folder is open (fail-soft, never throws)', () => {
assert.doesNotThrow(() => {
assert.equal(resolveWorkspaceCwd({ workspace: { workspaceFolders: [] } }), process.cwd());
assert.equal(resolveWorkspaceCwd({ workspace: {} }), process.cwd());
assert.equal(resolveWorkspaceCwd({}), process.cwd());
});
});
test('#2103 FIX: all three desktop dispatch surfaces (gsd.invoke command, @gsd chat participant, LM tool invoke) thread the resolved workspace folder as cwd — not process.cwd()', async () => {
// Spy on the SHARED subprocess-shim dispatchGsdCommand (the one every
// surface ultimately calls via extension.js's own dispatchGsdCommand) so we
// observe the actual `cwd` each surface passes, without needing a real
// gsd-tools.cjs project rooted at the mock workspace path.
const originalShimDispatch = shellCommandProjection.dispatchGsdCommand;
const calls = [];
shellCommandProjection.dispatchGsdCommand = (args) => {
calls.push(args);
return originalShimDispatch(args);
};
const registeredCommands = {};
let chatHandler = null;
const toolImpls = [];
const mockVscode = {
commands: {
registerCommand(id, handler) { registeredCommands[id] = handler; return { dispose() {} }; },
},
chat: {
createChatParticipant(id, handler) { chatHandler = handler; return { id, dispose() {} }; },
},
lm: {
registerTool(name, impl) { toolImpls.push({ name, impl }); return { dispose() {} }; },
},
workspace: {
workspaceFolders: [{ uri: { fsPath: '/tmp/mock-gsd-workspace' } }],
getConfiguration: () => ({ get: () => undefined }),
},
LanguageModelTextPart: class { constructor(t) { this.text = t; } },
LanguageModelToolResult: class { constructor(parts) { this.parts = parts; } },
};
const originalLoad = Module._load;
Module._load = function (request, ...rest) {
if (request === 'vscode') return mockVscode;
return originalLoad.call(this, request, ...rest);
};
const extensionPath = path.join(__dirname, '..', 'vscode', 'extension.js');
const hostBindingPath = path.join(__dirname, '..', 'vscode', 'host-binding.js');
try {
delete require.cache[extensionPath];
delete require.cache[hostBindingPath];
const freshExtension = require(extensionPath);
const context = {
subscriptions: [],
globalState: { get: () => undefined, update: () => Promise.resolve() },
};
freshExtension.activate(context);
// Surface 1: gsd.invoke command handler.
calls.length = 0;
await registeredCommands['gsd.invoke']({ family: 'progress', subcommand: 'json' });
assert.equal(calls.length, 1, 'gsd.invoke handler must dispatch exactly once');
assert.equal(calls[0].cwd, '/tmp/mock-gsd-workspace', 'gsd.invoke must thread the workspace folder as cwd');
// Surface 1b: an explicit args.cwd still takes precedence over the resolved workspace.
calls.length = 0;
await registeredCommands['gsd.invoke']({ family: 'progress', subcommand: 'json', cwd: '/explicit/override' });
assert.equal(calls[0].cwd, '/explicit/override', 'an explicit cwd argument must still override the resolved workspace folder');
// Surface 2: @gsd chat participant handler.
calls.length = 0;
let markdownOut = '';
await chatHandler({ prompt: 'progress json' }, {}, { markdown: (t) => { markdownOut += t; } }, {});
assert.equal(calls.length, 1, 'the chat participant handler must dispatch exactly once');
assert.equal(calls[0].cwd, '/tmp/mock-gsd-workspace', 'the chat participant must thread the workspace folder as cwd');
assert.ok(markdownOut.length > 0);
// Surface 3: Language Model Tool invoke().
calls.length = 0;
const progressTool = toolImpls.find((t) => t.name === 'gsd_progress');
assert.ok(progressTool, 'gsd_progress tool must be registered');
await progressTool.impl.invoke({ input: {} }, {});
assert.equal(calls.length, 1, 'the LM tool invoke() must dispatch exactly once');
assert.equal(calls[0].cwd, '/tmp/mock-gsd-workspace', 'the LM tool invoke() must thread the workspace folder as cwd');
} finally {
Module._load = originalLoad;
shellCommandProjection.dispatchGsdCommand = originalShimDispatch;
delete require.cache[extensionPath];
delete require.cache[hostBindingPath];
}
});

View File

@@ -18,8 +18,9 @@
const { test } = require('node:test');
const assert = require('node:assert/strict');
const { profileOf } = require('../gsd-core/bin/lib/host-integration.cjs');
const { profileOf, negotiateHostCapabilities } = require('../gsd-core/bin/lib/host-integration.cjs');
const bindGsdToVscode = require('./fixtures/vscode-host-binding.cjs');
const registry = require('../gsd-core/bin/lib/capability-registry.cjs');
test('VS Code IDE axes classify as the ide profile', () => {
// ide baseline (host-integration.cts PROFILE_BASELINES): imperative + sandboxed-web.
@@ -27,10 +28,17 @@ test('VS Code IDE axes classify as the ide profile', () => {
assert.notEqual(profileOf({ embeddingMode: 'imperative', runtime: 'node' }), 'ide');
});
test('bindGsdToVscode composes the full IDE profile (active model + engine bus + sandboxed state + imperative adapter)', () => {
test('bindGsdToVscode composes the full IDE profile (active model + engine bus + sandboxed state + imperative adapter)', async () => {
// Real API: vscode.lm.selectChatModels() (async → LanguageModelChat[]); the
// SELECTED MODEL has .sendRequest, NOT vscode.lm itself (#2103 correction —
// there is no vscode.lm.sendRequest).
let lastLmReq = null;
const vscode = {
lm: { sendRequest: (req) => { lastLmReq = req; return 'lm-response'; } },
lm: {
selectChatModels: async () => [
{ sendRequest: (req) => { lastLmReq = req; return 'lm-response'; } },
],
},
};
const storageWrites = [];
const hostStorage = {
@@ -41,9 +49,10 @@ test('bindGsdToVscode composes the full IDE profile (active model + engine bus +
const host = bindGsdToVscode(vscode, hostStorage);
assert.equal(host.runtime, 'vscode');
// Active model routes through vscode.lm (no system messages — User role only).
// Active model routes through vscode.lm.selectChatModels() → model.sendRequest
// (no system messages — User role only).
assert.equal(host.model.mode, 'active');
assert.equal(host.model.sendRequest({ prompt: 'hi' }), 'lm-response');
assert.equal(await host.model.sendRequest({ prompt: 'hi' }), 'lm-response');
assert.deepEqual(lastLmReq, { prompt: 'hi' });
// Engine-owned hook bus: in-process pub/sub (VS Code has no host bus).
@@ -67,9 +76,30 @@ test('bindGsdToVscode composes the full IDE profile (active model + engine bus +
assert.ok(host.commands['gsd.invoke'], 'palette/chat command surface present');
});
// #2103: proves the binding actually COMPOSES (does not throw) against a
// realistic desktop VS Code `vscode.lm` shape — the whole point of the guard
// fix: a real host has `selectChatModels`, never `sendRequest` directly, and
// the binding must succeed, not silently fail-open at the extension.js layer.
test('#2103: bindGsdToVscode composes successfully against a REALISTIC vscode.lm (selectChatModels only, no vscode.lm.sendRequest)', async () => {
const vscode = {
lm: {
selectChatModels: async () => [{ sendRequest: (req) => Promise.resolve({ echoed: req }) }],
// Deliberately no top-level sendRequest — matches the real API surface.
},
};
const hostStorage = { read: () => '', write: () => {} };
let host;
assert.doesNotThrow(() => { host = bindGsdToVscode(vscode, hostStorage); },
'bindGsdToVscode must succeed on a realistic host (selectChatModels-only vscode.lm)');
assert.equal(host.model.mode, 'active');
const response = await host.model.sendRequest({ prompt: 'hello' });
assert.deepEqual(response, { echoed: { prompt: 'hello' } });
});
test('bindGsdToVscode is fail-closed without vscode.lm or hostStorage', () => {
const okStorage = { read() {}, write() {} };
const okVscode = { lm: { sendRequest() {} } };
const okVscode = { lm: { selectChatModels: async () => [] } };
// vscode.lm missing or incomplete → vscode.lm error
assert.throws(() => bindGsdToVscode({}, okStorage), /vscode\.lm/);
assert.throws(() => bindGsdToVscode({ lm: {} }, okStorage), /vscode\.lm/);
@@ -77,3 +107,73 @@ test('bindGsdToVscode is fail-closed without vscode.lm or hostStorage', () => {
assert.throws(() => bindGsdToVscode(okVscode, null), /hostStorage/);
assert.throws(() => bindGsdToVscode(okVscode, { read() {} }), /hostStorage/);
});
test('#2103: bindGsdToVscode still throws when vscode.lm.selectChatModels is ABSENT (a vscode.lm.sendRequest-only mock, matching the OLD incorrect API assumption, must be rejected)', () => {
const okStorage = { read() {}, write() {} };
// The pre-#2103 (wrong) shape: sendRequest directly on vscode.lm, no selectChatModels.
const staleShapeVscode = { lm: { sendRequest: () => 'stale' } };
assert.throws(() => bindGsdToVscode(staleShapeVscode, okStorage), /selectChatModels/);
});
// ── #2103: negotiate fail-closed for an UNDECLARED vscode axis ──────────────
// negotiateHostCapabilities must never throw and must degrade an undeclared
// (missing) axis to its most-restrictive documented default — the same
// fail-closed contract already proven for the "undocumented" sentinel
// (tests/host-integration-descriptors.test.cjs), but exercised here against a
// genuinely ABSENT key (not the string "undocumented") on vscode's real
// hostIntegration object, to prove the negotiation seam is defensive against
// both failure modes.
//
// #2103 FIX (adversarial review, MINOR): strengthened from a weak
// `!== undefined` check to a STRICT equality against the actual
// most-restrictive value negotiateHostCapabilities produces — the fail-closed
// floor pinned as `SAFE_DEFAULTS` in gsd-core/bin/lib/host-integration.cjs
// (not exported, so the values are pinned here verbatim, verified via node -e
// against the real negotiation output before writing this test — see the PR
// report). A `!== undefined` check would pass even if negotiation regressed
// to a WRONG-but-defined value (e.g. a less-restrictive default) — this
// assertion pins the exact known floor per the AC's own wording
// ("most-restrictive known value").
const HOST_INTEGRATION_SAFE_DEFAULTS = {
embeddingMode: 'declarative',
commandSurface: 'prose-only',
modelMode: 'passive',
hookBus: 'none',
stateIO: 'session-log-append',
transport: 'mcp',
runtime: 'node',
};
const HOST_INTEGRATION_DISPATCH_SAFE_DEFAULTS = {
namedDispatch: false,
nested: false,
maxDepth: 0,
background: false,
subagentToolkit: 'read-only',
backgroundDispatch: false,
};
test('#2103: negotiateHostCapabilities never throws for vscode with an UNDECLARED axis, and degrades to the EXACT most-restrictive SAFE_DEFAULTS value', () => {
const realHi = registry.runtimes.vscode.runtime.hostIntegration;
for (const axis of Object.keys(HOST_INTEGRATION_SAFE_DEFAULTS)) {
const undeclared = { ...realHi, dispatch: { ...realHi.dispatch } };
delete undeclared[axis];
let result;
assert.doesNotThrow(() => { result = negotiateHostCapabilities(undeclared); },
`negotiateHostCapabilities must not throw when vscode's "${axis}" axis is entirely absent`);
assert.strictEqual(result.effective[axis], HOST_INTEGRATION_SAFE_DEFAULTS[axis],
`effective.${axis} must resolve to the exact most-restrictive SAFE_DEFAULTS value "${HOST_INTEGRATION_SAFE_DEFAULTS[axis]}", got: ${JSON.stringify(result.effective[axis])}`);
}
});
test('#2103: negotiateHostCapabilities never throws for vscode with an UNDECLARED dispatch sub-axis, and degrades to the EXACT most-restrictive value', () => {
const realHi = registry.runtimes.vscode.runtime.hostIntegration;
for (const key of Object.keys(HOST_INTEGRATION_DISPATCH_SAFE_DEFAULTS)) {
const undeclared = { ...realHi, dispatch: { ...realHi.dispatch } };
delete undeclared.dispatch[key];
let result;
assert.doesNotThrow(() => { result = negotiateHostCapabilities(undeclared); },
`negotiateHostCapabilities must not throw when vscode's dispatch.${key} is entirely absent`);
assert.strictEqual(result.effective.dispatch[key], HOST_INTEGRATION_DISPATCH_SAFE_DEFAULTS[key],
`effective.dispatch.${key} must resolve to the exact most-restrictive value "${HOST_INTEGRATION_DISPATCH_SAFE_DEFAULTS[key]}", got: ${JSON.stringify(result.effective.dispatch[key])}`);
}
});

View File

@@ -0,0 +1,155 @@
'use strict';
/**
* VS Code Language Model Tools test — #2103 UPGRADE 1.
*
* Proves the GSD extension's Language Model Tools are keystone-WIRED:
* 1. contributes.languageModelTools is present in package.json and its
* `name` entries match the runtime registration names (extension.js's
* LM_TOOLS / browser.js's LM_TOOL_NAMES) exactly — a mismatch here would
* mean VS Code rejects the tool registration at activation.
* 2. registerLanguageModelTools() calls vscode.lm.registerTool for every
* manifest entry (mock vscode.lm — no real VS Code host available in CI).
* 3. A registered tool's invoke() dispatches through the SAME shared
* dispatchGsdCommand as gsd.invoke (desktop) and returns REAL output —
* the "user can invoke X" proof, matching the reachability-test pattern.
* 4. The desktop and web entries register the identical tool NAME set (only
* the invoke() behavior differs — real dispatch vs. honest web-mode message).
*/
const { test } = require('node:test');
const assert = require('node:assert/strict');
const pkg = require('../vscode/package.json');
const extension = require('../vscode/extension.js');
const browser = require('../vscode/browser.js');
const { createTempDir, cleanup } = require('./helpers.cjs');
class FakeTextPart {
constructor(text) { this.text = text; }
}
class FakeToolResult {
constructor(parts) { this.parts = parts; }
}
function mockVscodeLm() {
const registered = [];
return {
lm: {
registerTool(name, impl) {
registered.push({ name, impl });
return { dispose() {} };
},
},
LanguageModelTextPart: FakeTextPart,
LanguageModelToolResult: FakeToolResult,
registered,
};
}
test('package.json contributes.languageModelTools is present with well-formed entries', () => {
assert.ok(pkg.contributes && Array.isArray(pkg.contributes.languageModelTools),
'contributes.languageModelTools must be an array');
assert.ok(pkg.contributes.languageModelTools.length > 0, 'must declare at least one tool');
for (const tool of pkg.contributes.languageModelTools) {
assert.equal(typeof tool.name, 'string');
assert.ok(tool.name.length > 0);
assert.equal(typeof tool.toolReferenceName, 'string');
assert.equal(typeof tool.displayName, 'string');
assert.equal(typeof tool.modelDescription, 'string');
assert.equal(typeof tool.userDescription, 'string');
assert.equal(tool.canBeReferencedInPrompt, true);
assert.ok(Array.isArray(tool.tags));
assert.equal(typeof tool.inputSchema, 'object');
}
});
test('manifest tool names exactly match extension.js LM_TOOLS registration names', () => {
const manifestNames = pkg.contributes.languageModelTools.map((t) => t.name).sort();
const runtimeNames = extension.LM_TOOLS.map((t) => t.name).sort();
assert.deepEqual(runtimeNames, manifestNames,
'a mismatch here means VS Code would reject the runtime registerTool call against the manifest');
});
test('manifest tool names exactly match browser.js LM_TOOL_NAMES (web entry registers the same surface)', () => {
const manifestNames = pkg.contributes.languageModelTools.map((t) => t.name).sort();
assert.deepEqual([...browser.LM_TOOL_NAMES].sort(), manifestNames);
});
test('REACHABILITY (desktop): registerLanguageModelTools registers every manifest tool via vscode.lm.registerTool', () => {
const mock = mockVscodeLm();
const context = { subscriptions: [] };
const count = extension.registerLanguageModelTools(mock, context);
assert.equal(count, pkg.contributes.languageModelTools.length);
assert.equal(mock.registered.length, pkg.contributes.languageModelTools.length);
assert.equal(context.subscriptions.length, pkg.contributes.languageModelTools.length);
assert.deepEqual(mock.registered.map((r) => r.name).sort(), extension.LM_TOOLS.map((t) => t.name).sort());
});
test('REACHABILITY (desktop): gsd_progress tool.invoke() dispatches through the hub and returns REAL output', async () => {
const dir = createTempDir();
try {
const mock = mockVscodeLm();
extension.registerLanguageModelTools(mock, { subscriptions: [] });
const progressTool = mock.registered.find((r) => r.name === 'gsd_progress');
assert.ok(progressTool, 'gsd_progress must be registered');
const result = await progressTool.impl.invoke({ input: {}, cwd: dir }, {});
assert.ok(result instanceof FakeToolResult, 'invoke must return a LanguageModelToolResult');
assert.ok(Array.isArray(result.parts) && result.parts.length === 1);
assert.ok(result.parts[0] instanceof FakeTextPart, 'result part must be a LanguageModelTextPart');
const parsed = JSON.parse(result.parts[0].text);
assert.equal(typeof parsed.percent, 'number', 'the real progress command ran (engine reached, not a stub)');
} finally {
cleanup(dir);
}
});
test('REACHABILITY (desktop): gsd_plan_phase tool.invoke() forwards the "phase" input through dispatch (real, not UnknownCommand)', async () => {
const dir = createTempDir();
try {
const mock = mockVscodeLm();
extension.registerLanguageModelTools(mock, { subscriptions: [] });
const planPhaseTool = mock.registered.find((r) => r.name === 'gsd_plan_phase');
assert.ok(planPhaseTool);
const result = await planPhaseTool.impl.invoke({ input: { phase: 'nonexistent-phase-8675309' }, cwd: dir }, {});
const parsed = JSON.parse(result.parts[0].text);
// Real dispatch reaches gsd-tools.cjs and returns a structured "phase not
// found" response (proves the engine was reached) — not the manifest's
// own family/subcommand rejected as unknown.
assert.equal(parsed.phase, 'nonexistent-phase-8675309');
assert.ok('error' in parsed || 'plans' in parsed, 'expected a real phase-plan-index response shape');
} finally {
cleanup(dir);
}
});
test('gsd_workstreams tool.invoke() dispatches through the hub and returns REAL output', async () => {
const dir = createTempDir();
try {
const mock = mockVscodeLm();
extension.registerLanguageModelTools(mock, { subscriptions: [] });
const wsTool = mock.registered.find((r) => r.name === 'gsd_workstreams');
const result = await wsTool.impl.invoke({ input: {}, cwd: dir }, {});
const parsed = JSON.parse(result.parts[0].text);
assert.ok('workstreams' in parsed || 'mode' in parsed, 'expected a real workstream list response shape');
} finally {
cleanup(dir);
}
});
test('registerLanguageModelTools fails soft (returns 0, does not throw) when vscode.lm is absent', () => {
assert.doesNotThrow(() => {
const count = extension.registerLanguageModelTools({}, { subscriptions: [] });
assert.equal(count, 0);
});
});
test('WEB MODE: browser.js registerLanguageModelTools registers the same names but invoke() returns an honest web-mode message (no engine dispatch)', async () => {
const mock = mockVscodeLm();
const count = browser.registerLanguageModelTools(mock, { subscriptions: [] });
assert.equal(count, browser.LM_TOOL_NAMES.length);
const progressTool = mock.registered.find((r) => r.name === 'gsd_progress');
const result = await progressTool.impl.invoke({ input: {} }, {});
assert.match(result.parts[0].text, /web mode/i);
assert.match(result.parts[0].text, /MCP server/);
});

View File

@@ -0,0 +1,148 @@
'use strict';
/**
* VS Code #runSubagent wiring test — #2103 UPGRADE 2.
*
* VS Code 1.105+ lets the primary chat agent invoke registered chat
* participants / Language Model Tools as a nested agent turn via
* `#runSubagent`, gated by the `chat.subagents.allowInvocationsFromSubagents`
* setting (default off; nested depth max 5 when enabled — docs/agents/subagents.md,
* release-notes/v1_105.md). There is no separate extension-side "subagent
* contribution" registration API: VS Code's chat engine surfaces the already
* -registered chat participant + languageModelTools directly. This extension's
* own contribution is `registerSubagentDispatch` — availability detection
* (fail-soft on older/Insiders-gated hosts) plus a belt-and-suspenders
* maxDepth:5 ceiling (dispatchAsSubagent) that mirrors
* capabilities/vscode/capability.json's hostIntegration.dispatch.maxDepth,
* independent of whatever the host itself enforces.
*
* Mock vscode only — no real VS Code host in CI.
*/
const { test } = require('node:test');
const assert = require('node:assert/strict');
const extension = require('../vscode/extension.js');
const { createTempDir, cleanup } = require('./helpers.cjs');
function mockVscodeWithSubagentSupport(allow) {
return {
workspace: {
getConfiguration(section) {
return {
get(key) {
if (section === 'chat.subagents' && key === 'allowInvocationsFromSubagents') return allow;
return undefined;
},
};
},
},
};
}
test('registerSubagentDispatch reports maxDepth matching capabilities/vscode/capability.json (dispatch.maxDepth:5)', () => {
const cap = require('../capabilities/vscode/capability.json');
const wiring = extension.registerSubagentDispatch(mockVscodeWithSubagentSupport(true));
assert.equal(wiring.maxDepth, cap.runtime.hostIntegration.dispatch.maxDepth,
'the extension-side depth cap must mirror the descriptor-declared maxDepth');
assert.equal(extension.GSD_MAX_SUBAGENT_DEPTH, cap.runtime.hostIntegration.dispatch.maxDepth);
});
test('registerSubagentDispatch: available:true when chat.subagents.allowInvocationsFromSubagents is configured', () => {
const wiring = extension.registerSubagentDispatch(mockVscodeWithSubagentSupport(true));
assert.equal(wiring.available, true);
assert.equal(typeof wiring.dispatchAsSubagent, 'function');
});
test('registerSubagentDispatch: available:false (fail-soft, no throw) when the setting is absent (older/Insiders-gated VS Code)', () => {
assert.doesNotThrow(() => {
const wiring = extension.registerSubagentDispatch({});
assert.equal(wiring.available, false);
});
});
test('registerSubagentDispatch: available:false (fail-soft) when vscode.workspace.getConfiguration itself throws', () => {
const throwingVscode = {
workspace: { getConfiguration() { throw new Error('simulated host failure'); } },
};
assert.doesNotThrow(() => {
const wiring = extension.registerSubagentDispatch(throwingVscode);
assert.equal(wiring.available, false);
});
});
test('REACHABILITY: a background-eligible dispatchAsSubagent call at depth 0 dispatches through the shared hub and returns REAL output', async () => {
const dir = createTempDir();
try {
const result = JSON.parse(await extension.dispatchAsSubagent({
family: 'progress', subcommand: 'json', cwd: dir, depth: 0,
}));
assert.equal(result.ok, true);
const parsed = JSON.parse(result.stdout);
assert.equal(typeof parsed.percent, 'number', 'the real progress command ran (engine reached)');
} finally {
cleanup(dir);
}
});
// #2103 FIX (adversarial review, MINOR — boundary gap): the repo's own
// TESTING-STANDARDS mandate exercising limit-1/limit/limit+1 around a boundary.
// The maxDepth:5 ceiling previously covered only 5 (limit) and 6 (limit+1) —
// this completes the triple with depth 4 (limit-1).
test('dispatchAsSubagent enforces the maxDepth:5 ceiling — depth 4 (limit-1) still dispatches', async () => {
const dir = createTempDir();
try {
const result = JSON.parse(await extension.dispatchAsSubagent({
family: 'progress', subcommand: 'json', cwd: dir, depth: 4,
}));
assert.equal(result.ok, true, 'depth one below the ceiling must be allowed');
} finally {
cleanup(dir);
}
});
test('dispatchAsSubagent enforces the maxDepth:5 ceiling — depth 5 (at limit) still dispatches', async () => {
const dir = createTempDir();
try {
const result = JSON.parse(await extension.dispatchAsSubagent({
family: 'progress', subcommand: 'json', cwd: dir, depth: 5,
}));
assert.equal(result.ok, true, 'depth exactly at the ceiling must still be allowed');
} finally {
cleanup(dir);
}
});
test('dispatchAsSubagent enforces the maxDepth:5 ceiling — depth 6 (over limit) is refused, never throws', async () => {
const result = JSON.parse(await extension.dispatchAsSubagent({
family: 'progress', subcommand: 'json', depth: 6,
}));
assert.equal(result.ok, false);
assert.match(result.stderr, /exceeds maxDepth/);
assert.equal(result.code, null);
});
test('dispatchAsSubagent defaults depth to 0 when omitted (a direct, non-nested call is always allowed)', async () => {
const dir = createTempDir();
try {
const result = JSON.parse(await extension.dispatchAsSubagent({ family: 'progress', subcommand: 'json', cwd: dir }));
assert.equal(result.ok, true);
} finally {
cleanup(dir);
}
});
// ── Web mode: #runSubagent availability detection is registered identically,
// but there is no dispatchAsSubagent on web (no engine dispatch at all — see
// browser.js's header comment). ─────────────────────────────────────────────
test('WEB MODE: browser.js detectSubagentSupport uses the same fail-soft availability contract', () => {
const browser = require('../vscode/browser.js');
assert.doesNotThrow(() => {
const wiring = browser.detectSubagentSupport(mockVscodeWithSubagentSupport(true));
assert.equal(wiring.available, true);
});
assert.doesNotThrow(() => {
const wiring = browser.detectSubagentSupport({});
assert.equal(wiring.available, false);
});
});

197
vscode/browser.js Normal file
View File

@@ -0,0 +1,197 @@
'use strict';
/**
* GSD extension for VS Code — WEB (browser) entry, #2103.
*
* This is the `browser` entry point (vscode/package.json `"browser": "./browser.js"`),
* loaded by VS Code Web / vscode.dev in a webworker context. It has ZERO Node
* APIs: no `require('fs')`, `require('path')`, or `require('child_process')`,
* and no Node globals (`process`, `Buffer`, `__dirname`, `__filename`). This is
* a HARD constraint, not a style preference — a Web Extension host does not
* have Node's core modules available at all; requiring one throws immediately
* at load time and breaks activation.
*
* WHY THIS FILE DOES NOT REQUIRE `./host-binding.js` OR `./extension.js`
* (a deliberate deviation from "compose the seams via bindGsdToVscode" — see
* the #2103 dispatch-crux note below):
*
* `host-binding.js`'s `bindGsdToVscode` is NOT actually web-safe once its
* transitive dependencies are checked — three of its four required engine-lib
* modules pull in Node's `fs`/`os`/`path` at module-load time (eagerly, on
* every `require()`, regardless of which code path runs):
* - gsd-core/bin/lib/state-io.cjs → requires 'node:fs' directly.
* - gsd-core/bin/lib/adapter-imperative.cjs → requires install-engine.cjs +
* capability-loader.cjs (fs/os/path).
* - gsd-core/bin/lib/model-adapter.cjs → requires model-resolver.cjs →
* config-loader.cjs (fs/os/path) +
* configuration.cjs (fs/path).
* (gsd-core/bin/lib/hook-bus.cjs alone has no requires and is genuinely
* web-safe.) Requiring `host-binding.js` here would transitively pull in
* `node:fs` and throw at web-worker load time — the opposite of "zero Node
* APIs". See host-binding.js's own header comment for the full chain. Fixing
* those engine-lib modules to be fs-free is a separate, much larger
* engine-wide refactor (config/capability loading genuinely reads files from
* disk for every OTHER host) — out of scope here; flagged rather than routed
* around silently.
*
* So this file implements its OWN minimal, independently-verified-zero-Node-API
* composition directly against `vscode.lm` — no engine-lib requires at all.
*
* DISPATCH STORY ON WEB (per the #2103 dispatch-crux design): full GSD engine
* dispatch (the gsd-tools.cjs subprocess-shim `dispatchGsdCommand` used by the
* desktop `extension.js`) is fundamentally a Node `child_process.spawnSync`
* call — there is no web-worker equivalent. On web, GSD command dispatch is
* available through VS Code's NATIVE MCP client connecting to the GSD
* companion MCP server (gsd-core/bin/lib/mcp-server.cjs, `gsd-mcp-server`
* bin entry — a separate, already-existing surface; this file does NOT
* implement an MCP client itself, it only points the user at that story).
* The chat participant and Language Model Tools registered below are
* therefore intentionally limited on web: they register (so the surface is
* discoverable and `#runSubagent`-eligible per VS Code's chat engine) but
* their handlers return an honest "configure the GSD MCP server for full
* dispatch on web" message rather than silently failing or faking success.
*/
/**
* Tokenizes a raw chat/free-form prompt string. Kept local (not shared with
* extension.js) so this file has zero requires of any kind beyond `vscode`.
* @param {string} rawPrompt
* @returns {{family: string, subcommand: string|undefined, args: string[]}}
*/
function parseChatPrompt(rawPrompt) {
const tokens = String(rawPrompt || '').trim().split(/\s+/).filter(Boolean);
return {
family: tokens[0] || '--help',
subcommand: tokens[1],
args: tokens.slice(2),
};
}
/**
* The honest "web mode" message every web-surface handler returns instead of
* attempting Node-only engine dispatch.
* @param {string} family
*/
function webDispatchUnavailableMessage(family) {
return (
`GSD web mode: full engine dispatch for "${family}" is not available in the browser ` +
'(the VS Code Web/webworker host has no Node runtime, so the gsd-tools.cjs ' +
'subprocess dispatch used on desktop cannot run here). Configure the GSD MCP ' +
'server (gsd-mcp-server) as a VS Code MCP server for full command dispatch on web, ' +
'or use the desktop GSD Core extension.'
);
}
/**
* Registers the `@gsd` chat participant in web mode (#2103). Its handler is
* honest about the web dispatch limitation — see webDispatchUnavailableMessage.
* Exported separately so it is testable with a mock `vscode.chat`.
* @param {object} vscode
* @param {import('vscode').ExtensionContext} context
* @returns {object|null} the created participant, or null if vscode.chat is absent.
*/
function registerChatParticipant(vscode, context) {
if (!vscode || !vscode.chat || typeof vscode.chat.createChatParticipant !== 'function') {
return null;
}
const participant = vscode.chat.createChatParticipant('gsd', async (request, _chatContext, stream, _token) => {
const { family } = parseChatPrompt(request && request.prompt);
if (stream && typeof stream.markdown === 'function') {
stream.markdown(webDispatchUnavailableMessage(family));
}
return { metadata: { command: family, mode: 'web' } };
});
if (context && Array.isArray(context.subscriptions)) context.subscriptions.push(participant);
return participant;
}
/**
* The same representative LM tool NAMES as the desktop extension (must match
* package.json's contributes.languageModelTools[].name — underscored, the
* vscode.lm.registerTool registration name — so the manifest is identical
* across both entry points), but with web-mode invoke() handlers.
*/
const LM_TOOL_NAMES = ['gsd_progress', 'gsd_workstreams', 'gsd_plan_phase'];
/**
* Registers web-mode LM tools via vscode.lm.registerTool (#2103). Each
* invoke() returns the honest web-dispatch-unavailable message — no engine-lib
* requires, no Node APIs.
* @param {object} vscode
* @param {import('vscode').ExtensionContext} context
* @returns {number} count of tools registered (0 if vscode.lm is absent — fail-soft).
*/
function registerLanguageModelTools(vscode, context) {
if (!vscode || !vscode.lm || typeof vscode.lm.registerTool !== 'function') return 0;
let count = 0;
for (const name of LM_TOOL_NAMES) {
const impl = {
async invoke(_options, _token) {
return new vscode.LanguageModelToolResult([
new vscode.LanguageModelTextPart(webDispatchUnavailableMessage(name)),
]);
},
};
const disposable = vscode.lm.registerTool(name, impl);
if (context && Array.isArray(context.subscriptions)) context.subscriptions.push(disposable);
count++;
}
return count;
}
/**
* Detects `#runSubagent` feature availability (`chat.subagents.allowInvocationsFromSubagents`,
* VS Code 1.105+). Fail-soft: never throws. Identical detection logic to the
* desktop extension.js (duplicated, not shared, to keep this file at zero
* requires) — see extension.js's registerSubagentDispatch for the rationale
* that VS Code's chat engine itself surfaces registered participants/tools to
* `#runSubagent`, with no separate registration API.
* @param {object} vscode
* @returns {{available: boolean}}
*/
function detectSubagentSupport(vscode) {
let available = false;
try {
const cfg = vscode && vscode.workspace && typeof vscode.workspace.getConfiguration === 'function'
? vscode.workspace.getConfiguration('chat.subagents')
: null;
available = !!(cfg && typeof cfg.get === 'function' && cfg.get('allowInvocationsFromSubagents') !== undefined);
} catch {
available = false;
}
return { available };
}
/**
* VS Code Web extension activation. Registers the chat participant + Language
* Model Tools in web mode. Does NOT register the `gsd.invoke` command with a
* real-dispatch handler (there is no Node dispatch on web) — the command is
* still contributed (contributes.commands in package.json is shared across
* desktop/web), so it is registered here too, but its handler returns the
* same honest web-mode message.
* @param {import('vscode').ExtensionContext} context
*/
function activate(context) {
const vscode = require('vscode');
const gsdCommand = vscode.commands.registerCommand('gsd.invoke', async (args) => {
const a = (args && typeof args === 'object') ? args : {};
const family = (typeof a.family === 'string' && a.family) ? a.family : '--help';
return JSON.stringify({ ok: false, stdout: '', stderr: webDispatchUnavailableMessage(family), code: null, timedOut: false });
});
context.subscriptions.push(gsdCommand);
registerChatParticipant(vscode, context);
registerLanguageModelTools(vscode, context);
detectSubagentSupport(vscode);
}
module.exports = {
activate,
parseChatPrompt,
webDispatchUnavailableMessage,
registerChatParticipant,
registerLanguageModelTools,
detectSubagentSupport,
LM_TOOL_NAMES,
};

View File

@@ -1,20 +1,51 @@
'use strict';
/**
* GSD extension for VS Code — ADR-1239 Phase D / #1942.
* GSD extension for VS Code — ADR-1239 Phase D / #1942, dispatch fixed +
* extension surface (chat participant, Language Model Tools, #runSubagent
* wiring) added #2103.
*
* This is the DESKTOP (Node) `main` entry — see browser.js for the Web
* Extension `browser` entry, which is intentionally a SEPARATE, much more
* minimal file (zero Node APIs; does not require this file or host-binding.js).
*
* VS Code is the IDE-profile reference host. This extension binds GSD's command
* surface to VS Code's Command Palette + Chat participant via the imperative
* adapter path. Engine entry: in-process CJS require (the extension host runs
* Node). The engine seams (active model via vscode.lm, engine-owned hook bus,
* sandboxed-storage stateIO) are composed in activate() per the #1933 binding.
* sandboxed-storage stateIO) are composed in activate() via host-binding.js
* per the #1933 binding.
*
* Installation: repo-local (not Marketplace-published). Open this dir in VS Code
* + press F5 (Extension Development Host) to run, or package with `vsce package`.
* Installation: Marketplace/VSIX extension (see capabilities/vscode/capability.json
* — installSurface:'none', it is never CLI-installed by bin/install.js).
*
* Engine entry: the gsd.invoke handler dispatches IN-PROCESS through the GSD
* command-routing hub (createHub/dispatch). This is the same hub the companion
* MCP server + the pi extension use.
* Engine entry: dispatch is SUBPROCESS-REUSE to gsd-tools.cjs (bounded,
* no-throw — the shared `dispatchGsdCommand` in
* gsd-core/bin/lib/shell-command-projection.cjs), NOT an in-process
* command-routing hub. No fully-populated hub factory exists anywhere in
* gsd-core — every createHub() caller builds a single-family hub for its own
* narrow purpose — so calling createHub() with no args (the original #1942
* cut) always answered UnknownCommand. This mirrors the fix already applied
* to the pi extension (pi/gsd.cjs) and the companion MCP server
* (gsd-core/bin/lib/mcp-server.cjs), which dispatch through the SAME shared
* helper (#2102 Stage 2 / #2103).
*
* Extension surface (#2103):
* - Chat participant `@gsd` (contributes.chatParticipants) — dispatches free-form
* prompts through the same dispatchGsdCommand.
* - Language Model Tools (contributes.languageModelTools) — a representative
* set of GSD skills exposed as vscode.lm tools, each dispatching through the
* same shared helper (UPGRADE 1).
* - #runSubagent wiring — VS Code 1.105+ lets the primary chat agent invoke
* registered chat participants / languageModelTools as a nested agent turn
* via `#runSubagent`, gated by the `chat.subagents.allowInvocationsFromSubagents`
* setting. There is no separate "subagent contribution" registration API
* beyond the participant + tools already registered above — VS Code's own
* chat engine surfaces them. This extension's own contribution is a
* belt-and-suspenders depth cap (dispatchAsSubagent, GSD_MAX_SUBAGENT_DEPTH)
* mirroring capabilities/vscode/capability.json's
* hostIntegration.dispatch.maxDepth:5, independent of whatever VS Code
* itself enforces natively (UPGRADE 2).
*/
const path = require('path');
@@ -36,38 +67,317 @@ const ENGINE_ROOT = resolveEngineRoot(__dirname);
const GSD_CORE = path.join(ENGINE_ROOT, 'gsd-core');
/**
* Pure command handler — dispatches through the GSD command-routing hub.
* Exported separately from activate() so it is testable WITHOUT a VS Code host.
* @returns {Promise<string>} JSON-stringified dispatch result.
* Pure command handler — dispatches through the SHARED subprocess-shim
* `dispatchGsdCommand` (gsd-core/bin/lib/shell-command-projection.cjs), the
* same helper the pi extension (pi/gsd.cjs) and the companion MCP server
* (gsd-core/bin/lib/mcp-server.cjs) dispatch through.
*
* Exported separately from activate() so it is testable WITHOUT a VS Code
* host. Preserves the original return CONTRACT — a JSON-stringified result
* object — but now backed by a REAL dispatch instead of an unconfigured
* `createHub()` that always answered UnknownCommand (#2103 fix).
*
* Empty/omitted args default to `--help` (a real, working, ok:true
* gsd-tools.cjs command) — NOT the `'query'`/`'help'` pairing the original
* cut used, which is not a valid gsd-tools.cjs command and always produced
* UnknownCommand (mirrors the same fix already applied to
* pi/gsd.cjs's parseGsdCommandArgs).
*
* @returns {Promise<string>} JSON-stringified dispatch result:
* `{ok, stdout, stderr, code, timedOut}` on a normal dispatch, or
* `{ok:false, stdout:'', stderr:'GSD engine unavailable: ...', code:null,
* timedOut:false}` if the shared helper itself cannot be loaded (e.g.
* gsd-core/ missing from the tree).
*/
async function dispatchGsdCommand(args) {
const { createHub } = require(path.join(GSD_CORE, 'bin', 'lib', 'command-routing-hub.cjs'));
const hub = createHub();
const res = hub.dispatch({
family: (args && args.family) || 'query',
subcommand: (args && args.subcommand) || 'help',
args: (args && Array.isArray(args.args)) ? args.args : [],
cwd: (args && args.cwd) || process.cwd(),
const a = (args && typeof args === 'object') ? args : {};
const family = (typeof a.family === 'string' && a.family) ? a.family : '--help';
const subcommand = (typeof a.subcommand === 'string' && a.subcommand) ? a.subcommand : undefined;
const rest = Array.isArray(a.args) ? a.args : [];
const cwd = a.cwd || process.cwd();
let dispatchViaShim;
try {
({ dispatchGsdCommand: dispatchViaShim } = require(path.join(GSD_CORE, 'bin', 'lib', 'shell-command-projection.cjs')));
} catch (e) {
return JSON.stringify({
ok: false,
stdout: '',
stderr: `GSD engine unavailable: ${e && e.message ? e.message : String(e)}`,
code: null,
timedOut: false,
});
}
const result = dispatchViaShim({ family, subcommand, args: rest, cwd });
return JSON.stringify(result);
}
/**
* Resolves the current workspace's root directory, per-invocation (never
* cached at activate() time), so GSD commands dispatch against the user's
* actual project instead of the extension host's own `process.cwd()` (#2103
* FIX — adversarial review: all three desktop dispatch surfaces previously
* omitted `cwd` entirely when calling dispatchGsdCommand, silently defaulting
* to the wrong directory). Falls back to `process.cwd()` only when no
* workspace folder is open (e.g. an empty window) — mirrors VS Code's own
* single-root convention of reading `workspaceFolders[0]`.
* @param {object} vscode
* @returns {string}
*/
function resolveWorkspaceCwd(vscode) {
const folders = vscode && vscode.workspace && vscode.workspace.workspaceFolders;
const first = Array.isArray(folders) ? folders[0] : undefined;
const fsPath = first && first.uri && first.uri.fsPath;
return (typeof fsPath === 'string' && fsPath) ? fsPath : process.cwd();
}
/**
* Tokenizes a raw chat/free-form prompt string into {family, subcommand, args}.
* Mirrors pi/gsd.cjs's parseGsdCommandArgs (simple whitespace split — no shell
* quoting support needed for a chat prompt). Empty input defaults to `--help`
* (a real, working gsd-tools.cjs command), matching dispatchGsdCommand's own
* default so the two surfaces (palette vs. chat) never diverge on "no input".
* @param {string} rawPrompt
* @returns {{family: string, subcommand: string|undefined, args: string[]}}
*/
function parseChatPrompt(rawPrompt) {
const tokens = String(rawPrompt || '').trim().split(/\s+/).filter(Boolean);
return {
family: tokens[0] || '--help',
subcommand: tokens[1],
args: tokens.slice(2),
};
}
/**
* Registers the `@gsd` chat participant (#2103). Its handler dispatches the
* user's free-form prompt through the SAME dispatchGsdCommand as gsd.invoke —
* one dispatch path for every command surface (palette / chat / LM tools).
* Exported separately so it is testable with a mock `vscode.chat`.
* @param {object} vscode
* @param {import('vscode').ExtensionContext} context
* @returns {object|null} the created participant, or null if vscode.chat is absent
* (older VS Code — fail-soft, never throws).
*/
function registerChatParticipant(vscode, context) {
if (!vscode || !vscode.chat || typeof vscode.chat.createChatParticipant !== 'function') {
return null;
}
const participant = vscode.chat.createChatParticipant('gsd', async (request, _chatContext, stream, _token) => {
// #2103 FIX: resolve the user's actual workspace, not the extension host's
// process.cwd() — computed per-invocation so it always reflects the
// CURRENT workspace (a chat request has no cwd field of its own; VS Code's
// ChatContext does not carry one).
const cwd = resolveWorkspaceCwd(vscode);
const { family, subcommand, args } = parseChatPrompt(request && request.prompt);
const result = JSON.parse(await dispatchGsdCommand({ family, subcommand, args, cwd }));
if (stream && typeof stream.markdown === 'function') {
stream.markdown(result.ok ? result.stdout : `GSD error: ${result.stderr || result.stdout || 'dispatch failed'}`);
}
return { metadata: { command: family } };
});
return JSON.stringify(res);
if (context && Array.isArray(context.subscriptions)) context.subscriptions.push(participant);
return participant;
}
/**
* #2103 UPGRADE 1 — Language Model Tools.
*
* A representative set of GSD skills (see skills/gsd-progress, skills/gsd-workstreams,
* skills/gsd-plan-phase) exposed as vscode.lm tools, matching the
* contributes.languageModelTools manifest entries in package.json. Each tool's
* invoke() dispatches through the SAME shared dispatchGsdCommand as gsd.invoke —
* the tool name maps to a real, verified family/subcommand pair (verified by
* direct `gsd-tools.cjs <family> [subcommand] --raw --json-errors` invocation;
* see the #2103 CORE-stage precedent for `progress json`).
*
* Kept to a small, curated set rather than all 71 shipped skills: these three
* both name a real skill AND map cleanly onto a single, safe, read-only
* gsd-tools.cjs command (the rest are multi-step agent workflows that do not
* reduce to one non-interactive CLI call, and stay slash-command-native).
*/
// `name` uses underscores (vscode.lm.registerTool's registration name — MUST
// match contributes.languageModelTools[].name in package.json exactly); the
// hyphenated `#gsd-progress`-style mention alias is package.json's separate
// `toolReferenceName` field (VS Code owns that mapping internally).
const LM_TOOLS = [
{
name: 'gsd_progress',
// skills/gsd-progress — real, verified: `gsd-tools.cjs progress json`.
resolveCommand: () => ({ family: 'progress', subcommand: 'json', args: [] }),
},
{
name: 'gsd_workstreams',
// skills/gsd-workstreams — real, verified: `gsd-tools.cjs workstream list`.
resolveCommand: () => ({ family: 'workstream', subcommand: 'list', args: [] }),
},
{
name: 'gsd_plan_phase',
// skills/gsd-plan-phase — real, verified: `gsd-tools.cjs phase-plan-index <phase>`.
// (The full multi-step planning workflow stays slash-command-native; this
// tool exposes the read-only plan-index lookup the workflow itself queries first.)
resolveCommand: (input) => ({
family: 'phase-plan-index',
subcommand: undefined,
args: [input && input.phase ? String(input.phase) : ''],
}),
},
];
/**
* Builds a vscode.lm tool implementation for one LM_TOOLS entry.
* @param {{name:string, resolveCommand:(input:object)=>{family:string,subcommand:string|undefined,args:string[]}}} toolDef
* @param {object} vscode
*/
function createLanguageModelTool(toolDef, vscode) {
return {
async invoke(options, _token) {
const input = (options && options.input) || {};
// #2103 FIX: resolve the user's actual workspace, not the extension
// host's process.cwd() — computed per-invocation. LanguageModelToolInvocationOptions
// has no cwd field of its own.
const cwd = resolveWorkspaceCwd(vscode);
const { family, subcommand, args } = toolDef.resolveCommand(input);
const result = JSON.parse(await dispatchGsdCommand({ family, subcommand, args, cwd }));
const text = result.ok ? result.stdout : `GSD error: ${result.stderr || result.stdout || 'dispatch failed'}`;
return new vscode.LanguageModelToolResult([new vscode.LanguageModelTextPart(text)]);
},
};
}
/**
* Registers the LM_TOOLS set via vscode.lm.registerTool (#2103 UPGRADE 1).
* Exported separately so it is testable with a mock `vscode.lm`.
* @param {object} vscode
* @param {import('vscode').ExtensionContext} context
* @returns {number} count of tools registered (0 if vscode.lm is absent — fail-soft).
*/
function registerLanguageModelTools(vscode, context) {
if (!vscode || !vscode.lm || typeof vscode.lm.registerTool !== 'function') return 0;
let count = 0;
for (const toolDef of LM_TOOLS) {
const disposable = vscode.lm.registerTool(toolDef.name, createLanguageModelTool(toolDef, vscode));
if (context && Array.isArray(context.subscriptions)) context.subscriptions.push(disposable);
count++;
}
return count;
}
/** Mirrors capabilities/vscode/capability.json's hostIntegration.dispatch.maxDepth. */
const GSD_MAX_SUBAGENT_DEPTH = 5;
/**
* #2103 UPGRADE 2 — native subagent dispatch (#runSubagent).
*
* Dispatches a command as a (possibly nested) subagent turn, enforcing GSD's
* own maxDepth:5 ceiling (capabilities/vscode/capability.json) independent of
* whatever VS Code's chat engine enforces natively for `#runSubagent` /
* `chat.subagents.allowInvocationsFromSubagents` — belt-and-suspenders, never
* silently trusts the host's own depth accounting.
* @param {{family?:string, subcommand?:string, args?:string[], cwd?:string, depth?:number}} args
* @returns {Promise<string>} same JSON-stringified contract as dispatchGsdCommand.
*/
async function dispatchAsSubagent(args) {
const a = (args && typeof args === 'object') ? args : {};
const depth = Number.isInteger(a.depth) ? a.depth : 0;
if (depth > GSD_MAX_SUBAGENT_DEPTH) {
return JSON.stringify({
ok: false,
stdout: '',
stderr: `GSD subagent dispatch refused: depth ${depth} exceeds maxDepth ${GSD_MAX_SUBAGENT_DEPTH}`,
code: null,
timedOut: false,
});
}
return dispatchGsdCommand(a);
}
/**
* Detects whether the host VS Code build exposes the `#runSubagent` feature
* surface (`chat.subagents.allowInvocationsFromSubagents`, VS Code 1.105+).
* Fail-soft: any missing/older API surface (including an Insiders-gated build
* where the setting does not exist yet) resolves `available:false` — never
* throws. There is no separate registration call: VS Code's chat engine
* surfaces the already-registered chat participant + languageModelTools to
* `#runSubagent` on its own; this function only reports/confirms availability.
* @param {object} vscode
* @returns {{available: boolean, dispatchAsSubagent: typeof dispatchAsSubagent, maxDepth: number}}
*/
function registerSubagentDispatch(vscode) {
let available = false;
try {
const cfg = vscode && vscode.workspace && typeof vscode.workspace.getConfiguration === 'function'
? vscode.workspace.getConfiguration('chat.subagents')
: null;
available = !!(cfg && typeof cfg.get === 'function' && cfg.get('allowInvocationsFromSubagents') !== undefined);
} catch {
available = false;
}
return { available, dispatchAsSubagent, maxDepth: GSD_MAX_SUBAGENT_DEPTH };
}
/**
* VS Code extension activation. Composes the IDE-profile seams (per the #1933
* reference binding) + registers the command surface.
* reference binding) + registers the full command surface: palette
* (gsd.invoke), chat participant (@gsd), Language Model Tools, and the
* #runSubagent depth-cap wiring.
* @param {import('vscode').ExtensionContext} context
*/
function activate(context) {
const vscode = require('vscode');
// ── Command surface: palette + chat participant ──────────────────────────
const gsdCommand = vscode.commands.registerCommand('gsd.invoke', dispatchGsdCommand);
// ── Command surface: palette ──────────────────────────────────────────────
// #2103 FIX: wrap dispatchGsdCommand (rather than registering it directly as
// the handler) so a palette invocation that omits `cwd` resolves the user's
// actual workspace instead of silently defaulting to the extension host's
// own process.cwd(). An explicit `args.cwd` (e.g. from a programmatic
// vscode.commands.executeCommand('gsd.invoke', {..., cwd}) caller) still
// takes precedence.
const gsdCommand = vscode.commands.registerCommand('gsd.invoke', (args) => {
const a = (args && typeof args === 'object') ? args : {};
const cwd = a.cwd || resolveWorkspaceCwd(vscode);
return dispatchGsdCommand({ ...a, cwd });
});
context.subscriptions.push(gsdCommand);
// The full IDE-profile binding (active vscode.lm model, engine-owned hook bus,
// sandboxed-storage stateIO, imperative adapter) composes in activate() per
// the #1933 reference binding (tests/fixtures/vscode-host-binding.cjs). The
// command handler dispatches through the hub — the user-reachable surface.
// ── IDE-profile host binding (#1933 reference binding; desktop/Node only —
// see host-binding.js's header for why browser.js does NOT use this path).
// On a real desktop VS Code host this SUCCEEDS (#2103 fix: host-binding.js's
// guard checks vscode.lm.selectChatModels — the real API — not the
// nonexistent vscode.lm.sendRequest the pre-#2103 code assumed). Still
// wrapped fail-open so a genuinely older VS Code build (no vscode.lm at all)
// degrades to the palette command only, rather than blocking activation.
try {
const bindGsdToVscode = require('./host-binding.js');
const hostStorage = {
read: (key) => context.globalState.get(key),
write: (key, value) => context.globalState.update(key, value),
};
bindGsdToVscode(vscode, hostStorage);
} catch {
// fail-open — see doc comment above.
}
// ── Chat participant (@gsd) ───────────────────────────────────────────────
registerChatParticipant(vscode, context);
// ── Language Model Tools (#2103 UPGRADE 1) ────────────────────────────────
registerLanguageModelTools(vscode, context);
// ── #runSubagent wiring (#2103 UPGRADE 2) ─────────────────────────────────
registerSubagentDispatch(vscode);
}
module.exports = { activate, dispatchGsdCommand, resolveEngineRoot };
module.exports = {
activate,
dispatchGsdCommand,
resolveEngineRoot,
resolveWorkspaceCwd,
parseChatPrompt,
registerChatParticipant,
registerLanguageModelTools,
registerSubagentDispatch,
dispatchAsSubagent,
LM_TOOLS,
GSD_MAX_SUBAGENT_DEPTH,
};

113
vscode/host-binding.js Normal file
View File

@@ -0,0 +1,113 @@
'use strict';
/**
* VS Code IDE host binding for GSD (ADR-1239 Phase D / #1933, shipped #2103).
*
* VS Code is the IDE-profile reference host. This module composes the Phase-3
* engine seams for the negotiated `ide` profile (host-integration.cts
* PROFILE_BASELINES):
*
* - modelMode: 'active' → createModelAdapter({modelMode:'active'}, {sendRequest})
* backed by `vscode.lm` (LanguageModelChat). There is NO
* `vscode.lm.sendRequest` — the real API is
* `const [model] = await vscode.lm.selectChatModels(selector?);
* const response = await model.sendRequest(messages, options?, token?);`
* (Context7-verified #2103). The injected `sendRequest` wrapper
* below selects a model on every call and delegates to IT. VS Code
* rejects system-role messages, so the request mapper uses User
* role only.
* - hookBus: 'engine' → createHookBus({bus:'engine'}) — VS Code has NO host event bus,
* so GSD owns the bus in-process (full subscribe + emit).
* - stateIO: 'sandboxed-storage' → createStateIO({io:'sandboxed-storage'}, {backend}) bound to a
* host-supplied storage (no arbitrary FS — web/no-child_process safe
* AT THE STATE-IO SEAM ITSELF; see the DESKTOP-ONLY note below for
* why the module as a WHOLE is not safe to require from browser.js).
* - embeddingMode: 'imperative' → createImperativeAdapter({runtime:'vscode'}) — engine-as-library.
*
* Distribution: VS Code is shipped as an EXTENSION (Marketplace), NOT file-projected onto a
* config dir, so it intentionally has NO runtime descriptor / `--vscode` installer entry — the
* extension IS the host. This module is the binding `vscode/extension.js` (the Node/desktop
* `main` entry) runs in activate().
*
* DESKTOP-ONLY (#2103 finding — do NOT require this from vscode/browser.js):
* despite the per-seam design above being conceptually host-agnostic, the CONCRETE engine-lib
* modules this file requires are NOT web-safe today — each pulls in Node's `fs`/`os`/`path`
* at module-load time (eagerly, regardless of which code path actually runs):
* - gsd-core/bin/lib/state-io.cjs → requires 'node:fs' directly.
* - gsd-core/bin/lib/adapter-imperative.cjs → requires install-engine.cjs (fs/os/path) and
* capability-loader.cjs (fs/os/path).
* - gsd-core/bin/lib/model-adapter.cjs → requires model-resolver.cjs → config-loader.cjs
* (fs/os/path) and configuration.cjs (fs/path).
* - gsd-core/bin/lib/hook-bus.cjs → no requires; this one alone is web-safe.
* In a real VS Code Web Extension host (webworker context) `require('node:fs')` does not
* resolve — activation would throw immediately. This is why `vscode/browser.js` does NOT
* require this module (or any of the engine-lib adapters) and instead implements its own
* minimal, genuinely-zero-Node-API composition directly against `vscode.lm`. See browser.js's
* header comment for the full rationale. Fixing the engine-lib modules to be fs-free is a
* separate, much larger engine-wide refactor (config-loader/capability-loader/install-engine
* all read real files from disk) — out of scope for the extension-surface stage; flagged here
* for visibility rather than silently routed around.
*
* Mock-friendly: takes `vscode` (with `vscode.lm`) + `hostStorage` ({read,write}) so it is
* behaviorally testable without a live VS Code host.
*
* @param {{ lm: { selectChatModels: (selector?: unknown) => Promise<Array<{ sendRequest: (messages: unknown, options?: unknown, token?: unknown) => unknown }>> } }} vscode
* VS Code namespace (vscode.lm.selectChatModels — NOT vscode.lm.sendRequest, which does not exist).
* @param {{ read: (path: string) => string, write: (path: string, content: string) => void }} hostStorage
* sandboxed-storage backend (e.g. globalState/workspaceState/secrets).
* @returns {object} the composed IDE host surface: { runtime, model, hookBus, stateIO, adapter, commands }
*/
module.exports = function bindGsdToVscode(vscode, hostStorage) {
if (!vscode || !vscode.lm || typeof vscode.lm.selectChatModels !== 'function') {
throw new TypeError('bindGsdToVscode: vscode.lm.selectChatModels is required (active model provider — VS Code exposes no vscode.lm.sendRequest; a model is selected via selectChatModels() and ITS sendRequest is called)');
}
if (!hostStorage || typeof hostStorage.read !== 'function' || typeof hostStorage.write !== 'function') {
throw new TypeError('bindGsdToVscode: hostStorage {read,write} is required (sandboxed-storage backend)');
}
const { createImperativeAdapter } = require('../gsd-core/bin/lib/adapter-imperative.cjs');
const { createModelAdapter } = require('../gsd-core/bin/lib/model-adapter.cjs');
const { createHookBus } = require('../gsd-core/bin/lib/hook-bus.cjs');
const { createStateIO } = require('../gsd-core/bin/lib/state-io.cjs');
// Active model: GSD model calls route through vscode.lm. There is no
// `vscode.lm.sendRequest` — a model must be selected first
// (`vscode.lm.selectChatModels()`, async → LanguageModelChat[]), then THAT
// model's own `.sendRequest(messages, options?, token?)` is called. Selects
// fresh on every call (no cross-call caching) so a model becoming available/
// unavailable between calls is always reflected; gsd-core/bin/lib/model-adapter.cjs's
// ActiveModelAdapter.sendRequest is a plain (non-async) pass-through that
// returns whatever the injected function returns, so an async injected
// function composes transparently — no adapter-side change needed (verified
// by reading model-adapter.cjs: `sendRequest(req) { return sendRequest(req); }`).
// No system-role messages — VS Code rejects them; a full extension builds
// LanguageModelChatMessages with User role only.
const model = createModelAdapter({ modelMode: 'active' }, {
async sendRequest(req) {
const models = await vscode.lm.selectChatModels();
const [chatModel] = models || [];
if (!chatModel || typeof chatModel.sendRequest !== 'function') {
throw new Error('bindGsdToVscode: vscode.lm.selectChatModels() returned no usable model (no active model available)');
}
return chatModel.sendRequest(req);
},
});
// Engine-owned hook bus: VS Code has no host bus, so GSD owns it in-process.
const hookBus = createHookBus({ bus: 'engine' });
// Sandboxed-storage stateIO bound to the host storage backend (no fs / no child_process).
const stateIO = createStateIO({ io: 'sandboxed-storage' }, { backend: hostStorage });
// Imperative adapter: the engine-as-library for the VS Code runtime.
const adapter = createImperativeAdapter({ runtime: 'vscode' });
// Command surface: Command Palette + Chat participant entries bound to the
// GSD command-routing hub via the imperative adapter (interface point 1).
const commands = Object.freeze({
'gsd.invoke': Object.freeze({ description: 'Invoke a GSD command via the embedded engine (palette/chat).' }),
'gsd.help': Object.freeze({ description: 'List GSD commands available in the IDE host.' }),
});
return Object.freeze({ runtime: 'vscode', model, hookBus, stateIO, adapter, commands });
};

View File

@@ -5,23 +5,81 @@
"version": "1.7.0-rc.5",
"publisher": "opengsd",
"engines": {
"vscode": "^1.90.0"
"vscode": "^1.105.0"
},
"categories": [
"Other"
],
"activationPoints": {
"onCommand": [
"gsd.invoke"
]
},
"activationEvents": [
"onCommand:gsd.invoke"
],
"main": "./extension.js",
"browser": "./browser.js",
"contributes": {
"commands": [
{
"command": "gsd.invoke",
"title": "GSD: Invoke Command"
}
],
"chatParticipants": [
{
"id": "gsd",
"name": "gsd",
"fullName": "GSD Core",
"description": "Invoke GSD orchestration commands from chat.",
"isSticky": true
}
],
"languageModelTools": [
{
"name": "gsd_progress",
"tags": ["gsd", "status"],
"toolReferenceName": "gsd-progress",
"displayName": "GSD Progress",
"modelDescription": "Reports GSD milestone/phase progress (percent complete, plan and summary counts) for the current project.",
"userDescription": "Check GSD project progress.",
"canBeReferencedInPrompt": true,
"inputSchema": {
"type": "object",
"properties": {},
"additionalProperties": false
}
},
{
"name": "gsd_workstreams",
"tags": ["gsd", "status"],
"toolReferenceName": "gsd-workstreams",
"displayName": "GSD Workstreams",
"modelDescription": "Lists the GSD parallel workstreams for the current project (or reports flat/single-workstream mode).",
"userDescription": "List GSD workstreams.",
"canBeReferencedInPrompt": true,
"inputSchema": {
"type": "object",
"properties": {},
"additionalProperties": false
}
},
{
"name": "gsd_plan_phase",
"tags": ["gsd", "plan"],
"toolReferenceName": "gsd-plan-phase",
"displayName": "GSD Plan Phase",
"modelDescription": "Looks up the plan index (plans, waves, checkpoints) for a named GSD phase. Read-only — does not create or modify a phase plan; use the /gsd-plan-phase chat workflow for full phase planning.",
"userDescription": "Look up a GSD phase's plan index.",
"canBeReferencedInPrompt": true,
"inputSchema": {
"type": "object",
"properties": {
"phase": {
"type": "string",
"description": "The GSD phase name to look up (e.g. \"01-core\")."
}
},
"required": ["phase"],
"additionalProperties": false
}
}
]
}
}