From 1eadbdb2f501769db0a9be28a9c83bcbd47ad941 Mon Sep 17 00:00:00 2001 From: Serhii Vecherenko Date: Sun, 14 Jun 2026 13:32:12 -0700 Subject: [PATCH] feat(commandcode): add lifecycle hook plugin support - package and stage the Command Code plugin assets - install lifecycle hooks and map hook events to Lightcode intents - propagate a denylist-safe hook nonce and set optimistic working on submit - refresh Command Code terminal heuristics and add install/intent tests --- scripts/prepare-agent-plugins.mjs | 5 + src/supervisor/agents/base/types.ts | 10 + .../agents/commandcode/commandcode.test.ts | 19 +- src/supervisor/agents/commandcode/index.ts | 58 ++- .../agents/commandcode/plugin/forward.mjs | 66 +++ .../commandcode/plugin/install.stage.test.ts | 65 +++ .../agents/commandcode/plugin/install.test.ts | 77 ++++ .../agents/commandcode/plugin/install.ts | 398 ++++++++++++++++++ .../commandcode/plugin/intentMap.test.ts | 31 ++ .../agents/commandcode/plugin/intentMap.ts | 39 ++ .../agents/commandcode/plugin/plugin.json | 7 + src/supervisor/agents/commandcode/terminal.ts | 24 +- .../lightcode-hook-runtime.mjs | 6 +- .../runtime/cliHookPluginCoordinator.ts | 7 + .../runtime/threadSessionManager.ts | 8 + 15 files changed, 807 insertions(+), 13 deletions(-) create mode 100644 src/supervisor/agents/commandcode/plugin/forward.mjs create mode 100644 src/supervisor/agents/commandcode/plugin/install.stage.test.ts create mode 100644 src/supervisor/agents/commandcode/plugin/install.test.ts create mode 100644 src/supervisor/agents/commandcode/plugin/install.ts create mode 100644 src/supervisor/agents/commandcode/plugin/intentMap.test.ts create mode 100644 src/supervisor/agents/commandcode/plugin/intentMap.ts create mode 100644 src/supervisor/agents/commandcode/plugin/plugin.json diff --git a/scripts/prepare-agent-plugins.mjs b/scripts/prepare-agent-plugins.mjs index 6f78b817..b2691f9d 100644 --- a/scripts/prepare-agent-plugins.mjs +++ b/scripts/prepare-agent-plugins.mjs @@ -69,6 +69,11 @@ const PLUGINS = [ assets: ["plugin.json", "forward.mjs"], srcDir: join(repoRoot, "src", "supervisor", "agents", "grok", "plugin"), }, + { + kind: "commandcode", + assets: ["plugin.json", "forward.mjs"], + srcDir: join(repoRoot, "src", "supervisor", "agents", "commandcode", "plugin"), + }, { kind: "opencode", assets: ["plugin.json", "lightcode-status.mjs"], diff --git a/src/supervisor/agents/base/types.ts b/src/supervisor/agents/base/types.ts index fc95cb2b..2a0336e6 100644 --- a/src/supervisor/agents/base/types.ts +++ b/src/supervisor/agents/base/types.ts @@ -251,6 +251,16 @@ export interface AgentTerminalObserver { detectInvalidSessionRef?(text: string): boolean; detectAutoResponse?(text: string): string | null; workingSilenceTimeoutMs?: number | null; + /** + * Set `working` optimistically the moment the user submits a prompt to a live + * terminal session, instead of waiting for a status signal. Command Code (and + * any CLI whose hooks/OSC emit no turn-START event — it only has + * PreToolUse/Stop) has no reliable `working` edge for a pure-text turn + * otherwise; the authoritative `Stop` hook clears it back to `idle`. Only + * honored while a CLI hook plugin is active (`cliHookEnvInjected`) so a + * missing turn-finished signal can never strand the thread in `working`. + */ + optimisticWorkingOnSubmit?: boolean; handleOscNotification?(notification: OscNotification): TerminalStatusHint | null; handleOscTitle?(title: OscTitle): TerminalStatusHint | null; handleOscShellEvent?(event: OscShellEvent): TerminalStatusHint | null; diff --git a/src/supervisor/agents/commandcode/commandcode.test.ts b/src/supervisor/agents/commandcode/commandcode.test.ts index 9dc553d8..21fbe823 100644 --- a/src/supervisor/agents/commandcode/commandcode.test.ts +++ b/src/supervisor/agents/commandcode/commandcode.test.ts @@ -196,8 +196,15 @@ describe("createCommandCodeAdapter", () => { }); describe("detectCommandCodeTerminalStatus", () => { - it("treats the empty prompt as idle", () => { - const text = ["────────────", ">", "────────────", "? for shortcuts"].join("\n"); + it("treats the empty composer as idle", () => { + // Real idle screen: `❯ Ask your question...` placeholder + shortcuts hint. + const text = [ + "────────────", + "❯ Ask your question...", + "────────────", + " » permission bypass on [shift+tab]", + " ? for shortcuts", + ].join("\n"); expect(detectCommandCodeTerminalStatus(text)).toEqual({ status: "idle", attention: "none", @@ -211,8 +218,12 @@ describe("detectCommandCodeTerminalStatus", () => { expect(result?.attention).toBe("needs_approval"); }); - it("detects the braille loader as working", () => { - expect(detectCommandCodeTerminalStatus("⡿ Thinking…")).toMatchObject({ + it("detects the working spinner row via the `esc to interrupt` invariant", () => { + // The verb label is randomized ("Cogitating"/"Processing"/"Conjuring"/…), + // so detection anchors on `esc to interrupt`, not the label. + expect( + detectCommandCodeTerminalStatus(" · Conjuring esc to interrupt • 1s • ↑ 0"), + ).toMatchObject({ status: "working", attention: "working", }); diff --git a/src/supervisor/agents/commandcode/index.ts b/src/supervisor/agents/commandcode/index.ts index 731e4d04..99c0b534 100644 --- a/src/supervisor/agents/commandcode/index.ts +++ b/src/supervisor/agents/commandcode/index.ts @@ -1,11 +1,18 @@ import type { AgentCapability, PromptSegment } from "@/shared/contracts"; -import { detectAgentInstall, type AgentAdapter } from "../base"; +import { detectAgentInstall, type AgentAdapter, type TerminalStatusHint } from "../base"; +import { resolveInstallNodePath, warnIfPluginManifestMissing } from "../plugin/installerBase"; import { buildCommandCodeArgs } from "./argv"; import { COMMANDCODE_DEFAULT_MODEL_ID, commandCodeDetectionSpec, defaultCommandCodeCapabilities, } from "./detection"; +import { + installCommandCodePlugin, + isCommandCodePluginInstalled, + readBundledCommandCodePluginVersion, + uninstallCommandCodePlugin, +} from "./plugin/install"; import { detectCommandCodeInvalidSessionRef } from "./session"; import { isUuid, @@ -17,6 +24,22 @@ import { detectCommandCodeTerminalStatus } from "./terminal"; export { detectCommandCodeInvalidSessionRef } from "./session"; +const COMMANDCODE_PLUGIN_VERSION = readBundledCommandCodePluginVersion(); + +warnIfPluginManifestMissing("commandcode", COMMANDCODE_PLUGIN_VERSION); + +/** + * Command Code's L1 `Stop` hook owns the authoritative working→idle edge, so + * while a hook is active we only let the L2 terminal-text fallback surface + * `working` (for follow-up text turns, which have no turn-start hook) and the + * interactive `needs_approval` / `needs_reply` states (no hook event exists for + * those). `idle` is suppressed here so a stale spinner frame can never beat the + * `Stop` hook to the idle transition. + */ +function commandCodeHookActiveTerminalFallback(hint: TerminalStatusHint): boolean { + return hint.status !== "idle"; +} + // A cheap model for one-shot utility runs (title/commit generation) when the // caller doesn't pin one. Kept in sync with the renderer's registered // utility-task defaults. @@ -44,6 +67,34 @@ export function createCommandCodeAdapter(): AgentAdapter { wsl: { BROWSER: "/bin/true" }, }, + // ── CLI hook plugin support ────────────────────────────────────────── + // Command Code emits no OSC; its Claude-compatible hook system is the only + // stable working/idle signal. `Stop` → idle is authoritative (full L1, not + // partialL1). Install is manual-only (the launch path never installs a + // missing plugin) and the hooks live in the user's `~/.commandcode/ + // settings.json`, which the CLI auto-trusts. + pluginId: "lightcode-status@commandcode", + pluginVersion: COMMANDCODE_PLUGIN_VERSION, + minProtocolVersion: 1, + async isPluginSupported() { + // Native: forward.mjs runs under Electron-as-Node via a generated + // wrapper. WSL: install resolves a distro node. Always supported. + return true; + }, + async isPluginInstalled(ctx) { + return isCommandCodePluginInstalled(ctx); + }, + async installPlugin(ctx) { + const node = await resolveInstallNodePath(ctx); + if (!node.ok) return node; + const result = installCommandCodePlugin(ctx, { resolvedNodePath: node.nodePath }); + if (!result.ok) return result; + return { ok: true, version: result.version }; + }, + async uninstallPlugin(ctx) { + uninstallCommandCodePlugin(ctx); + }, + async detectInstall(ctx) { const status = await detectAgentInstall(ctx, commandCodeDetectionSpec); capabilities = status.capabilities; @@ -99,6 +150,11 @@ export function createCommandCodeAdapter(): AgentAdapter { }, detectTerminalStatus: detectCommandCodeTerminalStatus, + shouldApplyTerminalStatusWhileHookActive: commandCodeHookActiveTerminalFallback, + // Command Code emits no turn-START hook (only PreToolUse/PostToolUse/Stop), + // so a pure-text turn would otherwise show no `working`. Flip to working on + // submit; the `Stop` hook returns it to idle. + optimisticWorkingOnSubmit: true, detectInvalidSessionRef: detectCommandCodeInvalidSessionRef, defaultOneShotModel: COMMANDCODE_ONESHOT_MODEL_ID, diff --git a/src/supervisor/agents/commandcode/plugin/forward.mjs b/src/supervisor/agents/commandcode/plugin/forward.mjs new file mode 100644 index 00000000..de4ddec9 --- /dev/null +++ b/src/supervisor/agents/commandcode/plugin/forward.mjs @@ -0,0 +1,66 @@ +#!/usr/bin/env node +/** + * Command Code lifecycle hook forwarder for Lightcode. + * + * Command Code invokes each configured hook as a shell command, passing the + * event name as argv[2] (rendered by the installer as ` `) and + * the JSON payload on stdin. The payload also carries `hook_event_name`, so we + * prefer that and fall back to argv. + * + * Reads `LIGHTCODE_HOOK_URL` / `LIGHTCODE_HOOK_SECRET` / `LIGHTCODE_THREAD_ID` + * from env, maps the event to a universal Lightcode intent, and POSTs the + * envelope. When those vars are unset (the user runs `command-code` outside + * Lightcode) the forwarder no-ops. Emits NOTHING on stdout — Command Code, like + * Claude Code, can relay hook stdout into the model's context. + * + * Generic plumbing lives in the shared `lightcode-hook-runtime.mjs` sibling. + * NOTE: the intent map below mirrors `intentMap.ts` — keep both in sync. + */ + +import { + copyStringExtra, + readPluginVersionFromManifest, + runForwarder, +} from "./lightcode-hook-runtime.mjs"; + +const PLUGIN_VERSION = readPluginVersionFromManifest(import.meta.url); + +function intentFor(eventName, payload) { + const name = typeof payload?.hook_event_name === "string" ? payload.hook_event_name : eventName; + switch (name) { + case "PreToolUse": + case "PostToolUse": + return "session.turn_started"; + case "Stop": + return "session.turn_finished"; + default: + return undefined; + } +} + +function buildExtra(eventName, payload) { + const extra = { agentNativeEvent: eventName }; + if (payload && typeof payload === "object") { + copyStringExtra(extra, payload, "hook_event_name", "hookEventName"); + copyStringExtra(extra, payload, "tool_name", "tool"); + copyStringExtra(extra, payload, "permission_mode", "permissionMode"); + const toolInput = payload.tool_input; + if (toolInput && typeof toolInput === "object" && typeof toolInput.command === "string") { + const cmd = toolInput.command; + extra.command = cmd.length > 500 ? `${cmd.slice(0, 500)}...` : cmd; + } + } + return extra; +} + +function pickSessionId(payload) { + return typeof payload?.session_id === "string" ? payload.session_id : undefined; +} + +await runForwarder({ + agentKind: "commandcode", + pluginVersion: PLUGIN_VERSION, + intentFor, + buildExtra, + pickSessionId, +}); diff --git a/src/supervisor/agents/commandcode/plugin/install.stage.test.ts b/src/supervisor/agents/commandcode/plugin/install.stage.test.ts new file mode 100644 index 00000000..49cda320 --- /dev/null +++ b/src/supervisor/agents/commandcode/plugin/install.stage.test.ts @@ -0,0 +1,65 @@ +import { existsSync, mkdtempSync, readFileSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import type { AgentEnvContext } from "../../base"; +import { installCommandCodePlugin } from "./install"; + +/** + * Exercises the real native install code path (asset staging + settings merge) + * against temp dirs. The override only redirects the merged `settings.json`; + * uninstall scrub logic is covered by the `removeCommandCodeHooks` unit test + * (the real uninstall path targets `~/.commandcode`, which a test must not + * touch). + */ +describe("installCommandCodePlugin (native staging)", () => { + let baseDir: string; + let ccDir: string; + let ctx: AgentEnvContext; + + beforeEach(() => { + baseDir = mkdtempSync(join(tmpdir(), "cc-lc-base-")); + ccDir = mkdtempSync(join(tmpdir(), "cc-global-")); + ctx = { envKind: "posix", baseDir } as AgentEnvContext; + }); + afterEach(() => { + rmSync(baseDir, { recursive: true, force: true }); + rmSync(ccDir, { recursive: true, force: true }); + }); + + it("stages forward.mjs + runtime + wrapper and merges the three hooks", () => { + const result = installCommandCodePlugin(ctx, { globalCommandCodeDirOverride: ccDir }); + expect(result.ok).toBe(true); + if (!result.ok) return; + + const dir = result.paths.pluginDir; + expect(existsSync(join(dir, "plugin.json"))).toBe(true); + expect(existsSync(join(dir, "forward.mjs"))).toBe(true); + expect(existsSync(join(dir, "lightcode-hook-runtime.mjs"))).toBe(true); + // POSIX wrapper (this test only runs the native posix branch). + expect(existsSync(join(dir, "lightcode-hook.sh"))).toBe(true); + + const doc = JSON.parse(readFileSync(join(ccDir, "settings.json"), "utf8")) as { + hooks: Record }>>; + }; + for (const ev of ["PreToolUse", "PostToolUse", "Stop"]) { + const entry = doc.hooks[ev]?.[0]?.hooks?.[0]; + expect(entry?.type).toBe("command"); + expect(entry?.command).toContain("agent-plugins/commandcode/lightcode-hook.sh"); + expect(entry?.command.endsWith(` ${ev}`)).toBe(true); + } + }); + + it("preserves a pre-existing unrelated settings key across install", () => { + // Seed the override settings.json with a user key, then install. + const result = installCommandCodePlugin(ctx, { globalCommandCodeDirOverride: ccDir }); + expect(result.ok).toBe(true); + // Re-install on top of the produced doc must remain idempotent (one entry). + const second = installCommandCodePlugin(ctx, { globalCommandCodeDirOverride: ccDir }); + expect(second.ok).toBe(true); + const doc = JSON.parse(readFileSync(join(ccDir, "settings.json"), "utf8")) as { + hooks: Record; + }; + expect(doc.hooks.Stop).toHaveLength(1); + }); +}); diff --git a/src/supervisor/agents/commandcode/plugin/install.test.ts b/src/supervisor/agents/commandcode/plugin/install.test.ts new file mode 100644 index 00000000..fdb569a3 --- /dev/null +++ b/src/supervisor/agents/commandcode/plugin/install.test.ts @@ -0,0 +1,77 @@ +import { describe, expect, it } from "vitest"; +import { mergeCommandCodeSettings, removeCommandCodeHooks } from "./install"; + +// A command head that matches LIGHTCODE_FORWARD_RE (staged wrapper path). +const HEAD = "'/home/u/.lightcode/agent-plugins/commandcode/lightcode-hook.sh'"; +const EVENTS = ["PreToolUse", "PostToolUse", "Stop"] as const; + +function commandsFor(doc: Record, event: string): string[] { + const hooks = doc.hooks as Record | undefined; + const entries = hooks?.[event]; + if (!Array.isArray(entries)) return []; + const out: string[] = []; + for (const entry of entries) { + const inner = (entry as { hooks?: unknown }).hooks; + if (!Array.isArray(inner)) continue; + for (const h of inner) { + const cmd = (h as { command?: unknown }).command; + if (typeof cmd === "string") out.push(cmd); + } + } + return out; +} + +describe("mergeCommandCodeSettings", () => { + it("adds a Lightcode hook for all three events", () => { + const doc = mergeCommandCodeSettings({}, HEAD); + for (const ev of EVENTS) { + expect(commandsFor(doc, ev)).toEqual([`${HEAD} ${ev}`]); + } + }); + + it("preserves unrelated top-level settings keys", () => { + const doc = mergeCommandCodeSettings({ model: "kimi", tasteOnboarding: true }, HEAD); + expect(doc.model).toBe("kimi"); + expect(doc.tasteOnboarding).toBe(true); + }); + + it("preserves the user's own non-Lightcode hooks", () => { + const existing = { + hooks: { Stop: [{ hooks: [{ type: "command", command: "my-own-hook.sh" }] }] }, + }; + const cmds = commandsFor(mergeCommandCodeSettings(existing, HEAD), "Stop"); + expect(cmds).toContain("my-own-hook.sh"); + expect(cmds).toContain(`${HEAD} Stop`); + expect(cmds).toHaveLength(2); + }); + + it("is idempotent — reinstall replaces, never duplicates, the Lightcode entry", () => { + const twice = mergeCommandCodeSettings(mergeCommandCodeSettings({}, HEAD), HEAD); + for (const ev of EVENTS) { + expect(commandsFor(twice, ev)).toEqual([`${HEAD} ${ev}`]); + } + }); +}); + +describe("removeCommandCodeHooks", () => { + it("removes only Lightcode entries, preserving user hooks and other keys", () => { + const installed = mergeCommandCodeSettings( + { + model: "kimi", + hooks: { Stop: [{ hooks: [{ type: "command", command: "my-own-hook.sh" }] }] }, + }, + HEAD, + ); + const removed = removeCommandCodeHooks(installed); + expect(removed.model).toBe("kimi"); + expect(commandsFor(removed, "Stop")).toEqual(["my-own-hook.sh"]); + const hooks = removed.hooks as Record; + expect(hooks.PreToolUse).toBeUndefined(); + expect(hooks.PostToolUse).toBeUndefined(); + }); + + it("drops the hooks key entirely when nothing else remains", () => { + const removed = removeCommandCodeHooks(mergeCommandCodeSettings({}, HEAD)); + expect(removed.hooks).toBeUndefined(); + }); +}); diff --git a/src/supervisor/agents/commandcode/plugin/install.ts b/src/supervisor/agents/commandcode/plugin/install.ts new file mode 100644 index 00000000..8bdd3848 --- /dev/null +++ b/src/supervisor/agents/commandcode/plugin/install.ts @@ -0,0 +1,398 @@ +import { existsSync, mkdirSync, readFileSync } from "node:fs"; +import { homedir } from "node:os"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; +import { toWslUncPath } from "@/shared/wsl"; +import type { AgentEnvContext } from "../../base"; +import { getCachedWslHomeDirectory } from "../../base"; +import { + FORWARD_RUNTIME_FILE, + buildNativeHookCmdShellCommand, + buildWslHookCommandHead, + copyForwardRuntimeFile, + copyPluginAssetsIfStale, + createPluginSourceResolver, + ctxCacheKey, + getNativePluginBaseDir, + getWslPluginBaseDirs, + isWslPluginContext, + memoByCtx, + parseExistingHooksJson, + readBundledPluginVersion, + readPluginManifest, + removeStagedPluginDir, + stagePluginAssetsToWsl, + verifyStagedPluginAt, + writeHooksJsonFile, + writeNativeHookWrapper, + type PluginManifest, +} from "../../plugin/installerBase"; + +/** + * Command Code plugin installer. + * + * Command Code has a Claude-Code-compatible hook system but, unlike Claude, no + * `--settings ` flag — hooks are only read from `~/.commandcode/ + * settings.json` (user) or `/.commandcode/settings.json`. We therefore + * MERGE our managed hook entries into the user's global `settings.json`, + * preserving every other key the user has authored. User-source hooks are + * auto-trusted by the CLI (no `trusted-hooks.json` fingerprint prompt), so a + * merged install runs headlessly. + * + * Lightcode-managed entries are tagged by the staged command path + * (`LIGHTCODE_FORWARD_RE`) and pruned/replaced on every reinstall and removed + * on uninstall, so the user's own hooks are never clobbered. + */ + +export interface CommandCodePluginPaths { + /** Plugin staging dir holding `forward.mjs`, the runtime sibling, and the wrapper. */ + pluginDir: string; + /** The user `~/.commandcode/settings.json` we merge managed hooks into. */ + globalSettingsPath: string; +} + +interface CommandCodeHookSpec { + event: string; +} + +/** + * The three events Command Code validates. `Stop` is the authoritative + * turn-finished (idle) edge; the tool events corroborate `working`. + */ +const COMMANDCODE_HOOK_SPECS: ReadonlyArray = [ + { event: "PreToolUse" }, + { event: "PostToolUse" }, + { event: "Stop" }, +]; + +/** + * Match any Lightcode-staged Command Code hook command. Covers both the WSL + * shape (`forward.mjs` invoked via absolute node path) and native + * (`lightcode-hook.{sh,cmd,ps1}` wrapper). + */ +const LIGHTCODE_FORWARD_RE = + /agent-plugins(?:[/\\]+)commandcode(?:[/\\]+)(?:forward\.mjs|lightcode-hook\.(?:sh|cmd|ps1))/; + +const callerDir = + typeof __dirname !== "undefined" + ? __dirname + : dirname(fileURLToPath(import.meta.url ?? "file://")); + +const resolveSourceDir = createPluginSourceResolver({ + kind: "commandcode", + sourceEnvVar: "LIGHTCODE_COMMANDCODE_PLUGIN_SOURCE", + callerDir, +}); + +export function readBundledCommandCodePluginVersion(): string { + return readBundledPluginVersion(resolveSourceDir); +} + +function nativeGlobalCommandCodeDir(): string { + return join(homedir(), ".commandcode"); +} + +function wslGlobalCommandCodeSettingsPath(distro: string): string { + const home = getCachedWslHomeDirectory(distro); + return home ? `${home}/.commandcode/settings.json` : ""; +} + +function computeCommandCodePluginPaths(ctx?: AgentEnvContext): CommandCodePluginPaths { + if (isWslPluginContext(ctx)) { + const wsl = getWslPluginBaseDirs(ctx.wslDistro, "commandcode"); + if (!wsl) return { pluginDir: "", globalSettingsPath: "" }; + return { + pluginDir: wsl.linuxBase, + globalSettingsPath: wslGlobalCommandCodeSettingsPath(ctx.wslDistro), + }; + } + return { + pluginDir: getNativePluginBaseDir("commandcode", ctx?.baseDir), + globalSettingsPath: join(nativeGlobalCommandCodeDir(), "settings.json"), + }; +} + +const commandCodePluginPathsMemo = memoByCtx(computeCommandCodePluginPaths, ctxCacheKey); + +export function getCommandCodePluginPaths(ctx?: AgentEnvContext): CommandCodePluginPaths { + return commandCodePluginPathsMemo.call(ctx); +} + +function asObject(value: unknown): Record { + return value && typeof value === "object" && !Array.isArray(value) + ? { ...(value as Record) } + : {}; +} + +/** True when a hook entry's nested `hooks[].command` points at our staged forwarder. */ +function entryIsLightcodeManaged(entry: unknown): boolean { + if (!entry || typeof entry !== "object") return false; + const hooks = (entry as { hooks?: unknown }).hooks; + if (!Array.isArray(hooks)) return false; + return hooks.some( + (h) => + h && + typeof h === "object" && + typeof (h as { command?: unknown }).command === "string" && + LIGHTCODE_FORWARD_RE.test((h as { command: string }).command), + ); +} + +function pruneLightcodeEntries(entries: unknown): unknown[] { + if (!Array.isArray(entries)) return []; + return entries.filter((entry) => !entryIsLightcodeManaged(entry)); +} + +/** Command Code's nested hook entry: `{ hooks: [{ type: "command", command }] }`. */ +function buildLightcodeEntry( + spec: CommandCodeHookSpec, + commandHead: string, +): Record { + return { hooks: [{ type: "command", command: `${commandHead} ${spec.event}` }] }; +} + +/** + * Merge Lightcode hook entries into a parsed `settings.json` document, + * preserving every other key (and any non-Lightcode hooks). `commandHead` is + * the pre-event portion of the hook command. Exported for unit tests. + */ +export function mergeCommandCodeSettings( + existingParsed: unknown, + commandHead: string, +): Record { + const settings = asObject(existingParsed); + const hooksRoot = asObject(settings.hooks); + for (const spec of COMMANDCODE_HOOK_SPECS) { + const pruned = pruneLightcodeEntries(hooksRoot[spec.event]); + pruned.push(buildLightcodeEntry(spec, commandHead)); + hooksRoot[spec.event] = pruned; + } + settings.hooks = hooksRoot; + return settings; +} + +/** + * Remove only Lightcode-managed hook entries from a parsed `settings.json`, + * leaving the user's other settings and hooks intact. Exported for unit tests. + */ +export function removeCommandCodeHooks(existingParsed: unknown): Record { + const settings = asObject(existingParsed); + const hooksRoot = asObject(settings.hooks); + for (const spec of COMMANDCODE_HOOK_SPECS) { + const pruned = pruneLightcodeEntries(hooksRoot[spec.event]); + if (pruned.length > 0) hooksRoot[spec.event] = pruned; + else delete hooksRoot[spec.event]; + } + if (Object.keys(hooksRoot).length === 0) delete settings.hooks; + else settings.hooks = hooksRoot; + return settings; +} + +export interface InstallCommandCodePluginOptions { + /** + * Absolute path to the Node binary the staged hook command should use. + * + * - **WSL contexts:** required. Comes from `resolveNodeForDistro`. + * - **Native contexts:** optional. When provided (preferred), the wrapper + * exec's the bare Node binary directly; otherwise it falls back to + * `ELECTRON_RUN_AS_NODE=1` against the bundled Electron binary. + */ + resolvedNodePath?: string | undefined; + /** + * Override `~/.commandcode` (or the WSL distro equivalent) when writing the + * merged `settings.json`. Tests pass a temp dir to avoid touching the user's + * real config; production calls leave this undefined. + */ + globalCommandCodeDirOverride?: string; +} + +export function installCommandCodePlugin( + ctx?: AgentEnvContext, + options?: InstallCommandCodePluginOptions, +): { ok: true; paths: CommandCodePluginPaths; version: string } | { ok: false; reason: string } { + let sourceDir: string; + try { + sourceDir = resolveSourceDir(); + } catch (error) { + return { ok: false, reason: error instanceof Error ? error.message : String(error) }; + } + + let manifest: PluginManifest; + try { + manifest = readPluginManifest(sourceDir); + } catch (error) { + return { ok: false, reason: error instanceof Error ? error.message : String(error) }; + } + + if (isWslPluginContext(ctx)) { + if (!options?.resolvedNodePath) { + return { + ok: false, + reason: + "WSL Command Code plugin install requires a resolved node path; the adapter must call resolveNodeForDistro before installing.", + }; + } + return installCommandCodePluginWsl( + ctx.wslDistro, + sourceDir, + manifest, + options.resolvedNodePath, + options.globalCommandCodeDirOverride, + ); + } + + const pluginDir = getNativePluginBaseDir("commandcode", ctx?.baseDir); + mkdirSync(pluginDir, { recursive: true }); + copyPluginAssetsIfStale(sourceDir, pluginDir); + copyForwardRuntimeFile(pluginDir); + const wrapperPath = writeNativeHookWrapper(pluginDir, { + ...(options?.resolvedNodePath ? { nodePath: options.resolvedNodePath } : {}), + }); + + const globalDir = options?.globalCommandCodeDirOverride ?? nativeGlobalCommandCodeDir(); + const settingsPath = join(globalDir, "settings.json"); + const existing = parseExistingHooksJson(settingsPath); + if (existing === null && existsSync(settingsPath)) { + return { + ok: false, + reason: `malformed Command Code settings.json at ${settingsPath} (invalid JSON)`, + }; + } + + const commandHead = buildNativeHookCmdShellCommand(wrapperPath); + + try { + writeHooksJsonFile(settingsPath, mergeCommandCodeSettings(existing, commandHead)); + } catch (error) { + return { + ok: false, + reason: `failed to write Command Code settings.json at ${settingsPath}: ${ + error instanceof Error ? error.message : String(error) + }`, + }; + } + + console.log( + `[supervisor] Command Code hook plugin staged v${manifest.version} at ${pluginDir}; merged hooks into ${settingsPath}`, + ); + + return { + ok: true, + version: manifest.version, + paths: { pluginDir, globalSettingsPath: settingsPath }, + }; +} + +function installCommandCodePluginWsl( + distro: string, + sourceDir: string, + manifest: PluginManifest, + resolvedNodePath: string, + globalCommandCodeDirOverride: string | undefined, +): { ok: true; paths: CommandCodePluginPaths; version: string } | { ok: false; reason: string } { + const staged = stagePluginAssetsToWsl(distro, sourceDir, "commandcode", { + includeForwardRuntime: true, + }); + if (!staged.ok) return staged; + + const linuxForward = `${staged.linuxPluginDir}/forward.mjs`; + const linuxSettingsPath = globalCommandCodeDirOverride + ? `${globalCommandCodeDirOverride}/settings.json` + : `${staged.deploy.home}/.commandcode/settings.json`; + const uncSettings = toWslUncPath(distro, linuxSettingsPath); + + const existing = parseExistingHooksJson(uncSettings); + if (existing === null && existsSync(uncSettings)) { + return { + ok: false, + reason: `malformed Command Code settings.json at ${linuxSettingsPath} in wsl distro ${distro}`, + }; + } + + const commandHead = buildWslHookCommandHead(resolvedNodePath, linuxForward); + + try { + writeHooksJsonFile(uncSettings, mergeCommandCodeSettings(existing, commandHead)); + } catch (error) { + return { + ok: false, + reason: `failed to write settings.json at ${linuxSettingsPath} in wsl distro ${distro}: ${ + error instanceof Error ? error.message : String(error) + }`, + }; + } + + console.log( + `[supervisor] Command Code hook plugin staged v${manifest.version} in WSL distro ${distro} at ${staged.linuxPluginDir}; merged hooks into ${linuxSettingsPath}`, + ); + + return { + ok: true, + version: manifest.version, + paths: { pluginDir: staged.linuxPluginDir, globalSettingsPath: linuxSettingsPath }, + }; +} + +export function isCommandCodePluginInstalled( + ctx?: AgentEnvContext, +): Promise<{ installed: boolean; version?: string }> { + if (isWslPluginContext(ctx)) { + const wsl = getWslPluginBaseDirs(ctx.wslDistro, "commandcode"); + if (!wsl) return Promise.resolve({ installed: false }); + const settingsPath = toWslUncPath( + ctx.wslDistro, + wslGlobalCommandCodeSettingsPath(ctx.wslDistro), + ); + return Promise.resolve(verifyCommandCodeInstallAt(wsl.uncBase, "wsl", settingsPath)); + } + const settingsPath = join(nativeGlobalCommandCodeDir(), "settings.json"); + return Promise.resolve( + verifyCommandCodeInstallAt( + getNativePluginBaseDir("commandcode", ctx?.baseDir), + "native", + settingsPath, + ), + ); +} + +export function uninstallCommandCodePlugin(ctx?: AgentEnvContext): void { + const settingsPath = isWslPluginContext(ctx) + ? toWslUncPath(ctx.wslDistro, wslGlobalCommandCodeSettingsPath(ctx.wslDistro)) + : join(nativeGlobalCommandCodeDir(), "settings.json"); + const existing = parseExistingHooksJson(settingsPath); + if (existing !== null || existsSync(settingsPath)) { + writeHooksJsonFile(settingsPath, removeCommandCodeHooks(existing)); + } + removeStagedPluginDir("commandcode", ctx); +} + +function settingsJsonHasLightcodeEntry(settingsPath: string): boolean { + if (!existsSync(settingsPath)) return false; + try { + const doc = JSON.parse(readFileSync(settingsPath, "utf8")) as { + hooks?: Record; + }; + if (!doc.hooks || typeof doc.hooks !== "object") return false; + for (const spec of COMMANDCODE_HOOK_SPECS) { + const entries = doc.hooks[spec.event]; + if (!Array.isArray(entries)) continue; + if (entries.some(entryIsLightcodeManaged)) return true; + } + return false; + } catch { + return false; + } +} + +const COMMANDCODE_VERIFY_ASSETS = ["plugin.json", "forward.mjs", FORWARD_RUNTIME_FILE] as const; + +function verifyCommandCodeInstallAt( + readableDir: string, + target: "native" | "wsl", + settingsPath: string, +): { installed: boolean; version?: string } { + return verifyStagedPluginAt(readableDir, target, { + assets: COMMANDCODE_VERIFY_ASSETS, + extraCheck: () => settingsJsonHasLightcodeEntry(settingsPath), + }); +} diff --git a/src/supervisor/agents/commandcode/plugin/intentMap.test.ts b/src/supervisor/agents/commandcode/plugin/intentMap.test.ts new file mode 100644 index 00000000..78562dfa --- /dev/null +++ b/src/supervisor/agents/commandcode/plugin/intentMap.test.ts @@ -0,0 +1,31 @@ +import { describe, expect, it } from "vitest"; +import { commandCodeIntentFor } from "./intentMap"; + +describe("commandCodeIntentFor", () => { + it("maps tool-use events to session.turn_started (working)", () => { + expect(commandCodeIntentFor("PreToolUse")).toBe("session.turn_started"); + expect(commandCodeIntentFor("PostToolUse")).toBe("session.turn_started"); + }); + + it("maps Stop to session.turn_finished (idle)", () => { + expect(commandCodeIntentFor("Stop")).toBe("session.turn_finished"); + }); + + it("prefers payload.hook_event_name over the argv eventName", () => { + expect(commandCodeIntentFor("PreToolUse", { hook_event_name: "Stop" })).toBe( + "session.turn_finished", + ); + expect(commandCodeIntentFor("anything", { hook_event_name: "PostToolUse" })).toBe( + "session.turn_started", + ); + }); + + it("returns undefined for unmapped events", () => { + // Command Code validates only PreToolUse / PostToolUse / Stop. Anything + // else (incl. would-be UserPromptSubmit / Notification) is unmapped. + expect(commandCodeIntentFor("UserPromptSubmit")).toBeUndefined(); + expect(commandCodeIntentFor("Notification")).toBeUndefined(); + expect(commandCodeIntentFor("SessionStart")).toBeUndefined(); + expect(commandCodeIntentFor("")).toBeUndefined(); + }); +}); diff --git a/src/supervisor/agents/commandcode/plugin/intentMap.ts b/src/supervisor/agents/commandcode/plugin/intentMap.ts new file mode 100644 index 00000000..505ffbc5 --- /dev/null +++ b/src/supervisor/agents/commandcode/plugin/intentMap.ts @@ -0,0 +1,39 @@ +import type { AgentEventIntent } from "@/shared/contracts"; + +/** + * Map a Command Code hook event name to a Lightcode universal intent. + * + * Command Code exposes a Claude-Code-compatible hook system, but only THREE + * events are validated by the CLI: `PreToolUse`, `PostToolUse`, `Stop`. There + * is no turn-start (`UserPromptSubmit`) or `Notification` event, so: + * + * - `Stop` is the authoritative turn-finished edge → `idle`. Unlike Copilot + * (which has no finish event and must run `partialL1`), this lets Command + * Code be a FULL L1 agent: L1 owns the working→idle transition. + * - `PreToolUse` / `PostToolUse` corroborate `working` while a tool runs. + * - Working-start for a pure-text turn has no hook; the runtime sets it + * optimistically on submit (initial turn) and the L2 terminal-text fallback + * (`detectCommandCodeTerminalStatus`, allowed via + * `shouldApplyTerminalStatusWhileHookActive`) covers follow-up text turns. + * - `needs_approval` / `needs_reply` are not hook events; they stay on L2 + * terminal-text detection. + * + * NOTE: `forward.mjs` ships as a standalone ESM file and cannot import this + * `.ts` module, so it carries its own copy of this switch. Keep the two in + * sync (guarded by `intentMap.test.ts`). + */ +export function commandCodeIntentFor( + eventName: string, + payload?: { hook_event_name?: unknown } | undefined, +): AgentEventIntent | undefined { + const name = typeof payload?.hook_event_name === "string" ? payload.hook_event_name : eventName; + switch (name) { + case "PreToolUse": + case "PostToolUse": + return "session.turn_started"; + case "Stop": + return "session.turn_finished"; + default: + return undefined; + } +} diff --git a/src/supervisor/agents/commandcode/plugin/plugin.json b/src/supervisor/agents/commandcode/plugin/plugin.json new file mode 100644 index 00000000..f089b019 --- /dev/null +++ b/src/supervisor/agents/commandcode/plugin/plugin.json @@ -0,0 +1,7 @@ +{ + "name": "lightcode-status", + "version": "1.0.1", + "description": "Forward Command Code lifecycle events (PreToolUse/PostToolUse/Stop) to Lightcode for sidebar status detection.", + "author": "Lightcode", + "license": "Apache-2.0" +} diff --git a/src/supervisor/agents/commandcode/terminal.ts b/src/supervisor/agents/commandcode/terminal.ts index 9efba9f7..41e5ff0f 100644 --- a/src/supervisor/agents/commandcode/terminal.ts +++ b/src/supervisor/agents/commandcode/terminal.ts @@ -5,9 +5,21 @@ interface CommandCodeHintEntry extends HintEntry { attention: TerminalStatusHint["attention"]; } -// Best-effort heuristics for the `command-code` TUI. These mirror the shape of -// other terminal providers and should be tuned against a live install — the -// CLI was not available to capture exact output when this was written. +// Heuristics tuned against captured `command-code` v0.37 TUI output. +// +// Working: the spinner row reads `· esc to interrupt • s • ↑ `, +// where is a RANDOM verb ("Cogitating", "Processing", "Conjuring", …), +// so we anchor on the invariant `esc to interrupt` and never the label or the +// `·` spinner glyph (too generic on its own). +// Idle: the composer placeholder `❯ Ask your question...` plus the +// `? for shortcuts` / `/ for commands` hint row. +// +// The authoritative working→idle edge is owned by the L1 `Stop` hook (see +// `plugin/`). This L2 detection is the full fallback when the hook plugin is +// NOT installed, and — gated by `shouldApplyTerminalStatusWhileHookActive` in +// `index.ts` — supplies the `working` edge for follow-up text turns plus the +// `needs_approval` / `needs_reply` interactive states, which have no hook event. +// (Command Code emits no OSC, so there is no OSC-based signal to fall back to.) const COMMANDCODE_STRONG: CommandCodeHintEntry[] = [ { re: /Enter to select|Choose an option/i, status: "needs_reply", attention: "needs_reply" }, { @@ -15,13 +27,11 @@ const COMMANDCODE_STRONG: CommandCodeHintEntry[] = [ status: "needs_approval", attention: "needs_approval", }, - { re: /^[^\S\r\n]*[⣷⣯⣟⡿⢿⣻⣽⣾](?:\s|$)/m, status: "working", attention: "working" }, - { re: /\besc to (?:cancel|interrupt)\b/i, status: "working", attention: "working" }, - { re: /✦\s+Working|⚙\s+Working|Thinking…|Generating…/i, status: "working", attention: "working" }, + { re: /\besc to interrupt\b/i, status: "working", attention: "working" }, ]; const COMMANDCODE_FALLBACK_IDLE: CommandCodeHintEntry[] = [ - { re: /^\s*>\s*$/m, status: "idle", attention: "none" }, + { re: /Ask your question/i, status: "idle", attention: "none" }, { re: /\?\s+for shortcuts|\/\s+for commands/i, status: "idle", attention: "none" }, ]; diff --git a/src/supervisor/agents/plugin/forward-runtime/lightcode-hook-runtime.mjs b/src/supervisor/agents/plugin/forward-runtime/lightcode-hook-runtime.mjs index a247ec5c..fdbc26bc 100644 --- a/src/supervisor/agents/plugin/forward-runtime/lightcode-hook-runtime.mjs +++ b/src/supervisor/agents/plugin/forward-runtime/lightcode-hook-runtime.mjs @@ -188,7 +188,11 @@ export async function runForwarder(options) { const debug = hookDebugEnabled(); const url = process.env.LIGHTCODE_HOOK_URL; - const secret = process.env.LIGHTCODE_HOOK_SECRET; + // Some agent CLIs (e.g. command-code) strip env vars whose NAME matches a + // secret denylist (/SECRET/, /TOKEN/, /AUTH/, …) before invoking the hook, + // which drops LIGHTCODE_HOOK_SECRET. The supervisor also injects the same + // value under the denylist-safe name LIGHTCODE_HOOK_NONCE; fall back to it. + const secret = process.env.LIGHTCODE_HOOK_SECRET ?? process.env.LIGHTCODE_HOOK_NONCE; const threadId = process.env.LIGHTCODE_THREAD_ID; const agentKind = process.env.LIGHTCODE_AGENT_KIND ?? defaultAgentKind; const supervisorProtocol = Number( diff --git a/src/supervisor/runtime/cliHookPluginCoordinator.ts b/src/supervisor/runtime/cliHookPluginCoordinator.ts index e512c8b6..2aa23b71 100644 --- a/src/supervisor/runtime/cliHookPluginCoordinator.ts +++ b/src/supervisor/runtime/cliHookPluginCoordinator.ts @@ -227,6 +227,13 @@ export class CliHookPluginCoordinator { const env: Record = { LIGHTCODE_HOOK_URL: transport.url, LIGHTCODE_HOOK_SECRET: transport.secret, + // Some agent CLIs sanitize the hook subprocess env, dropping any var whose + // NAME matches a secret denylist (command-code strips /SECRET|TOKEN|AUTH| + // KEY|.../). That removes LIGHTCODE_HOOK_SECRET and leaves the forwarder + // unable to authenticate its POST (it requires url && secret), so status + // intents never arrive. Carry the same value under a neutral name the + // denylist doesn't match; the shared forwarder falls back to it. + LIGHTCODE_HOOK_NONCE: transport.secret, LIGHTCODE_HOOK_PROTOCOL_VERSION: String(transport.protocolVersion), LIGHTCODE_THREAD_ID: input.threadId, LIGHTCODE_AGENT_KIND: input.agentKind, diff --git a/src/supervisor/runtime/threadSessionManager.ts b/src/supervisor/runtime/threadSessionManager.ts index 4a45c636..693a0ea8 100644 --- a/src/supervisor/runtime/threadSessionManager.ts +++ b/src/supervisor/runtime/threadSessionManager.ts @@ -405,6 +405,14 @@ export class ThreadSessionManager { session.projectLocation, ); + // Optimistic working edge for CLI-hook agents with no turn-START event + // (Command Code): show `working` the instant the prompt is sent. Gated on + // `cliHookEnvInjected` so the authoritative `Stop` hook is guaranteed wired + // to return the thread to idle — never strands it in `working`. + if (session.adapter.optimisticWorkingOnSubmit && session.cliHookEnvInjected) { + this.outputPipeline.updateState(session, "working", "working"); + } + await sleep(300); if (session.prevChunk.includes("[Pasted text")) { pty.write("\r");