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:
197
vscode/browser.js
Normal file
197
vscode/browser.js
Normal 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,
|
||||
};
|
||||
@@ -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
113
vscode/host-binding.js
Normal 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 });
|
||||
};
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user