Files
msd-core/pi/gsd.cjs
Tom Boucher 27aa40f65e fix(#3023): stage pi's shared hook bundle outside pi's reserved hooks/ directory (#3175)
* test(#3023): failing-first guard — pi must not stage hooks in its reserved dir

pi reserves <configDir>/hooks as its deprecated extension location and warns
on every startup when it exists. Assert a pi install stages the shared hook
bundle under gsd-hooks/ instead, manifests it there, and never creates hooks/.

Also adds pi to the local-scope dir table in install-shared.cjs: pi was in
RUNTIME_META but not LOCAL_DIR_NAME, so scope:'local' resolved
path.join(root, undefined) and no local pi install could be exercised.

Fails before the fix. Verified via the remote runner.

* fix(#3023): stage pi's shared hook bundle outside pi's reserved hooks/ dir

pi reserves <configDir>/hooks as its now-deprecated extension location and
warns on every startup when that directory merely exists — checkDeprecatedExtensionDirs()
guards the warning with a bare existsSync(), unlike its tools/ sibling. GSD staged
its shared hook bundle exactly there, and pi's advised remediation (move it to
extensions/) would break the adapter's paths and expose GSD's .js helpers to pi's
extension auto-discovery.

The bundle directory name is now runtime-descriptor-driven: hostBehaviors
.sharedHooksDirName, defaulting to 'hooks' so all 18 other runtimes are
byte-identical. pi sets 'gsd-hooks'. The name is validated as a single path
segment — separators, dot-only segments, trailing dots, absolute paths, NUL,
and Windows reserved device names all fall back to the default, because the
value is joined onto a user's config root and written to.

Renamed in place rather than relocated: hook scripts resolve siblings via
__dirname/.., so a depth change would silently break them.

- install / uninstall / manifest sites all read the resolved name
- pi/gsd.cjs probes gsd-hooks then hooks, so dev checkouts and half-upgraded
  trees still resolve; the never-throws contract is preserved
- new migration 009 retires the legacy pi hooks/ dir on upgrade, using a new
  non-recursive remove-empty-dir engine primitive (rmdirSync only,
  symlink-refusing, containment-guarded); ADR-0008 amended accordingly
- fixes two latent name-dependencies the rename exposed: the stale-hook scan
  and the injection scanner's self-exclusion both hardcoded 'hooks'

Verified on the remote runner.

Closes #3023

* fix(#3023): close review findings and align emitted provenance with the rename

Adversarial review found two defects, and the remote runner found four
failure clusters. All fixed here.

Review BLOCKER — detect-custom-files was blind to the renamed bundle.
GSD_PREFIX_MANAGED_DIRS in gsd-tools.cjs hardcoded 'hooks', so for pi the
whole gsd-hooks/ tree was invisible to the custom-file scan and user-added
files there were never backed up before the next update's clean-install wipe.
The dir set now resolves via the .gsd-runtime marker plus the shipped
capability registry (never bin/install.js, which is not shipped into installed
trees), and falls back to scanning every known candidate when the runtime
cannot be determined — over-scanning is safe, under-scanning is the data loss.

Review MAJOR — the pi adapter bound to an empty bundle. resolveSharedHooksDir
accepted any directory, so an interrupted install left gsd-hooks/ winning over
a fully-staged legacy hooks/ and every hook silently no-opped. A candidate now
qualifies only if it is non-empty.

Remote-runner clusters:
- emitted-provenance had no rule for the gsd-hooks/ family; added two pi-scoped
  rules pointing at the same sources the existing hooks/ rules use. The table is
  total, so an unattributed family is a hard failure by design.
- pi tests in install-minimal-hooks and the install integration suite asserted
  the old layout; updated to derive the dir name from the descriptor rather than
  hardcoding either name.
- 19 unrelated-looking failures on node22 only were a leaked fs mock: t.after()
  runs in registration order, cleanup was registered before mock.restoreAll(),
  and node22's JS rimraf calls the public fs.rmdirSync while node24's native
  path does not — so the EACCES stub leaked process-wide on one lane. Restore
  now runs first.

Verified on the remote runner.

* fix(#3023): honor PI_CODING_AGENT_DIR, ack the rename ripple, fix expandTilde

pi resolves its agent dir as PI_CODING_AGENT_DIR ?? ~/<CONFIG_DIR_NAME>/agent
(packages/coding-agent/src/config.ts). GSD's pi descriptor declared an empty
configHome.env, so a user with that variable set had GSD installed where pi
never looks. Added the env name; the dot-home-nested resolver already handled
the override, so no resolver logic changed.

Also fixes expandTilde in the shared runtime-homes resolver, found while adding
that: it hardcoded os.homedir() and ignored the opts.home every caller threads,
so EVERY runtime's tilde-valued env override (claude, antigravity, windsurf, pi)
silently resolved against the real home. That is a correctness bug and a
test-escape hazard — a sandboxed test asserting on a tilde override reached the
developer's actual home directory. Now threaded through every branch; behavior
with no injected home is unchanged.

Adds the emitted-drift ack fragment for the 58 pi paths whose emitted location
moved with the rename. The provenance rules satisfy the totality gate; the
differential gate needs the ack because the hook sources are byte-unchanged —
only the installer's target directory moved. The two hook files this branch
genuinely edits stay attributed and are not double-acked.

Note on piConfig.configDir: it is read from pi's OWN installed package.json
(getPackageDir walks up from pi's __dirname), alongside piConfig.name — a
white-label setting for a redistributed pi fork, not a per-project user setting.
Documented accordingly rather than treated as an unsupported override.

Verified on the remote runner.

* fix(#3023): reject blank env overrides, pin adapter/descriptor parity

Three review findings, all fixed.

A whitespace-only config-dir override was accepted verbatim: the guard was
`if (val)`, falsy only for the empty string, so PI_CODING_AGENT_DIR='   '
resolved to a literal three-space directory name instead of falling back to the
descriptor default. Fixed across every env-consuming branch — dot-home,
dot-home-nested, all three xdg steps, and generic-agents-root — not just pi's.
Non-blank values are still never trimmed, so '~/My Agent Dir' keeps working.

pi/gsd.cjs's probe list and the descriptor were two independent sources of truth
for the bundle directory name; a future rename would have desynced them silently
and left every pi hook quiet with no error. The probe list stays deliberate — it
must resolve in a dev checkout and a half-upgraded tree, where the registry's
answer would be wrong — so this adds the parity assertion the repo's
generative-fix-divergence rule calls for: the descriptor value must be the FIRST
candidate, and the default must remain present.

Changeset body rewritten to cover the two later user-facing fixes it had not
caught up with.

Verified on the remote runner.

* chore(#3023): backfill changeset PR number

* fix(#3023): anchor injection-scan patterns and fix a macOS detection hole

CI's security job flagged CONTEXT.md:124 — pre-existing prose reading 'not the
same fact as a genuinely empty or absent one'. The match was the 'act as a'
INSIDE 'f-act as a': the pattern had no left word boundary, so any word ending
in act tripped it (fact, impact, contract, artifact, interact, redact,
abstract). My four-line CONTEXT.md edit dragged the latent false positive into
this PR because the scan is diff-scoped by file but reads whole files. Anchored
with (^|[^[:alnum:]]) rather than rewording maintainer-owned prose, which would
have left the class alive for the next PR touching any file saying 'fact as a'.

Auditing the rest of the list for the same class surfaced a real detection hole:
the eval/exec/Function patterns matched a quote via \x27, a GNU-grep-only hex
escape. BSD/macOS grep reads it as four literal characters, so single-quoted
eval('...')/exec('...') payloads were NEVER detected there while passing on
GNU-grep CI. Replaced with a literal apostrophe class.

Boundaries were added only where a real word-suffix collision exists; exec,
jailbreak, developer mode and the role-manipulation family were audited and
deliberately left unanchored. 22 new cases cover both directions — the false
positives now scan clean, and every real payload still fires, including the
quote/punctuation/start-of-line boundary forms.

Also builds this branch's injection test fixture at runtime instead of carrying
the literal phrase, so the payload keeps its teeth without tripping the scan.

Verified on the remote runner.

---------

Co-authored-by: sim <sim@local>
2026-08-07 13:41:21 -04:00

431 lines
21 KiB
JavaScript

'use strict';
/**
* GSD extension for pi (pi.dev) — ADR-1239 Phase D / #1944, upgraded #2102 Stage 2.
*
* pi is a Programmatic-CLI host whose TS extensions implement the ExtensionAPI
* (`@earendil-works/pi-coding-agent`): registerCommand({handler(args, ctx)}) /
* registerTool({execute(toolCallId, params, signal, onUpdate, ctx)}) / pi.on(event, handler).
* This extension binds GSD's command surface to pi via the imperative adapter
* path — the programmatic-CLI peer of the OpenCode worked binding.
*
* Installation: copy this file to ~/.pi/agent/extensions/gsd.js — note the
* `.js` DEST suffix, not `.cjs`. pi auto-discovers extensions/ entries through
* `isExtensionFile()`, which accepts only `.ts`/`.js` and skips anything else
* SILENTLY (no error, no log line), so a `.cjs` dest installs fine and is then
* never loaded (#2470). This source file keeps its `.cjs` suffix on purpose —
* tests `require()` it directly and `.cjs` is unambiguous CommonJS — and pi
* loads the copied file via jiti, which handles CommonJS and ESM alike, so the
* suffix gates discovery, not parsing. The engine is resolved from the installed
* GSD tree (walk-up like the OpenCode plugin). pi's shared hooks/ bundle
* (hooks/*.js + hooks/lib/git-cmd.js) is installed alongside the extension —
* capabilities/pi/capability.json does NOT set
* `hostBehaviors.skipSharedHooksInstall` (#2102 Stage 2 fix; pi is
* architecturally identical to OpenCode here: `hooksSurface: 'none'` + a
* native extension that spawns the staged hooks — not Kilo/ZCode's
* no-plugin-surface case, where the same hooks would be genuine dead weight).
* This is what makes the event bridges below (and the tokenizer require)
* resolve for real in an installed tree, not just in this dev repo.
*
* Engine entry: dispatch is SUBPROCESS-REUSE to gsd-tools.cjs (bounded,
* no-throw — dispatchGsdCommand in shell-command-projection.cjs), NOT an
* in-process command-routing hub. No fully-populated hub factory exists
* anywhere in gsd-core — every createHub() caller in the tree builds a
* single-family hub for its own narrow purpose — so the "in-process createHub"
* framing of the original #1944 cut was aspirational and is not achievable
* without a hub factory that doesn't exist. This mirrors the precedent already
* established for the OpenCode/Kilo hook bridge (.opencode/plugins/gsd-core.js
* header: "Architecture: SUBPROCESS REUSE ... spawns existing hook scripts as
* child processes") — the same pattern, applied to command dispatch. The
* companion MCP server (gsd-mcp-server) dispatches through the SAME shared
* helper for out-of-process hosts.
*
* @param {object} pi pi ExtensionAPI (registerTool/registerCommand/on/…)
*/
const fs = require('fs');
const path = require('path');
const { spawnSync } = require('child_process');
// Resolve the GSD engine tree (the dir holding gsd-core/ + hooks/).
// Works across dev (<root>/pi/gsd.cjs → <root>) and installed layouts.
function resolveEngineRoot(startDir) {
let dir = startDir;
for (let i = 0; i < 6; i++) {
if (fs.existsSync(path.join(dir, 'gsd-core'))) return dir;
const parent = path.dirname(dir);
if (parent === dir) break;
dir = parent;
}
return path.resolve(startDir, '..');
}
const ENGINE_ROOT = resolveEngineRoot(__dirname);
const GSD_CORE = path.join(ENGINE_ROOT, 'gsd-core');
// #3023: pi reserves `hooks/` as its (now deprecated) extension directory and
// warns on every startup when one exists, so the installer stages GSD's shared
// hook bundle under `gsd-hooks/` for pi. Probe in preference order rather than
// hardcoding either name: an installed pi tree has `gsd-hooks/`, a dev checkout
// has the repo-root `hooks/`, and a half-upgraded tree can transiently have both
// — in which case the renamed bundle is the current one and wins.
const SHARED_HOOKS_DIR_CANDIDATES = ['gsd-hooks', 'hooks'];
/**
* Absolute path to the staged shared-hook bundle, or null when none is present.
* Never throws — a stat failure on any candidate is treated as "not this one".
*
* A candidate qualifies only when it is a directory AND non-empty. An install
* can be interrupted between `mkdirSync(gsd-hooks)` and the file copy, leaving
* a directory that exists but is empty; `gsd-hooks` is probed FIRST (it is the
* preferred, current name), so an empty `gsd-hooks/` would otherwise win over
* a fully-staged legacy `hooks/` and every hook would silently no-op —
* `runHook`'s `fs.existsSync(hookPath)` guard degrades per-file, so binding to
* an empty bundle produces no error at all, just silent inaction. A
* partially-staged bundle must lose to a fully-staged one for that reason.
* @param {string} engineRoot
* @returns {string|null}
*/
function resolveSharedHooksDir(engineRoot) {
for (const name of SHARED_HOOKS_DIR_CANDIDATES) {
const candidate = path.join(engineRoot, name);
try {
if (!fs.statSync(candidate).isDirectory()) continue;
if (fs.readdirSync(candidate).length === 0) continue;
return candidate;
} catch { /* absent or unreadable — try the next candidate */ }
}
return null;
}
const SHARED_HOOKS_DIR = resolveSharedHooksDir(ENGINE_ROOT);
// ── curated top-level command families (gsd-tools.cjs TOP_LEVEL_USAGE) ──────
// readCmdNames() (scripts/fix-slash-commands.cjs) reads commands/, which pi
// does NOT install (it ships a single native-extension file, no shared
// commands/ dir) — it would always return []. This is a self-contained,
// hand-curated subset of the STABLE top-level families documented by
// `node gsd-core/bin/gsd-tools.cjs --help` (gsd-tools.cjs:689-705). Named +
// exported (via _internals) so a test can assert against it directly.
const PI_COMMAND_FAMILIES = Object.freeze([
'agent', 'capability', 'check', 'commit', 'config-get', 'config-path',
'config-set', 'effort', 'git', 'graphify', 'init', 'intel', 'learnings',
'list-todos', 'loop', 'milestone', 'phase', 'phases', 'progress',
'requirements', 'research-plan', 'research-store', 'resolve-granularity',
'resolve-model', 'roadmap', 'scaffold', 'smart-entry', 'state', 'task',
'template', 'user-story', 'validate', 'verify', 'workstream', 'worktree',
]);
/**
* Filter PI_COMMAND_FAMILIES by prefix (startsWith). Returns null when there
* are no matches, per pi's `AutocompleteItem[]|null` contract.
* @param {string} prefix
* @returns {{value: string, label: string}[] | null}
*/
function getArgumentCompletions(prefix) {
const p = typeof prefix === 'string' ? prefix : '';
const matches = PI_COMMAND_FAMILIES.filter((name) => name.startsWith(p));
if (matches.length === 0) return null;
return matches.map((value) => ({ value, label: value }));
}
/**
* Tokenize the raw `/gsd <args>` string into { family, subcommand, args }.
* Reuses the quote-aware whitespace tokenizer already shipped for hooks
* (the staged shared-hook bundle's `lib/git-cmd.js`'s `tokenize`) rather than
* re-implementing shell-word splitting a second time. #2102 Stage 2: pi's capability descriptor no
* longer sets `hostBehaviors.skipSharedHooksInstall` (adversarial-review
* finding #1/#2 — pi ships NO hooks/ with that flag set, so this require was
* dead in a real install), so the shared hooks/ bundle — including
* hooks/lib/git-cmd.js — is installed alongside the extension for real
* (mirrors OpenCode, whose native plugin also spawns the staged hooks/*.js
* bundle). The require below is therefore the PRIMARY, live path in an
* installed pi tree; the whitespace-split fallback stays as defense-in-depth
* for a corrupted/partial install (e.g. a user who deleted hooks/lib/ by
* hand) rather than the only-ever-taken path.
* @param {string} rawArgs
* @returns {{ family: string, subcommand?: string, args: string[] }}
*/
function parseGsdCommandArgs(rawArgs) {
let tokenize;
try {
if (!SHARED_HOOKS_DIR) throw new Error('no staged hook bundle');
({ tokenize } = require(path.join(SHARED_HOOKS_DIR, 'lib', 'git-cmd.js')));
} catch {
tokenize = (s) => String(s || '').split(/\s+/).filter(Boolean);
}
const tokens = tokenize(typeof rawArgs === 'string' ? rawArgs : '');
return {
// Empty args → dispatch gsd-tools.cjs's own --help surface (a real,
// working, ok:true default — NOT the 'query'/'help' pairing the original
// #1944 cut used, which is not a valid gsd-tools.cjs command).
family: tokens[0] || '--help',
subcommand: tokens[1],
args: tokens.slice(2),
};
}
/**
* Best-effort TypeBox schema for gsd_invoke's `parameters`, falling back to a
* plain JSON-Schema object when the `typebox` package is unavailable (it is
* NOT a gsd-core dependency — pi's own ExtensionAPI contract expects TypeBox,
* but nothing in this repo installs it). TypeBox schemas ARE JSON Schema, so
* the fallback object is structurally equivalent for hosts that accept plain
* JSON Schema; this is a best-effort shim for the flat-file extension case.
* @returns {object}
*/
function buildGsdInvokeParameters() {
try {
const typebox = require('typebox');
const Type = typebox && typebox.Type;
if (Type) {
return Type.Object({
family: Type.String(),
subcommand: Type.Optional(Type.String()),
args: Type.Optional(Type.Array(Type.String())),
});
}
} catch {
// typebox is not installed in this environment — fall through to the
// plain JSON-Schema fallback below. This is the normal path (typebox is
// NOT a gsd-core dependency), so no warning is emitted (#3022).
}
return {
type: 'object',
properties: {
family: { type: 'string' },
subcommand: { type: 'string' },
args: { type: 'array', items: { type: 'string' } },
},
required: ['family'],
};
}
/**
* Build the `before_provider_request` handler that steers pi's model
* selection to GSD's tier-resolved id (modelMode: 'active' per
* capabilities/pi/capability.json). GSD does NOT call `pi.registerProvider` —
* that registers a NEW model provider; GSD's job here is only to pick a
* tier-appropriate id AMONG pi's EXISTING built-in anthropic models, so
* registerProvider would be the wrong primitive (it would wrongly add a fake
* provider instead of steering the real one).
*
* v1 tier policy: GSD does not yet expose a per-turn/per-agent tier signal to
* this event, so a conservative fixed default tier is used (parameterized —
* default 'sonnet' — so a future richer signal, or a test, can override it).
*
* ASSUMPTION (flagged — verify against a live pi host): the event payload's
* model field is named `model`, matching the anthropic-messages payload shape
* (Context7-confirmed for the wire protocol; pi's own before_provider_request
* event schema was not independently verifiable in this environment). If pi's
* actual field name differs, this returns the WRONG key and pi's fail-open
* default takes over only because bare-model-id mismatches degrade to
* provider-level errors, not GSD-level ones — a discrepancy here needs a
* live-host smoke test before shipping past this stage.
*
* Fail-open: any resolution failure (or a null/falsy resolved model — e.g. an
* unrecognized tier) returns `undefined`, leaving pi's model choice untouched.
* NEVER returns a payload with a missing/empty model id.
*
* @param {{ tier?: string }} [opts]
* @returns {(event: object, ctx: object) => Promise<object|undefined>}
*/
function buildBeforeProviderRequestHandler({ tier = 'sonnet' } = {}) {
return async function onBeforeProviderRequest(event, ctx) {
try {
const effectiveCwd = (ctx && ctx.cwd) || process.cwd();
const { loadConfig } = require(path.join(GSD_CORE, 'bin', 'lib', 'config-loader.cjs'));
const config = loadConfig(effectiveCwd);
const overrides = (config && config.model_profile_overrides) || undefined;
// #2460: FAIL-OPEN when the user has not explicitly configured
// model_profile_overrides[runtime][tier]. pi is provider-agnostic
// (kimi-coding, zai, openrouter, openai-codex, minimax, anthropic, …);
// the built-in RUNTIME_PROFILE_MAP.pi.sonnet default is `claude-sonnet-5`,
// an Anthropic-ecosystem assumption that is wrong for every non-Anthropic
// provider. Returning the built-in default unconditionally rewrote every
// outgoing request to a model the active provider did not know.
//
// resolveTierEntry falls back to the built-in catalog when no override is
// present, so we cannot call it to detect "did the user opt in?" — we
// inspect the override map directly. Only when the user has explicitly
// set model_profile_overrides[runtime][tier] do we steer; otherwise we
// return undefined and pi's chosen model flows through untouched.
const userRaw = overrides && overrides.pi ? overrides.pi[tier] : undefined;
// `''` is treated the same as null/undefined: an explicit empty-string
// override is the user clearing a previously-set value, NOT opting in to
// steering. Without this guard, `resolveTierEntry`'s falsy `if (userRaw)`
// check would silently fall back to the built-in catalog and rewrite
// payload.model to claude-sonnet-5 — re-introducing the exact bug this
// handler exists to prevent.
if (userRaw === undefined || userRaw === null || userRaw === '') {
return undefined; // fail-open — leave pi's model untouched
}
const { resolveTierEntry } = require(path.join(GSD_CORE, 'bin', 'lib', 'model-resolver.cjs'));
const entry = resolveTierEntry({ runtime: 'pi', tier, overrides });
const modelId = entry && typeof entry.model === 'string' && entry.model.length > 0 ? entry.model : null;
if (!modelId) return undefined; // fail-open — leave pi's model untouched
const basePayload = (event && typeof event === 'object' && event.payload && typeof event.payload === 'object')
? event.payload
: {};
return { ...basePayload, model: modelId };
} catch {
return undefined; // fail-open on any resolution error
}
};
}
/**
* Bounded subprocess bridge to GSD's Claude Code hook scripts. Mirrors
* .opencode/plugins/gsd-core.js's `runHook` (SUBPROCESS-REUSE): spawns
* `node <hooks/hookFile>` with the payload piped to stdin, on a bounded
* timeout. NEVER throws — a missing hook file, a spawn error, or a timeout
* all degrade to a silent-allow result so a hook problem can never block pi.
* @param {string} hookFile filename under the resolved shared-hook bundle, e.g. "gsd-context-monitor.js"
* @param {object} payload
* @param {{ timeout?: number, cwd?: string }} [opts]
* @returns {{ stdout: string, exitCode: number, timedOut: boolean }}
*/
function runHook(hookFile, payload, opts = {}) {
if (!SHARED_HOOKS_DIR) return { stdout: '', exitCode: 0, timedOut: false };
const hookPath = path.join(SHARED_HOOKS_DIR, hookFile);
if (!fs.existsSync(hookPath)) return { stdout: '', exitCode: 0, timedOut: false };
const timeout = opts.timeout || 8000;
let result;
try {
result = spawnSync(process.execPath, [hookPath], {
input: JSON.stringify(payload || {}),
encoding: 'utf8',
timeout,
cwd: opts.cwd || process.cwd(),
windowsHide: true,
});
} catch {
return { stdout: '', exitCode: 0, timedOut: false };
}
const stdout = (result && typeof result.stdout === 'string') ? result.stdout.trim() : '';
const exitCode = (result && result.status != null) ? result.status : 0;
return { stdout, exitCode, timedOut: !!(result && result.signal === 'SIGTERM') };
}
module.exports = function gsdPiExtension(pi) {
if (!pi || typeof pi !== 'object') {
throw new TypeError('gsdPiExtension: pi ExtensionAPI is required');
}
// ── /gsd command: dispatch through gsd-tools.cjs (subprocess-reuse) ──────
pi.registerCommand('gsd', {
description: 'Invoke a GSD command via the embedded engine (subprocess-reuse adapter).',
getArgumentCompletions,
handler: async (args, ctx) => {
const cwd = (ctx && ctx.cwd) || process.cwd();
const { family, subcommand, args: rest } = parseGsdCommandArgs(args);
let dispatchGsdCommand;
try {
({ dispatchGsdCommand } = require(path.join(GSD_CORE, 'bin', 'lib', 'shell-command-projection.cjs')));
} catch (e) {
// #2991: return the structured { content } shape Pi's ExtensionAPI
// displays, not a bare string (which Pi silently drops).
return { content: [{ type: 'text', text: `GSD engine unavailable: ${e && e.message ? e.message : String(e)}` }] };
}
const result = dispatchGsdCommand({ family, subcommand, args: rest, cwd });
// #2991: match gsd_invoke's proven output shape so Pi actually displays it.
const text = result.ok
? result.stdout
: `GSD error: ${result.stderr || result.stdout || `dispatch failed (exit ${result.code})`}`;
return { content: [{ type: 'text', text }] };
},
});
// ── gsd_invoke tool: programmatic command invocation ────────────────────
pi.registerTool({
name: 'gsd_invoke',
label: 'GSD Invoke',
description: 'Invoke a GSD command family/subcommand through the engine.',
parameters: buildGsdInvokeParameters(),
execute: async (toolCallId, params, signal, onUpdate, ctx) => {
const p = (params && typeof params === 'object') ? params : {};
const family = typeof p.family === 'string' ? p.family : '';
if (!family) {
return { content: [{ type: 'text', text: 'gsd_invoke requires a non-empty string "family".' }] };
}
const subcommand = typeof p.subcommand === 'string' ? p.subcommand : undefined;
const invokeArgs = Array.isArray(p.args) ? p.args : [];
const cwd = (ctx && ctx.cwd) || process.cwd();
let dispatchGsdCommand;
try {
({ dispatchGsdCommand } = require(path.join(GSD_CORE, 'bin', 'lib', 'shell-command-projection.cjs')));
} catch (e) {
return { content: [{ type: 'text', text: `GSD engine unavailable: ${e && e.message ? e.message : String(e)}` }] };
}
const result = dispatchGsdCommand({ family, subcommand, args: invokeArgs, cwd });
const text = result.ok ? result.stdout : (result.stderr || result.stdout || `dispatch failed (exit ${result.code})`);
return { content: [{ type: 'text', text }] };
},
});
// ── before_provider_request: active-model steering (modelMode: 'active') ──
// GSD steers pi's EXISTING built-in anthropic models; it does NOT call
// pi.registerProvider (that would wrongly register a NEW fake provider —
// see buildBeforeProviderRequestHandler's doc comment).
pi.on('before_provider_request', buildBeforeProviderRequestHandler());
// ── Event bindings: bounded subprocess bridge to GSD's hook scripts ──────
// Each binding fails open — a hook error/timeout/missing-file never blocks
// pi (mirrors .opencode/plugins/gsd-core.js's runHook SUBPROCESS-REUSE
// pattern, applied to pi's ExtensionAPI event names).
// session_start → SessionStart-equivalent bootstrap.
pi.on('session_start', async (event, ctx) => {
try {
const cwd = (ctx && ctx.cwd) || process.cwd();
runHook('gsd-ensure-canonical-path.js', { hook_event_name: 'SessionStart', cwd }, { cwd });
} catch { /* fail-open */ }
});
// before_agent_start → workflow-guard bridge. Forward-compatible binding:
// gsd-workflow-guard.js's current triggers are tool-scoped (Write/Edit/
// Bash via tool_name/tool_input), so with no tool_name in the payload it
// fires as a safe no-op today — wired so a future agent-start-scoped check
// can attach without a plugin change (mirrors the OpenCode session.idle
// recognized-but-unused sentinel pattern).
pi.on('before_agent_start', async (event, ctx) => {
try {
const cwd = (ctx && ctx.cwd) || process.cwd();
runHook('gsd-workflow-guard.js', { hook_event_name: 'before_agent_start', cwd }, { cwd });
} catch { /* fail-open */ }
});
// session_before_compact → PreCompact-equivalent (context-usage bridge).
pi.on('session_before_compact', async (event, ctx) => {
try {
const cwd = (ctx && ctx.cwd) || process.cwd();
runHook('gsd-context-monitor.js', { hook_event_name: 'PreCompact', cwd }, { cwd });
} catch { /* fail-open */ }
});
// tool_call event: lifecycle hook bridge attachment point (kept from the
// original cut — the PreToolUse/PostToolUse tool_name/tool_input mapping
// is a follow-up once pi's tool_call payload shape is verified against a
// live host).
pi.on('tool_call', async function () {
/* GSD hook bridge attachment point (PreToolUse/PostToolUse mapping). */
});
};
// Test-only internals (mirrors the OpenCode plugin pattern) — wired to the
// real functions (not stubs) so tests can exercise parsing/completions/model
// resolution WITHOUT a live pi runtime.
module.exports._internals = {
resolveEngineRoot,
resolveSharedHooksDir,
SHARED_HOOKS_DIR_CANDIDATES,
parseGsdCommandArgs,
getArgumentCompletions,
PI_COMMAND_FAMILIES,
buildBeforeProviderRequestHandler,
buildGsdInvokeParameters,
runHook,
};