Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions scripts/prepare-agent-plugins.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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"],
Expand Down
10 changes: 10 additions & 0 deletions src/supervisor/agents/base/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
19 changes: 15 additions & 4 deletions src/supervisor/agents/commandcode/commandcode.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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",
});
Expand Down
58 changes: 57 additions & 1 deletion src/supervisor/agents/commandcode/index.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -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.
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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,
Expand Down
66 changes: 66 additions & 0 deletions src/supervisor/agents/commandcode/plugin/forward.mjs
Original file line number Diff line number Diff line change
@@ -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 `<wrapper> <Event>`) 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,
});
65 changes: 65 additions & 0 deletions src/supervisor/agents/commandcode/plugin/install.stage.test.ts
Original file line number Diff line number Diff line change
@@ -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<string, Array<{ hooks: Array<{ type: string; command: string }> }>>;
};
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<string, unknown[]>;
};
expect(doc.hooks.Stop).toHaveLength(1);
});
});
77 changes: 77 additions & 0 deletions src/supervisor/agents/commandcode/plugin/install.test.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown>, event: string): string[] {
const hooks = doc.hooks as Record<string, unknown> | 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<string, unknown>;
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();
});
});
Loading