From b7787529bc754346a4b345fc358e62c7555e9b21 Mon Sep 17 00:00:00 2001 From: Diego Lhambi Pereira Date: Mon, 19 Oct 2026 20:52:29 -0300 Subject: [PATCH 1/4] feat(cmd): add Command Code agent support (transcript, conversation, prompts, skills) Mirror pi/claude/codex support for the herdr-detected `cmd` agent (Command Code). Command Code stores sessions as version-3 JSONL under ~/.commandcode/projects// with the same entry-tree shape as pi, so reuse pi's branch projection and omp transcript parser. - server/cmd.ts: transcript resolution (herdr agent_session, then COMMANDCODE_SESSION_ID from the pane process environ), store validation, slug derivation, and the context window from the commandcode model catalog. - server/dialogs/cmd.ts: the permission prompt as a card (numbered options, the cursor's own row, the heading and the question from the dialog block), answered by arrow navigation and Enter. - server/skills/cmd.ts: skill discovery under ~/.commandcode and .commandcode, and custom commands from their commands/ directories. - Wire `cmd-transcript` through conversation.ts (resolution, branch projection, tool output, images, abandoned turns, identity, cache) and conversation-metadata.ts (camelCase usage, the commandcode provider, the model from herdr's tokens). Co-authored-by: CommandCodeBot --- changelog.d/cmd-agent-support.md | 4 + docs/guide.md | 1 + server/cmd.test.ts | 156 +++++++++++++++++++++++++ server/cmd.ts | 194 +++++++++++++++++++++++++++++++ server/conversation-metadata.ts | 26 +++-- server/conversation.ts | 38 ++++-- server/dialogs/cmd.ts | 117 ++++++++++++++++++- server/dialogs/registry.test.ts | 1 + server/dialogs/shared.ts | 3 +- server/prompt.ts | 7 +- server/skills/cmd.ts | 48 ++++++++ server/skills/index.ts | 3 +- shared/protocol.ts | 2 +- site/demo/transport.ts | 2 +- 14 files changed, 575 insertions(+), 27 deletions(-) create mode 100644 changelog.d/cmd-agent-support.md create mode 100644 server/cmd.test.ts create mode 100644 server/cmd.ts create mode 100644 server/skills/cmd.ts diff --git a/changelog.d/cmd-agent-support.md b/changelog.d/cmd-agent-support.md new file mode 100644 index 0000000000..0e7a3d7723 --- /dev/null +++ b/changelog.d/cmd-agent-support.md @@ -0,0 +1,4 @@ +### Added +- Command Code (`cmd`) agent panes now show their conversation in the web UI: transcript + resolution from `~/.commandcode/projects/`, `/tree` branch projection, model and context + window from Command Code's catalog, permission prompts, and skill discovery. \ No newline at end of file diff --git a/docs/guide.md b/docs/guide.md index e05b147b0b..511a6e5612 100644 --- a/docs/guide.md +++ b/docs/guide.md @@ -159,6 +159,7 @@ Every agent herdr runs shows up with its live status, terminal and alerts. The c | **omo** | Native session file, found through the pane's process tree | ✓ the `/model` list (picked for the session); other menus — use Terminal | | **gjc** | Native session file identified by GJC's SDK broker and the pane's process | — use Terminal | | **pi** | Native session file, resolved through herdr; after `/tree`, the branch in play | ✓ its dialogs: a question, a confirmation, an answer typed in | +| **Command Code** (`cmd`) | Native session file under `~/.commandcode/projects/`, resolved through herdr; after `/tree`, the branch in play | ✓ its permission prompts: Yes / Yes, don't ask again / No | | **OpenCode** | Native session in OpenCode 2's own database, resolved through herdr: `~/.local/share/opencode/opencode.db` (`%USERPROFILE%\.local\share\opencode\opencode.db` on Windows), or where the server's `XDG_DATA_HOME` or `OPENCODE_DB` puts it | ✓ its questions on OpenCode 2.0 or later, answered through its background service (`~/.local/state/opencode/service.json`, or under the server's `XDG_STATE_HOME`) one question at a time, with Back to change an earlier one; other prompts, and OpenCode 1 — use Terminal | | **Devin CLI** | Native SQLite session, showing the active branch when herdr or the live process explicitly identifies the session; otherwise terminal text | — use Terminal | | **Grok** | Linux native update log. Any Grok executable named `grok` or `grok-…` that holds the `events.jsonl` for the session Herdr reported in exactly one directory, with a newer write time than every other held session. Older open files do not hide it; ambiguous IDs, tied times, and an unreported `/resume` use Terminal until Herdr names the confirmed session. macOS and Windows use Terminal. See [Grok setup and limits](grok-chat.md). | — use Terminal | diff --git a/server/cmd.test.ts b/server/cmd.test.ts new file mode 100644 index 0000000000..00575d21de --- /dev/null +++ b/server/cmd.test.ts @@ -0,0 +1,156 @@ +import { afterAll, describe, expect, it } from "bun:test"; +import { mkdirSync, mkdtempSync, rmSync, symlinkSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +import { cmdContextWindow, cmdSlug, cmdTranscriptInStore, unwrittenSession } from "./cmd.ts"; +import { transcriptPage } from "./conversation.ts"; +import { parseInteractivePrompt } from "./prompt.ts"; + +const root = mkdtempSync(join(tmpdir(), "herdr-cmd-store-")); +const project = join(root, "projects", cmdSlug(root)); +mkdirSync(project, { recursive: true }); +afterAll(() => rmSync(root, { recursive: true, force: true })); + +const canSymlink = (() => { + const probe = join(root, "symlink-capability"); + try { symlinkSync(project, probe, "dir"); rmSync(probe); return true; } + catch (error) { if (process.platform === "win32" && (error as NodeJS.ErrnoException).code === "EPERM") return false; throw error; } +})(); + +const session = (name: string, body: unknown[] = []) => { + const path = join(project, `${name}.jsonl`); + writeFileSync(path, [ + { type: "session", version: 3, id: name, timestamp: new Date().toISOString(), cwd: root }, + ...body, + ].map((entry) => JSON.stringify(entry)).join("\n") + "\n"); + return path; +}; + +describe("Command Code's session store holds the transcript a pane reads", () => { + it("names the project directory after the cwd", () => { + expect(cmdSlug("/home/daigou/Projects/herdr-web-ui")).toBe("home-daigou-projects-herdr-web-ui"); + expect(cmdSlug("/home/daigou/Projects/Yaba-Definitive")).toBe("home-daigou-projects-yaba-definitive"); + }); + + it("accepts a session file inside the store", () => { + const path = session("inside"); + expect(cmdTranscriptInStore(path, project)).toBe(path); + }); + + it("refuses paths that leave the store", () => { + const outside = join(root, "elsewhere.jsonl"); + writeFileSync(outside, "{}\n"); + expect(cmdTranscriptInStore(outside, project)).toBeNull(); + expect(cmdTranscriptInStore(join(project, "..", "elsewhere.jsonl"), project)).toBeNull(); + expect(cmdTranscriptInStore(project, project)).toBeNull(); + expect(cmdTranscriptInStore(join(root, "missing.jsonl"), project)).toBeNull(); + }); + + it.skipIf(!canSymlink)("refuses a link out of the store", () => { + const outside = join(root, "target.jsonl"); + writeFileSync(outside, "{}\n"); + const link = join(project, "link.jsonl"); + symlinkSync(outside, link); + expect(cmdTranscriptInStore(link, project)).toBeNull(); + }); + + it("refuses a directory and a file that is not jsonl", () => { + mkdirSync(join(project, "dir.jsonl"), { recursive: true }); + expect(cmdTranscriptInStore(join(project, "dir.jsonl"), project)).toBeNull(); + writeFileSync(join(project, "notes.txt"), "{}\n"); + expect(cmdTranscriptInStore(join(project, "notes.txt"), project)).toBeNull(); + }); + + it("names a session Command Code has not written yet only when its path is in the store", () => { + expect(unwrittenSession(join(project, "fresh.jsonl"), project)).toEqual({ id: "fresh", path: join(project, "fresh.jsonl") }); + expect(unwrittenSession(session("written"), project)).toBeNull(); + expect(unwrittenSession(join(root, "missing.jsonl"), project)).toBeNull(); + expect(unwrittenSession(join(project, "fresh.txt"), project)).toBeNull(); + }); +}); + +// Command Code's own record shapes: a prompt, an assistant turn whose thinking and prose sit +// side by side, and the camelCase usage it records. +const conversation = [ + { type: "message", id: "u1", parentId: null, timestamp: "2026-10-19T00:00:02.000Z", message: { role: "user", content: [{ type: "text", text: "fix the crash" }] } }, + { type: "message", id: "a1", parentId: "u1", timestamp: "2026-10-19T00:00:03.000Z", message: { + role: "assistant", model: "stealth/cat-jam:free", stopReason: "stop", + usage: { inputTokens: 100, outputTokens: 20, cacheReadTokens: 300, cacheWriteTokens: 0, costUsd: 0 }, + content: [ + { type: "thinking", thinking: "the null check is missing" }, + { type: "text", text: "Guarded. The crash came from a null export." }, + ], + } }, +]; + +describe("Command Code transcripts render as chat", () => { + it("reads prompts, thinking and the answer", () => { + const page = transcriptPage("cmd-transcript", session("readable", conversation)); + expect(page.source).toBe("cmd-transcript"); + expect(page.turns.map((turn) => turn.role)).toEqual(["user", "assistant"]); + expect(page.turns[1]?.parts.some((part) => part.kind === "thinking")).toBe(true); + }); +}); + +describe("Command Code's context window comes from its catalog", () => { + it("reads the window the catalog states and nothing else", () => { + const catalog = join(root, "commandcode-models.json"); + writeFileSync(catalog, JSON.stringify({ version: 2, models: [ + { id: "stealth/cat-jam:free", contextWindow: 1048576 }, + { id: "no-window", contextWindow: 0 }, + { id: "text-window", contextWindow: "many" }, + ] })); + expect(cmdContextWindow("stealth/cat-jam:free", catalog)).toBe(1048576); + expect(cmdContextWindow("no-window", catalog)).toBeNull(); + expect(cmdContextWindow("text-window", catalog)).toBeNull(); + expect(cmdContextWindow("absent", catalog)).toBeNull(); + expect(cmdContextWindow("stealth/cat-jam:free", join(root, "missing.json"))).toBeNull(); + }); +}); + +// The permission prompt, as Command Code's TUI draws it. +const permission = [ + "Create File", + "", + "Do you want to create hello.txt?", + "", + "❯ 1. Yes", + " 2. Yes, allow all edits this session [shift+tab]", + " 3. No, tell Command Code what to do differently", + "", + "↑/↓ navigate · enter select · ctrl+e explain · Run cmd --yolo to", + "bypass all permissions (Docs ↗)", +].join("\n"); + +describe("Command Code's permission prompt is a card", () => { + it("reads the heading, the question and the three options", () => { + const prompt = parseInteractivePrompt("cmd", permission); + expect(prompt?.title).toBe("Create File"); + expect(prompt?.question).toBe("Do you want to create hello.txt?"); + expect(prompt?.options.map((option) => option.label)).toEqual([ + "Yes", + "Yes, allow all edits this session [shift+tab]", + "No, tell Command Code what to do differently", + ]); + }); + + it("takes no option from a numbered list above the dialog", () => { + const screen = ["1. an earlier list", "2. of the agent's own", "", ...permission.split("\n")].join("\n"); + expect(parseInteractivePrompt("cmd", screen)?.options.map((option) => option.label)).toEqual([ + "Yes", + "Yes, allow all edits this session [shift+tab]", + "No, tell Command Code what to do differently", + ]); + }); + + it("offers nothing when the cursor is not marked on exactly one row", () => { + const screen = permission.replace("❯ 1. Yes", " 1. Yes"); + expect(parseInteractivePrompt("cmd", screen)).toBeNull(); + }); + + it("offers nothing when the footer is gone", () => { + const screen = permission.split("\n").slice(0, -2).join("\n"); + expect(parseInteractivePrompt("cmd", screen)).toBeNull(); + }); +}); diff --git a/server/cmd.ts b/server/cmd.ts new file mode 100644 index 0000000000..f2269e89a3 --- /dev/null +++ b/server/cmd.ts @@ -0,0 +1,194 @@ +import { constants, closeSync, fstatSync, lstatSync, openSync, readFileSync, realpathSync, statSync } from "node:fs"; +import { basename, dirname, isAbsolute, join, relative, sep } from "node:path"; +import { homedir } from "node:os"; + +import { agentGet, paneProcessInfo } from "./herdr/client.ts"; +import { processEnviron } from "./omo.ts"; + +/** + * Command Code's session store. One directory per project (the cwd), under + * `~/.commandcode/projects/`, where `` is the cwd with its leading + * slash dropped and every other slash turned into a dash, lowercased. + */ +export function cmdSlug(cwd: string): string { + return cwd.replace(/^[/\\]+/, "").replace(/[/\\]/g, "-").toLowerCase(); +} + +export function defaultCmdHome(): string { + return join(homedir(), ".commandcode"); +} + +export function defaultCmdProjectDir(cwd: string): string { + return join(defaultCmdHome(), "projects", cmdSlug(cwd)); +} + +/** Where the commandcode provider's cached catalog lives: the file pi reads the same models from. */ +export function cmdModelCatalogPath(): string { + return join(homedir(), ".pi", "agent", "commandcode-models.json"); +} + +/** + * The canonical file inside the store, or null. Command Code names an absolute + * path itself, so the store is checked rather than trusted: a path elsewhere, a + * link out of it or a non-session file is no evidence. + */ +export function cmdTranscriptInStore(path: string, sessionDir: string): string | null { + let canonical: string; + let root: string; + try { + canonical = realpathSync(path); + root = realpathSync(sessionDir); + } catch { return null; } + const inside = relative(root, canonical); + if (!inside || inside === ".." || inside.startsWith(`..${sep}`) || isAbsolute(inside)) return null; + if (!canonical.endsWith(".jsonl")) return null; + let fd: number | undefined; + try { + fd = openSync(canonical, constants.O_RDONLY | constants.O_NONBLOCK); + return fstatSync(fd).isFile() ? canonical : null; + } catch { return null; } + finally { if (fd !== undefined) closeSync(fd); } +} + +const absent = (error: unknown): boolean => (error as { code?: unknown } | null)?.code === "ENOENT"; + +export function unwrittenSession(path: string, sessionDir: string): { id: string; path: string } | null { + if (!isAbsolute(path) || !path.endsWith(".jsonl")) return null; + let root: string; + try { root = realpathSync(sessionDir); } catch { return null; } + try { + lstatSync(path); + return null; + } catch (error) { if (!absent(error)) return null; } + for (let dir = dirname(path); ; dir = dirname(dir)) { + try { + const real = realpathSync(dir); + if (!statSync(real).isDirectory()) return null; + const inside = relative(root, real); + if (inside === ".." || inside.startsWith(`..${sep}`) || isAbsolute(inside)) return null; + return { id: basename(path, ".jsonl"), path: join(real, relative(dir, path)) }; + } catch (error) { + if (!absent(error) || dirname(dir) === dir) return null; + try { lstatSync(dir); return null; } catch (missing) { if (!absent(missing)) return null; } + } + } +} + +const CMD_EXECUTABLE = /(^|[\\/])command[-_]?code(?:\.exe|\.[cm]?js)?$/i; +const CMD_NPM_ENTRY = /(^|[\\/])node_modules[\\/]command-code[\\/]/i; + +export function isCmdProcess(argv: readonly string[]): boolean { + if (argv.length === 0) return false; + const exec = argv[0] ?? ""; + const base = exec.split(/[\\/]/).pop()!.toLowerCase().replace(/\.(?:exe|cmd|js|mjs|cjs)$/i, ""); + if (base === "commandcode" || base === "cmdc" || base === "command-code" || base === "cmd") { + // only treat `cmd` as Command Code when it actually runs command-code + if (base === "cmd") { + return argv.some((word) => /command[-_]?code/i.test(word)); + } + return true; + } + if (/(?:^|[\\/])(?:bun|node)(?:\.exe)?$/i.test(exec) && argv.slice(1).some((word) => CMD_NPM_ENTRY.test(word) || /command[-_]?code/i.test(word))) { + return true; + } + return CMD_EXECUTABLE.test(exec); +} + +type CmdProcess = { pid?: number; argv?: readonly string[] | null; cwd?: string | null }; +type CmdTranscriptOptions = { + agent?: Awaited> | null; + processes?: readonly CmdProcess[]; + environOf?: (pid: number) => readonly string[] | null; + argvOf?: (pid: number) => readonly string[] | null; + home?: string; +}; + +function processArgv(pid: number): readonly string[] | null { + if (process.platform !== "linux") return null; + try { return readFileSync(`/proc/${pid}/cmdline`, "utf8").split("\0").filter(Boolean); } + catch { return null; } +} + +export async function cmdTranscriptPath( + paneId: string, + options: CmdTranscriptOptions = {}, +): Promise<{ path: string } | { unwritten: { id: string; path: string } } | null> { + const agent = options.agent === undefined ? await agentGet(paneId).catch(() => null) : options.agent; + if (agent?.agent !== "cmd") return null; + + const cwd = (typeof agent.cwd === "string" && agent.cwd.length > 0 ? agent.cwd : null) + ?? (typeof agent.foreground_cwd === "string" ? agent.foreground_cwd : null); + if (!cwd) return null; + const projectDir = defaultCmdProjectDir(cwd); + + // session value reported by herdr (if any) + const session = agent.agent_session; + const reportedPath = (session?.agent === "cmd" && session.source === "herdr:cmd" && session.kind === "path" + && typeof session.value === "string") ? session.value : null; + + const info = options.processes === undefined + ? await paneProcessInfo(paneId).catch(() => undefined) + : { foreground_processes: [...options.processes] }; + const environOf = options.environOf ?? processEnviron; + const argvOf = options.argvOf ?? processArgv; + + const cmdProcesses: { pid: number; argv: readonly string[] }[] = []; + for (const entry of info?.foreground_processes ?? []) { + if (typeof entry.pid !== "number") continue; + const argv = entry.argv?.length ? entry.argv : argvOf(entry.pid); + if (argv?.length && isCmdProcess(argv)) cmdProcesses.push({ pid: entry.pid, argv }); + } + + // session id from environ + let sessionId: string | null = null; + for (const process of cmdProcesses) { + const env = environOf(process.pid); + if (!env) continue; + const entry = env.find((word) => word.startsWith("COMMANDCODE_SESSION_ID=")); + if (entry) { sessionId = entry.slice("COMMANDCODE_SESSION_ID=".length); break; } + } + + // candidate path + let candidate: string | null = reportedPath; + if (candidate === null && sessionId) candidate = join(projectDir, `${sessionId}.jsonl`); + + if (candidate === null) return null; + const path = cmdTranscriptInStore(candidate, projectDir); + if (path !== null) return { path }; + const unwritten = unwrittenSession(candidate, projectDir); + return unwritten === null ? null : { unwritten }; +} + +/** + * Command Code's context window for a model: read from the commandcode provider's + * cached catalog. On `main` pi-models only consults models.json, which never + * names commandcode's models, so cmd resolves its own window here. + */ +let cmdCatalogCache: { path: string; mtimeMs: number; windows: Map } | null = null; + +export function cmdContextWindow(model: string, catalogPath = cmdModelCatalogPath()): number | null { + let mtimeMs: number; + try { mtimeMs = statSync(catalogPath).mtimeMs; } catch { return null; } + if (cmdCatalogCache === null || cmdCatalogCache.path !== catalogPath || cmdCatalogCache.mtimeMs !== mtimeMs) { + let text: string; + try { text = readFileSync(catalogPath, "utf8"); } catch { return null; } + let parsed: unknown; + try { parsed = JSON.parse(text.replace(/^\uFEFF/, "")); } catch { return null; } + const windows = new Map(); + if (parsed !== null && typeof parsed === "object" && !Array.isArray(parsed)) { + const record = parsed as { models?: unknown }; + if (Array.isArray(record.models)) { + for (const value of record.models) { + if (value === null || typeof value !== "object" || Array.isArray(value)) continue; + const entry = value as { id?: unknown; contextWindow?: unknown }; + if (typeof entry.id !== "string") continue; + const window = entry.contextWindow; + if (typeof window !== "number" || !Number.isFinite(window) || window <= 0) continue; + windows.set(entry.id, window); + } + } + } + cmdCatalogCache = { path: catalogPath, mtimeMs, windows }; + } + return cmdCatalogCache.windows.get(model) ?? null; +} \ No newline at end of file diff --git a/server/conversation-metadata.ts b/server/conversation-metadata.ts index a0e64f9683..d8c62cb936 100644 --- a/server/conversation-metadata.ts +++ b/server/conversation-metadata.ts @@ -1,5 +1,6 @@ import { isContextClear, piMessage } from "./transcript-records.ts"; import { piContextWindow } from "./pi-models.ts"; +import { cmdContextWindow } from "./cmd.ts"; import type { ConversationMetadata, ConversationResponse } from "../shared/protocol.ts"; type RecordValue = Record; @@ -69,7 +70,7 @@ export function parseConversationMetadata(text: string, source: ConversationResp if (model) metadata.model = model; // A new complete context without effort must not retain an older value. if (model || effort !== undefined) metadata.reasoning_effort = label(effort); - } else if (source === "omp-transcript" || source === "omo-transcript" || source === "gjc-transcript" || source === "pi-transcript") { + } else if (source === "omp-transcript" || source === "omo-transcript" || source === "gjc-transcript" || source === "pi-transcript" || source === "cmd-transcript") { if (entry.type === "model_change") { // pi and omo name `provider` and `modelId`; omp names one `model`, "provider/id", where // the id is what its answers name as their model @@ -83,21 +84,30 @@ export function parseConversationMetadata(text: string, source: ConversationResp // a turn answers at whatever model it ran at, and that is the one the window belongs to if (entry.type === "message" && message.role === "assistant" && label(message.model)) { metadata.model = label(message.model); - if (label(message.provider)) piProvider = label(message.provider); + if (source === "cmd-transcript") piProvider = "commandcode"; + else if (label(message.provider)) piProvider = label(message.provider); } if (entry.type === "message" && message.role === "assistant") { const usage = record(message.usage); - if (source !== "pi-transcript") { + if (source === "cmd-transcript") { + // cmd (Command Code) uses camelCase usage fields and resolves its window through + // the commandcode catalog; otherwise it is pi-shaped (counts the answer too) + const filled = message.stopReason === "error" || message.stopReason === "aborted" ? 0 + : count(usage.totalTokens) || count(usage.inputTokens) + count(usage.outputTokens) + count(usage.cacheReadTokens) + count(usage.cacheWriteTokens); + const window = metadata.model === null ? null : cmdContextWindow(metadata.model); + metadata.context = contextOf(filled, metadata.model, window, metadata.context); + } else if (source !== "pi-transcript") { // omp, omo and gjc are read the same way but resolve no window, so their figure // stays as it was rather than moving to a rule only pi states metadata.context = contextOf(count(usage.input) + count(usage.cacheRead) + count(usage.cacheWrite), metadata.model, null, metadata.context); continue; + } else { + // pi's own figure for what one request filled: it counts the answer too, and leaves a + // failed or aborted request out, which filled nothing + const filled = message.stopReason === "error" || message.stopReason === "aborted" ? 0 + : count(usage.totalTokens) || count(usage.input) + count(usage.output) + count(usage.cacheRead) + count(usage.cacheWrite); + metadata.context = contextOf(filled, metadata.model, null, metadata.context, piProvider); } - // pi's own figure for what one request filled: it counts the answer too, and leaves a - // failed or aborted request out, which filled nothing - const filled = message.stopReason === "error" || message.stopReason === "aborted" ? 0 - : count(usage.totalTokens) || count(usage.input) + count(usage.output) + count(usage.cacheRead) + count(usage.cacheWrite); - metadata.context = contextOf(filled, metadata.model, null, metadata.context, piProvider); } } else if (source === "claude-transcript") { const message = record(entry.message); diff --git a/server/conversation.ts b/server/conversation.ts index d50729b883..bba41d08c4 100644 --- a/server/conversation.ts +++ b/server/conversation.ts @@ -48,6 +48,7 @@ import { processStartedAt } from "./process-start.ts"; import { ompHeldTranscript } from "./omp.ts"; import { forgetOpencodeRead, forgetOpencodeState, OPENCODE_IMAGE_REF, opencodeConversation, opencodeDatabasePath, opencodeImage, opencodeReadKey, opencodeSessionId, opencodeToolOutput } from "./opencode.ts"; import { piTranscriptInStore, piTranscriptPath, unwrittenSession } from "./pi.ts"; +import { cmdTranscriptPath } from "./cmd.ts"; import { defaultDevinDbPath, DevinHistoryUnavailable, devinConversation, forgetDevinState } from "./devin.ts"; import { forgetGrokState, grokProjection, grokToolOutput, parseGrokTranscript, GrokUnavailable } from "./grok.ts"; import { resolveGrokStore } from "./grok-store.ts"; @@ -495,7 +496,7 @@ export class HistoryChanged extends Error { /** What paneConversation resolved: which store the turns came from, and where they start. */ export type RecognizedConversation = { activity?: "thinking" | "working" | "idle"; - source: "grok-transcript" | "agy-transcript" | "claude-transcript" | "omp-transcript" | "omo-transcript" | "gjc-transcript" | "pi-transcript" | "codex-transcript" | "devin-transcript" | "opencode-transcript" | "hermes-transcript"; + source: "grok-transcript" | "agy-transcript" | "claude-transcript" | "omp-transcript" | "omo-transcript" | "gjc-transcript" | "pi-transcript" | "cmd-transcript" | "codex-transcript" | "devin-transcript" | "opencode-transcript" | "hermes-transcript"; turns: ConversationTurn[]; metadata: ConversationMetadata; /** `metadata` was read off the agent's own screen (ConversationResponse.screen) */ @@ -563,8 +564,8 @@ function transcriptStream(source: StreamSource, path: string, stat: { dev: numbe // (a chain whose parent was archived since comes back shorter: codexHistorySegments; // a pi branch skips the side paths a /tree left behind: piBranchSegments) const grok = source === "grok-transcript" ? grokProjection(path, stat) : null; - const branch = source === "pi-transcript" ? piBranchSegments(path, stat.size) : null; - if (source === "pi-transcript" && branch === null) throw new ConversationUnavailable("branch_unreadable"); + const branch = (source === "pi-transcript" || source === "cmd-transcript") ? piBranchSegments(path, stat.size) : null; + if ((source === "pi-transcript" || source === "cmd-transcript") && branch === null) throw new ConversationUnavailable("branch_unreadable"); const segments: { path: string; start: number; end: number }[] = source === "codex-transcript" ? codexHistorySegments(path, codexHome).map((segment) => ({ path: segment.path, start: 0, end: segment.end })) : grok !== null ? grok.segments.map((segment) => ({ path, ...segment })) @@ -687,6 +688,7 @@ const TURN_MARK: Record = { "omo-transcript": Buffer.from('"user"'), "gjc-transcript": Buffer.from('"user"'), "pi-transcript": Buffer.from('"user"'), + "cmd-transcript": Buffer.from('"user"'), }; /** @@ -874,9 +876,9 @@ function rememberPaneDevin(paneId: string, sessionId: string, cwd: string, dbPat /** Pi retains at most four small parsed pages per cache; oversized pages remain readable uncached. */ function rememberPage(map: Map, key: string, value: T, limit: number): void { map.delete(key); - if (value.source === "pi-transcript" && !withinRetentionBudget(value, PI_PAGE_CACHE_BYTES)) return; + if ((value.source === "pi-transcript" || value.source === "cmd-transcript") && !withinRetentionBudget(value, PI_PAGE_CACHE_BYTES)) return; remember(map, key, value, limit); - const piKeys = [...map].filter(([, item]) => item.source === "pi-transcript").map(([key]) => key); + const piKeys = [...map].filter(([, item]) => item.source === "pi-transcript" || item.source === "cmd-transcript").map(([key]) => key); while (piKeys.length > 4) map.delete(piKeys.shift()!); } @@ -935,7 +937,7 @@ function parseTurns(source: RecognizedConversation["source"], path: string, text // only pi keeps a tool's images in the entry as base64; omp, omo and gjc are read the same // way but would carry image refs nothing can answer, so the option stays with pi alone : source === "claude-transcript" ? parseClaudeTranscript(text, Infinity, { subagents: subagentsOnPage(path, text), noticed }) - : parseOmpTranscript(text, Infinity, { toolImages: source === "pi-transcript", taskTitles }); + : parseOmpTranscript(text, Infinity, { toolImages: source === "pi-transcript" || source === "cmd-transcript", taskTitles }); } interface LiveCodexTurn { @@ -1036,8 +1038,8 @@ function liveTurns(path: string, stream: TranscriptStream, source: RecognizedCon /** Retention diagnostics only: counts/budget adherence, never transcript contents. */ export function piPageCacheUsage(): { pages: number; settled: number; withinBudget: boolean } { - const pages = [...cache.values()].filter(item => item.source === "pi-transcript"); - const settled = [...settledTurns.values()].filter(item => item.source === "pi-transcript"); + const pages = [...cache.values()].filter(item => item.source === "pi-transcript" || item.source === "cmd-transcript"); + const settled = [...settledTurns.values()].filter(item => item.source === "pi-transcript" || item.source === "cmd-transcript"); return { pages: pages.length, settled: settled.length, withinBudget: [...pages, ...settled].every(item => withinRetentionBudget(item, PI_PAGE_CACHE_BYTES)) }; } @@ -1395,6 +1397,12 @@ async function resolveTranscript(pane: HerdrPane, cwd: string, codexHome?: strin if ("unwritten" in found) throw new ConversationNotStarted(found.unwritten.id, "pi-transcript", found.unwritten.path); return { source: "pi-transcript", path: found.path }; } + if (agent === "cmd") { + const found = await cmdTranscriptPath(paneId); + if (found === null) throw new ConversationUnavailable("no_session_path"); + if ("unwritten" in found) throw new ConversationNotStarted(found.unwritten.id, "cmd-transcript", found.unwritten.path); + return { source: "cmd-transcript", path: found.path }; + } if (agent === "opencode") { // herdr's integration names the session the TUI shows; at its home screen there is none const session = opencodeSessionId(pane); @@ -1592,7 +1600,7 @@ export async function paneConversation(paneId: string, codexHome?: string, page: } const identity = resolved.source === "agy-transcript" ? resolved.path : resolved.source === "claude-transcript" ? nodePath.basename(resolved.path, ".jsonl") - : resolved.source === "pi-transcript" || resolved.source === "omp-transcript" ? realpathSync(resolved.path) : null; + : resolved.source === "pi-transcript" || resolved.source === "cmd-transcript" || resolved.source === "omp-transcript" ? realpathSync(resolved.path) : null; rememberPaneRead(paneId, resolved.path); const answer = transcriptPage(resolved.source, resolved.path, page, resolved.codexHome ?? codexHome); const status = snapshot.panes.find((pane) => pane.pane_id === paneId)?.agent_status; @@ -1605,6 +1613,12 @@ export async function paneConversation(paneId: string, codexHome?: string, page: } if (resolved.agy && !sameAgyStore(resolved.agy, await resolveAgyStore(snapshot.panes.find((pane) => pane.pane_id === paneId)!))) throw new ConversationUnavailable("no_session_path"); if (identity !== null) { writtenSessions.add(`${resolved.source}\0${identity}`); rememberPaneSession(paneId, `${resolved.source}\0${identity}`); } + if (resolved.source === "cmd-transcript") { + // Command Code records no model in its transcript; herdr knows the one the pane runs at + const herdrAgent = snapshot.agents.find((agent) => agent.pane_id === paneId); + const model = typeof herdrAgent?.tokens?.model === "string" ? herdrAgent.tokens.model : null; + if (model && answer.metadata.model === null) answer.metadata.model = model; + } if (resolved.source === "grok-transcript") { try { return { ...answer, activity: grokProjection(resolved.path, statSync(resolved.path)).activity }; } catch { throw new ConversationUnavailable("transcript_missing"); } @@ -1700,7 +1714,7 @@ function transcriptPageRead(source: StreamSource, path: string, page: Conversati cache.delete(key); // the abandoned turns live outside the branch's byte ranges, so the page cannot see them: a // straight line answers { count: 0 }, which is most sessions and costs the client nothing - const abandoned = source === "pi-transcript" ? piAbandonedTurns(path, stat.size) ?? undefined : undefined; + const abandoned = (source === "pi-transcript" || source === "cmd-transcript") ? piAbandonedTurns(path, stat.size) ?? undefined : undefined; rememberPage(cache, key, { source, signature, turns, metadata, cursor, abandoned }, 32); return { source, turns, metadata, cursor, abandoned, history_id: stream.id, version }; } @@ -1723,7 +1737,7 @@ export async function conversationImage(paneId: string, ref: string, codexHome?: if (resolved.source === "opencode-transcript") return opencodeImage(resolved.path, resolved.session, ref); rememberPaneRead(paneId, resolved.path); if (resolved.source === "codex-transcript") return codexTranscriptImage(codexHistorySegments(resolved.path, resolved.codexHome ?? codexHome), ref, pane.cwd); - if (resolved.source === "pi-transcript") return piTranscriptImage(resolved.path, ref); + if (resolved.source === "pi-transcript" || resolved.source === "cmd-transcript") return piTranscriptImage(resolved.path, ref); return resolved.source === "claude-transcript" ? transcriptImage(resolved.path, ref) : null; } @@ -1793,7 +1807,7 @@ export function transcriptToolOutput(source: StreamSource, path: string, ref: st } if (source === "grok-transcript") return grokToolOutput(path, ref); if (source === "agy-transcript") return agyToolOutput(path, ref); - if (source === "pi-transcript") return piToolOutput(path, ref); + if (source === "pi-transcript" || source === "cmd-transcript") return piToolOutput(path, ref); let text: string; try { text = readFileSync(path, "utf8"); } catch { return null; } return outputInText(source, text, ref); diff --git a/server/dialogs/cmd.ts b/server/dialogs/cmd.ts index 01a8313698..6fb4f1271a 100644 --- a/server/dialogs/cmd.ts +++ b/server/dialogs/cmd.ts @@ -1,4 +1,117 @@ import type { DialogReader } from "./registry.ts"; +import { ANSI_RE, type ParsedPrompt, cleanLine, findLastIndex, finishPrompt, isDivider } from "./shared.ts"; -// No agent-specific screen reader on this base. -export const readers: readonly DialogReader[] = []; +/** + * Command Code's permission prompt, as its TUI draws it (ACP `session/request_permission`): + * + * Create File + * + * Do you want to create hello.txt? + * + * ❯ 1. Yes + * 2. Yes, allow all edits this session [shift+tab] + * 3. No, tell Command Code what to do differently + * + * ↑/↓ navigate · enter select · ctrl+e explain · Run cmd --yolo to + * bypass all permissions (Docs ↗) + * + * The rows are numbered and the cursor is marked with `❯`; the footer names the keys. Only the + * rows directly above the footer are the dialog's: a numbered list in the conversation above it + * (or in the agent's own output) is not an option, and a command's own text is not one either. + */ +const CMD_HINT_RE = /↑\/↓ navigate/i; + +function parseCmdQuestion(screen: string): ParsedPrompt | null { + const lines = screen.replace(ANSI_RE, "").split(/\r?\n/); + const hintIndex = findLastIndex(lines, (line) => CMD_HINT_RE.test(line) && /enter select/i.test(line)); + if (hintIndex < 0) return null; + // the dialog block: the rows directly above the footer, and their wrapped continuation lines. + // The blank line between the last row and the footer is skipped; anything else ends the block. + let blockStart = hintIndex; + while (blockStart > 0 && lines[blockStart - 1]!.trim() === "") blockStart -= 1; + while (blockStart > 0) { + const prev = lines[blockStart - 1]!; + if (isDivider(prev) || prev.trim() === "") break; + if (/^\s*[❯›>]?\s*\d+\.\s+/.test(prev)) { blockStart -= 1; continue; } + // a wrapped continuation of the row above (indented, not numbered) + if (/^\s+\S/.test(prev) && !/^\s*[❯›>]?\s*\d+\.\s+/.test(prev)) { blockStart -= 1; continue; } + break; + } + const rows: { label: string; selected: boolean; number: number }[] = []; + for (let index = blockStart; index < hintIndex; index += 1) { + const match = /^\s*([❯›>])?\s*(\d+)\.\s+(.+)$/.exec(lines[index]!); + if (!match) continue; + const row = { label: match[3]!.trim(), selected: match[1] !== undefined, number: Number(match[2]) }; + let next = index + 1; + while (next < hintIndex && !/^\s*[❯›>]?\s*\d+\.\s+/.test(lines[next]!) && !/↑\/↓/.test(lines[next]!) && lines[next]!.trim() !== "" && !isDivider(lines[next]!)) { + row.label = (row.label + " " + lines[next]!.trim()).trim(); + next += 1; + } + rows.push(row); + } + if (rows.length < 2) return null; + // the rows are numbered from 1, in order: anything else is another program's list + if (!rows.every((row, index) => row.number === index + 1)) return null; + // the cursor must be marked on exactly one row: without it the chat cannot tell which option + // an answer would land on, and Command Code may mark the cursor by colour alone + if (rows.filter((row) => row.selected).length !== 1) return null; + const selectedIndex = rows.findIndex((row) => row.selected); + const firstRowIndex = lines.findIndex((line, index) => index >= blockStart && /^\s*[❯›>]?\s*1\.\s+/.test(line)); + // the heading: the first line of the dialog above the question (e.g. "Create File") + let title = "Permission request"; + if (firstRowIndex >= 0) { + let index = firstRowIndex - 1; + while (index >= 0) { + const cleaned = cleanLine(lines[index]!); + if (cleaned === "") { index -= 1; continue; } + if (isDivider(cleaned)) break; + if (/Do you want to .+\?/i.test(cleaned)) { index -= 1; continue; } + if (/needs to (execute|run|approve|create|edit|write|delete)/i.test(cleaned)) { index -= 1; continue; } + if (/ctrl\+e/i.test(cleaned) && /explain/i.test(cleaned)) { index -= 1; continue; } + if (/[;|]|2>\/dev\/null|head -\d/.test(cleaned)) { index -= 1; continue; } + if (cleaned) title = cleaned; + break; + } + } + // the question: the line asking for approval, the last one above the rows + let question = title; + if (firstRowIndex >= 0) { + for (let index = firstRowIndex - 1; index >= 0; index -= 1) { + const cleaned = cleanLine(lines[index]!); + if (cleaned === "") continue; + if (isDivider(cleaned)) break; + if (/Do you want to .+\?/i.test(cleaned) || /needs to (execute|run|approve|create|edit|write|delete)/i.test(cleaned)) { question = cleaned; break; } + } + } + // the body: what the agent wants to do (the command, the file), between the question and the rows + const questionIndex = firstRowIndex >= 0 ? lines.slice(0, firstRowIndex).reduce((acc, line, index) => (/Do you want to .+\?/i.test(cleanLine(line)) || /needs to (execute|run|approve|create|edit|write|delete)/i.test(cleanLine(line))) ? index : acc, -1) : -1; + const bodyLines = (questionIndex >= 0 && firstRowIndex >= 0) ? lines.slice(questionIndex + 1, firstRowIndex) : []; + const body = bodyLines.map(cleanLine).filter((line) => line && !isDivider(line) && !/ctrl\+e/i.test(line)).join("\n") || null; + return finishPrompt("cmd", { + kind: "approval", title, question, body, + options: rows.map((row) => ({ label: row.label, description: null })), + multi_select: false, + custom_option_index: null, + }, { + responder: "cmd-question", + menuLabels: rows.map((row) => row.label), + selectedIndex, + checkedOptionIndices: [], + customMenuIndex: null, + rejectWithEscapeIndex: null, + }); +} + +export const readers: readonly DialogReader[] = [ + { + id: "cmd-question", agents: ["cmd"], priority: 10, + // the cursor is read off the screen, so it is re-read before the Enter: a cursor the user + // moved in the terminal since the card was drawn would otherwise be answered from a + // position the chat never saw + reread: "before-commit", list: "none", + parse: (screen) => { + const parsed = parseCmdQuestion(screen); + return parsed?.responder === "cmd-question" ? parsed : null; + }, + }, +]; diff --git a/server/dialogs/registry.test.ts b/server/dialogs/registry.test.ts index 06f4a43e29..d11cb0988b 100644 --- a/server/dialogs/registry.test.ts +++ b/server/dialogs/registry.test.ts @@ -31,6 +31,7 @@ const snapshot: Record CMD_HINT_RE.test(line)); return ends(/ctrl\+g to edit|shift\+tab to approve with this feedback/i); } /** @@ -272,6 +275,8 @@ function promptTailIsActive(prompt: ParsedPrompt, screen: string): boolean { */ const PI_MENU_HINT_AT_END_RE = new RegExp(`${PI_MENU_HINT_RE.source}$`, "i"); const PI_INPUT_HINT_AT_END_RE = new RegExp(`${PI_INPUT_HINT_RE.source}$`, "i"); +/** Command Code's permission footer: `↑/↓ navigate · enter select · ctrl+e explain · Run cmd --yolo …` */ +const CMD_HINT_RE = /↑\/↓ navigate.*enter select/i; /** The same hint told from the end, so lines that follow it cannot complete a match of their own. */ const PI_MODEL_HINT_AT_END_RE = new RegExp(`${PI_MODEL_HINT_RE.source}$`, "i"); /** @@ -1184,7 +1189,7 @@ async function readKnownPrompt( return { prompt: publicPrompt(parsed) }; } - if (!["claude", "omp", "codex", "omo", "pi", "gjc", "hermes", ""].includes(agent)) return { prompt: null }; + if (!["claude", "omp", "codex", "omo", "pi", "gjc", "hermes", "cmd", ""].includes(agent)) return { prompt: null }; const screen = await liveScreen(paneId); // omo's form reads its text from the session's call, the screen showing where the form stands const omoAsks = ["omo", "pi", "claude", ""].includes(agent) && pane.cwd && OMO_FORM_RE.test(screen) diff --git a/server/skills/cmd.ts b/server/skills/cmd.ts new file mode 100644 index 0000000000..4ca98aaa62 --- /dev/null +++ b/server/skills/cmd.ts @@ -0,0 +1,48 @@ +import { existsSync } from "node:fs"; +import { join } from "node:path"; +import type { AgentCommandContext, AgentCommandSource } from "./index.ts"; +import { ancestorsToRepoRoot, piTemplates, walkSkills } from "./walk.ts"; +import type { SlashCommand } from "../../shared/protocol.ts"; + +const asCommands = (found: ReturnType, origin: NonNullable): SlashCommand[] => + found.map(({ name, description }) => ({ name: `skill:${name}`, description, source: "skill", origin })); + +const recursive = (root: string): ReturnType => walkSkills(root, { depth: "recursive", looseMarkdown: "root", ignoreFiles: false, requireDescription: true }); + +function discoverSkills({ home, cwd }: AgentCommandContext): SlashCommand[] { + const agentDir = join(home, ".commandcode"); + const user = [...recursive(join(agentDir, "skills")), ...recursive(join(home, ".agents", "skills"))]; + const result = asCommands(user, "user"); + if (cwd) { + for (const directory of ancestorsToRepoRoot(cwd)) { + const project = [ + ...recursive(join(directory, ".commandcode", "skills")), + ...recursive(join(directory, ".agents", "skills")), + ]; + result.push(...asCommands(project, "project")); + } + } + // deduplicate by name, keeping the first (user before project) occurrence + const seen = new Set(); + return result.filter((cmd) => { + if (seen.has(cmd.name)) return false; + seen.add(cmd.name); + return true; + }); +} + +export const source: AgentCommandSource = { + builtins: [], + discover(ctx) { + const { home, cwd } = ctx; + const agentDir = join(home, ".commandcode"); + const result: SlashCommand[] = []; + // Command Code stores custom slash commands in commands/, not prompts/ + result.push(...piTemplates(join(agentDir, "commands"), "user")); + if (cwd && existsSync(join(cwd, ".commandcode", "commands"))) { + result.push(...piTemplates(join(cwd, ".commandcode", "commands"), "project")); + } + result.push(...discoverSkills(ctx)); + return result; + }, +}; \ No newline at end of file diff --git a/server/skills/index.ts b/server/skills/index.ts index 1e850c5918..802a09b238 100644 --- a/server/skills/index.ts +++ b/server/skills/index.ts @@ -3,6 +3,7 @@ import { source as omo } from "./omo.ts"; import { source as claude } from "./claude.ts"; import { source as codex } from "./codex.ts"; import { source as pi } from "./pi.ts"; +import { source as cmd } from "./cmd.ts"; import { source as omp } from "./omp.ts"; import { source as gjc } from "./gjc.ts"; import { source as opencode } from "./opencode.ts"; @@ -28,4 +29,4 @@ export interface AgentCommandSource { readonly descriptions?: Readonly>; readonly discover: (ctx: AgentCommandContext) => SlashCommand[]; } -export const AGENT_SOURCES: Record = { omo, claude, codex, pi, omp, gjc, opencode, devin, grok, hermes, agy }; +export const AGENT_SOURCES: Record = { omo, claude, codex, pi, cmd, omp, gjc, opencode, devin, grok, hermes, agy }; diff --git a/shared/protocol.ts b/shared/protocol.ts index ed5a0865e8..cb6f65a5d3 100644 --- a/shared/protocol.ts +++ b/shared/protocol.ts @@ -609,7 +609,7 @@ export interface ConversationResponse { activity?: "thinking" | "working" | "idle"; /** Stable across appends; changes on transcript replacement or native context clear. */ history_id?: string; - source: "grok-transcript" | "agy-transcript" | "claude-transcript" | "omp-transcript" | "omo-transcript" | "gjc-transcript" | "pi-transcript" | "codex-transcript" | "devin-transcript" | "opencode-transcript" | "hermes-transcript" | "scrollback"; + source: "grok-transcript" | "agy-transcript" | "claude-transcript" | "omp-transcript" | "omo-transcript" | "gjc-transcript" | "pi-transcript" | "cmd-transcript" | "codex-transcript" | "devin-transcript" | "opencode-transcript" | "hermes-transcript" | "scrollback"; turns: ConversationTurn[]; metadata?: ConversationMetadata; /** diff --git a/site/demo/transport.ts b/site/demo/transport.ts index 5e034b889d..b2b8fcff7d 100644 --- a/site/demo/transport.ts +++ b/site/demo/transport.ts @@ -640,7 +640,7 @@ async function route(url: URL, method: string, init: RequestInit | undefined, in const chat = key ? chats.get(key) : undefined; const agent = agentOf(paneId); if (!chat) return json({ source: "scrollback", turns: [] }); - const source = agent === "claude" ? "claude-transcript" : agent === "codex" ? "codex-transcript" : agent === "gjc" ? "gjc-transcript" : agent === "omo" ? "omo-transcript" : agent === "pi" ? "pi-transcript" : agent === "devin" ? "devin-transcript" : agent === "hermes" ? "hermes-transcript" : "omp-transcript"; + const source = agent === "claude" ? "claude-transcript" : agent === "codex" ? "codex-transcript" : agent === "gjc" ? "gjc-transcript" : agent === "omo" ? "omo-transcript" : agent === "pi" ? "pi-transcript" : agent === "cmd" ? "cmd-transcript" : agent === "devin" ? "devin-transcript" : agent === "hermes" ? "hermes-transcript" : "omp-transcript"; // These demo fixtures have no native Grok/Agy lifecycle: omit optional activity. return json({ source, turns: chat.turns, metadata: chat.metadata, cursor: null }); } From 1084a571e7320cb19fe2bb4353e4b2c684976ad2 Mon Sep 17 00:00:00 2001 From: devswha Date: Tue, 20 Oct 2026 19:02:01 +0900 Subject: [PATCH 2/4] fix(cmd): take the session from herdr only, hash the whole permission dialog, and read the catalog as a bounded regular file - The COMMANDCODE_SESSION_ID fallback read the start-up environment, which is stale after /clear or /resume and inherited by children, so another session's conversation could show as this pane's. Only herdr's report names the session now, as for pi. - A command shown above the question was left out of the card, so a command replaced under an open card kept its id and the old card's Yes approved the new command. Every dialog line between the title and the rows is in the body now. - A dialog counted as live while its footer was among the last three lines, so a quoted or stale dialog with a typed line below it took the Enter. The footer must now end the screen, followed only by its own wrapped words. - commandcode-models.json was read with readFileSync, so a FIFO there blocked the server. It is opened non-blocking, checked as a regular file of at most 8 MiB. --- server/cmd.test.ts | 31 ++++++++++++++++ server/cmd.ts | 83 +++++++++++-------------------------------- server/dialogs/cmd.ts | 12 ++++--- server/prompt.ts | 13 ++++++- 4 files changed, 71 insertions(+), 68 deletions(-) diff --git a/server/cmd.test.ts b/server/cmd.test.ts index 00575d21de..d33134b78d 100644 --- a/server/cmd.test.ts +++ b/server/cmd.test.ts @@ -153,4 +153,35 @@ describe("Command Code's permission prompt is a card", () => { const screen = permission.split("\n").slice(0, -2).join("\n"); expect(parseInteractivePrompt("cmd", screen)).toBeNull(); }); + + it("makes the card stale when the command it asks about changes", () => { + const footer = permission.split("\n").slice(-2); + const run = (command: string) => ["Run Command", "", command, "", "Do you want to run this command?", "", "\u276f 1. Yes", " 2. Yes, and don't ask again", " 3. No, tell Command Code what to do differently", "", ...footer].join("\n"); + const harmless = parseInteractivePrompt("cmd", run("printf harmless | head -5")); + const replaced = parseInteractivePrompt("cmd", run("delete-important-files | head -5")); + expect(harmless?.body).toContain("printf harmless | head -5"); + expect(replaced?.id).toBeString(); + expect(replaced?.id).not.toBe(harmless?.id); + }); + + it("takes no dialog for a live one when a line typed below follows its footer", () => { + expect(parseInteractivePrompt("cmd", `${permission}\n\n\u276f my next message`)).toBeNull(); + expect(parseInteractivePrompt("cmd", `${permission}\nsomething the agent printed`)).toBeNull(); + }); + + it("still reads the footer wrapped over three lines on a narrow pane", () => { + const narrow = [...permission.split("\n").slice(0, -2), "\u2191/\u2193 navigate \u00b7 enter select \u00b7 ctrl+e", "explain \u00b7 Run cmd --yolo to bypass all", "permissions (Docs \u2197)"].join("\n"); + expect(parseInteractivePrompt("cmd", narrow)?.title).toBe("Create File"); + }); +}); + +// A FIFO where the catalog is expected must not block the server: read in a child with a deadline, +// since a blocking read would hang this process itself. +it.skipIf(process.platform === "win32")("reads no window from a FIFO in the catalog's place", () => { + const fifo = join(root, "catalog-fifo.json"); + expect(Bun.spawnSync(["mkfifo", fifo]).exitCode).toBe(0); + const script = `const { cmdContextWindow } = await import(${JSON.stringify(join(import.meta.dir, "cmd.ts"))}); console.log(JSON.stringify(cmdContextWindow("m", ${JSON.stringify(fifo)})));`; + const child = Bun.spawnSync([process.execPath, "-e", script], { timeout: 5_000, stdout: "pipe", stderr: "pipe" }); + expect(child.exitCode).toBe(0); + expect(child.stdout.toString().trim()).toBe("null"); }); diff --git a/server/cmd.ts b/server/cmd.ts index f2269e89a3..71b3e05264 100644 --- a/server/cmd.ts +++ b/server/cmd.ts @@ -2,8 +2,7 @@ import { constants, closeSync, fstatSync, lstatSync, openSync, readFileSync, rea import { basename, dirname, isAbsolute, join, relative, sep } from "node:path"; import { homedir } from "node:os"; -import { agentGet, paneProcessInfo } from "./herdr/client.ts"; -import { processEnviron } from "./omo.ts"; +import { agentGet } from "./herdr/client.ts"; /** * Command Code's session store. One directory per project (the cwd), under @@ -74,41 +73,11 @@ export function unwrittenSession(path: string, sessionDir: string): { id: string } } -const CMD_EXECUTABLE = /(^|[\\/])command[-_]?code(?:\.exe|\.[cm]?js)?$/i; -const CMD_NPM_ENTRY = /(^|[\\/])node_modules[\\/]command-code[\\/]/i; - -export function isCmdProcess(argv: readonly string[]): boolean { - if (argv.length === 0) return false; - const exec = argv[0] ?? ""; - const base = exec.split(/[\\/]/).pop()!.toLowerCase().replace(/\.(?:exe|cmd|js|mjs|cjs)$/i, ""); - if (base === "commandcode" || base === "cmdc" || base === "command-code" || base === "cmd") { - // only treat `cmd` as Command Code when it actually runs command-code - if (base === "cmd") { - return argv.some((word) => /command[-_]?code/i.test(word)); - } - return true; - } - if (/(?:^|[\\/])(?:bun|node)(?:\.exe)?$/i.test(exec) && argv.slice(1).some((word) => CMD_NPM_ENTRY.test(word) || /command[-_]?code/i.test(word))) { - return true; - } - return CMD_EXECUTABLE.test(exec); -} - -type CmdProcess = { pid?: number; argv?: readonly string[] | null; cwd?: string | null }; type CmdTranscriptOptions = { agent?: Awaited> | null; - processes?: readonly CmdProcess[]; - environOf?: (pid: number) => readonly string[] | null; - argvOf?: (pid: number) => readonly string[] | null; home?: string; }; -function processArgv(pid: number): readonly string[] | null { - if (process.platform !== "linux") return null; - try { return readFileSync(`/proc/${pid}/cmdline`, "utf8").split("\0").filter(Boolean); } - catch { return null; } -} - export async function cmdTranscriptPath( paneId: string, options: CmdTranscriptOptions = {}, @@ -121,37 +90,12 @@ export async function cmdTranscriptPath( if (!cwd) return null; const projectDir = defaultCmdProjectDir(cwd); - // session value reported by herdr (if any) + // Only herdr's report names the session, as for pi. The process environment's + // COMMANDCODE_SESSION_ID is set at start-up or inherited, so after /clear, /resume or in a child it + // names another session: shown as this pane's, another conversation would be read as this one. const session = agent.agent_session; - const reportedPath = (session?.agent === "cmd" && session.source === "herdr:cmd" && session.kind === "path" + const candidate = (session?.agent === "cmd" && session.source === "herdr:cmd" && session.kind === "path" && typeof session.value === "string") ? session.value : null; - - const info = options.processes === undefined - ? await paneProcessInfo(paneId).catch(() => undefined) - : { foreground_processes: [...options.processes] }; - const environOf = options.environOf ?? processEnviron; - const argvOf = options.argvOf ?? processArgv; - - const cmdProcesses: { pid: number; argv: readonly string[] }[] = []; - for (const entry of info?.foreground_processes ?? []) { - if (typeof entry.pid !== "number") continue; - const argv = entry.argv?.length ? entry.argv : argvOf(entry.pid); - if (argv?.length && isCmdProcess(argv)) cmdProcesses.push({ pid: entry.pid, argv }); - } - - // session id from environ - let sessionId: string | null = null; - for (const process of cmdProcesses) { - const env = environOf(process.pid); - if (!env) continue; - const entry = env.find((word) => word.startsWith("COMMANDCODE_SESSION_ID=")); - if (entry) { sessionId = entry.slice("COMMANDCODE_SESSION_ID=".length); break; } - } - - // candidate path - let candidate: string | null = reportedPath; - if (candidate === null && sessionId) candidate = join(projectDir, `${sessionId}.jsonl`); - if (candidate === null) return null; const path = cmdTranscriptInStore(candidate, projectDir); if (path !== null) return { path }; @@ -166,12 +110,25 @@ export async function cmdTranscriptPath( */ let cmdCatalogCache: { path: string; mtimeMs: number; windows: Map } | null = null; +const MAX_CATALOG_BYTES = 8 * 1024 * 1024; + +/** The catalog's text, read only as a regular file of bounded size: a FIFO there must not block the server. */ +function readCatalog(path: string): string | null { + let fd: number | undefined; + try { + fd = openSync(path, constants.O_RDONLY | (constants.O_NONBLOCK ?? 0)); + const stat = fstatSync(fd); + return stat.isFile() && stat.size <= MAX_CATALOG_BYTES ? readFileSync(fd, "utf8") : null; + } catch { return null; } + finally { if (fd !== undefined) closeSync(fd); } +} + export function cmdContextWindow(model: string, catalogPath = cmdModelCatalogPath()): number | null { let mtimeMs: number; try { mtimeMs = statSync(catalogPath).mtimeMs; } catch { return null; } if (cmdCatalogCache === null || cmdCatalogCache.path !== catalogPath || cmdCatalogCache.mtimeMs !== mtimeMs) { - let text: string; - try { text = readFileSync(catalogPath, "utf8"); } catch { return null; } + const text = readCatalog(catalogPath); + if (text === null) return null; let parsed: unknown; try { parsed = JSON.parse(text.replace(/^\uFEFF/, "")); } catch { return null; } const windows = new Map(); diff --git a/server/dialogs/cmd.ts b/server/dialogs/cmd.ts index 6fb4f1271a..aa8671ebd6 100644 --- a/server/dialogs/cmd.ts +++ b/server/dialogs/cmd.ts @@ -59,6 +59,8 @@ function parseCmdQuestion(screen: string): ParsedPrompt | null { const firstRowIndex = lines.findIndex((line, index) => index >= blockStart && /^\s*[❯›>]?\s*1\.\s+/.test(line)); // the heading: the first line of the dialog above the question (e.g. "Create File") let title = "Permission request"; + // where the dialog's text starts: just below the title, else below whatever ended the search + let titleIndex = -1; if (firstRowIndex >= 0) { let index = firstRowIndex - 1; while (index >= 0) { @@ -72,6 +74,7 @@ function parseCmdQuestion(screen: string): ParsedPrompt | null { if (cleaned) title = cleaned; break; } + titleIndex = index; } // the question: the line asking for approval, the last one above the rows let question = title; @@ -83,10 +86,11 @@ function parseCmdQuestion(screen: string): ParsedPrompt | null { if (/Do you want to .+\?/i.test(cleaned) || /needs to (execute|run|approve|create|edit|write|delete)/i.test(cleaned)) { question = cleaned; break; } } } - // the body: what the agent wants to do (the command, the file), between the question and the rows - const questionIndex = firstRowIndex >= 0 ? lines.slice(0, firstRowIndex).reduce((acc, line, index) => (/Do you want to .+\?/i.test(cleanLine(line)) || /needs to (execute|run|approve|create|edit|write|delete)/i.test(cleanLine(line))) ? index : acc, -1) : -1; - const bodyLines = (questionIndex >= 0 && firstRowIndex >= 0) ? lines.slice(questionIndex + 1, firstRowIndex) : []; - const body = bodyLines.map(cleanLine).filter((line) => line && !isDivider(line) && !/ctrl\+e/i.test(line)).join("\n") || null; + // the body: what the agent wants to do (the command, the file), every line of the dialog between + // its title and the rows but the question, so a command shown above the question is in the card's + // id too: a command replaced under the card makes it stale instead of approving the new one + const bodyLines = firstRowIndex >= 0 ? lines.slice(titleIndex + 1, firstRowIndex) : []; + const body = bodyLines.map(cleanLine).filter((line) => line && line !== question && !isDivider(line) && !/ctrl\+e/i.test(line)).join("\n") || null; return finishPrompt("cmd", { kind: "approval", title, question, body, options: rows.map((row) => ({ label: row.label, description: null })), diff --git a/server/prompt.ts b/server/prompt.ts index 49cc98bb00..cb5b0914a8 100644 --- a/server/prompt.ts +++ b/server/prompt.ts @@ -263,7 +263,7 @@ function promptTailIsActive(prompt: ParsedPrompt, screen: string): boolean { } // Command Code's permission footer names its keys at the dialog's end and wraps on a narrow // pane: `↑/↓ navigate · enter select · ctrl+e explain · Run cmd --yolo to bypass all …` - if (prompt.responder === "cmd-question") return shown.slice(-3).some((line) => CMD_HINT_RE.test(line)); + if (prompt.responder === "cmd-question") return cmdFooterEnds(shown); return ends(/ctrl\+g to edit|shift\+tab to approve with this feedback/i); } /** @@ -277,6 +277,17 @@ const PI_MENU_HINT_AT_END_RE = new RegExp(`${PI_MENU_HINT_RE.source}$`, "i"); const PI_INPUT_HINT_AT_END_RE = new RegExp(`${PI_INPUT_HINT_RE.source}$`, "i"); /** Command Code's permission footer: `↑/↓ navigate · enter select · ctrl+e explain · Run cmd --yolo …` */ const CMD_HINT_RE = /↑\/↓ navigate.*enter select/i; +const CMD_FOOTER_WORDS = new Set(["·", "ctrl+e", "explain", "run", "cmd", "--yolo", "to", "bypass", "all", "permissions", "(docs", "↗)", "↗", "(docs↗)"]); +/** + * The footer ends the screen: after its key hint only the footer's own words may follow, wrapped or + * not. A line typed below a quoted or stale dialog (`❯ my next message`) means it is no live one. + */ +function cmdFooterEnds(shown: string[]): boolean { + const at = findLastIndex(shown, (line) => CMD_HINT_RE.test(line)); + if (at < 0 || shown.length - at > 3) return false; + const rest = [shown[at]!.replace(/^.*?enter select/i, ""), ...shown.slice(at + 1)].join(" "); + return rest.split(/\s+/).filter(Boolean).every((word) => CMD_FOOTER_WORDS.has(word.toLowerCase())); +} /** The same hint told from the end, so lines that follow it cannot complete a match of their own. */ const PI_MODEL_HINT_AT_END_RE = new RegExp(`${PI_MODEL_HINT_RE.source}$`, "i"); /** From fea1cbc7ebb36349876c7028d20683791778343a Mon Sep 17 00:00:00 2001 From: devswha Date: Tue, 20 Oct 2026 19:09:06 +0900 Subject: [PATCH 3/4] fix(cmd): hash every dialog line above the rows into the permission card's id The title is guessed by walking up from the question, so a command of several lines, a heredoc or a wrapped command left its earlier lines out of both title and body: a command replaced under an open card kept the card's id. The id now hashes every line above the rows back to the dialog's border or the screen's top; output changing above the dialog makes the card stale and the answer goes to the terminal. --- server/cmd.test.ts | 7 +++++++ server/dialogs/cmd.ts | 9 ++++++++- 2 files changed, 15 insertions(+), 1 deletion(-) diff --git a/server/cmd.test.ts b/server/cmd.test.ts index d33134b78d..2413d14b6a 100644 --- a/server/cmd.test.ts +++ b/server/cmd.test.ts @@ -162,6 +162,13 @@ describe("Command Code's permission prompt is a card", () => { expect(harmless?.body).toContain("printf harmless | head -5"); expect(replaced?.id).toBeString(); expect(replaced?.id).not.toBe(harmless?.id); + // a command of several lines: the lines above its last one are no title and no body + const multi = (first: string) => parseInteractivePrompt("cmd", run(`${first}\nrm -rf build`))?.id; + expect(multi("cd /tmp/scratch &&")).toBeString(); + expect(multi("cd / &&")).not.toBe(multi("cd /tmp/scratch &&")); + // two heredocs that end alike + const heredoc = (line: string) => parseInteractivePrompt("cmd", run(`cat > notes.txt < { diff --git a/server/dialogs/cmd.ts b/server/dialogs/cmd.ts index aa8671ebd6..d71c76e0c2 100644 --- a/server/dialogs/cmd.ts +++ b/server/dialogs/cmd.ts @@ -91,6 +91,13 @@ function parseCmdQuestion(screen: string): ParsedPrompt | null { // id too: a command replaced under the card makes it stale instead of approving the new one const bodyLines = firstRowIndex >= 0 ? lines.slice(titleIndex + 1, firstRowIndex) : []; const body = bodyLines.map(cleanLine).filter((line) => line && line !== question && !isDivider(line) && !/ctrl\+e/i.test(line)).join("\n") || null; + // The id hashes every line above the rows back to the dialog's border (or the screen's top), not + // only the title and body the card shows, which a guessed title cuts short: a command of several + // lines, a heredoc or a wrapped command must make the card stale when any of it changes. Output + // that changes above the dialog makes it stale too, and the answer goes to the terminal instead. + let top = firstRowIndex; + while (top > 0 && !isDivider(cleanLine(lines[top - 1]!))) top -= 1; + const dialog = lines.slice(top, firstRowIndex).map(cleanLine).filter(Boolean).join("\n"); return finishPrompt("cmd", { kind: "approval", title, question, body, options: rows.map((row) => ({ label: row.label, description: null })), @@ -103,7 +110,7 @@ function parseCmdQuestion(screen: string): ParsedPrompt | null { checkedOptionIndices: [], customMenuIndex: null, rejectWithEscapeIndex: null, - }); + }, { body: dialog }); } export const readers: readonly DialogReader[] = [ From 7919d859c9995bac71ccc4aed94aedbd09968b3e Mon Sep 17 00:00:00 2001 From: devswha Date: Tue, 20 Oct 2026 19:15:08 +0900 Subject: [PATCH 4/4] fix(cmd): hash the whole screen above the rows into the permission card's id Where the dialog starts is a guess: a rule inside a heredoc stopped the scan, and trimming dropped a heredoc's meaningful indentation, so a changed command could keep the card's id. The id now hashes the screen above the rows as drawn; any change there makes the card stale and the answer goes to the terminal. --- server/cmd.test.ts | 5 +++++ server/dialogs/cmd.ts | 12 +++++------- 2 files changed, 10 insertions(+), 7 deletions(-) diff --git a/server/cmd.test.ts b/server/cmd.test.ts index 2413d14b6a..1aca64b853 100644 --- a/server/cmd.test.ts +++ b/server/cmd.test.ts @@ -169,6 +169,11 @@ describe("Command Code's permission prompt is a card", () => { // two heredocs that end alike const heredoc = (line: string) => parseInteractivePrompt("cmd", run(`cat > notes.txt < parseInteractivePrompt("cmd", run(`${first}\ncat <<'EOF'\n${"\u2500".repeat(16)}\nEOF\nprintf done`))?.id; + expect(ruled("printf harmless")).not.toBe(ruled("rm -rf important-files")); + const indented = (delimiter: string) => parseInteractivePrompt("cmd", run(`cat <<'EOF'\n${delimiter}\nrm -rf important-files\n: <<'EOF'\nEOF\nprintf done`))?.id; + expect(indented(" EOF")).not.toBe(indented("EOF")); }); it("takes no dialog for a live one when a line typed below follows its footer", () => { diff --git a/server/dialogs/cmd.ts b/server/dialogs/cmd.ts index d71c76e0c2..2955f4359e 100644 --- a/server/dialogs/cmd.ts +++ b/server/dialogs/cmd.ts @@ -91,13 +91,11 @@ function parseCmdQuestion(screen: string): ParsedPrompt | null { // id too: a command replaced under the card makes it stale instead of approving the new one const bodyLines = firstRowIndex >= 0 ? lines.slice(titleIndex + 1, firstRowIndex) : []; const body = bodyLines.map(cleanLine).filter((line) => line && line !== question && !isDivider(line) && !/ctrl\+e/i.test(line)).join("\n") || null; - // The id hashes every line above the rows back to the dialog's border (or the screen's top), not - // only the title and body the card shows, which a guessed title cuts short: a command of several - // lines, a heredoc or a wrapped command must make the card stale when any of it changes. Output - // that changes above the dialog makes it stale too, and the answer goes to the terminal instead. - let top = firstRowIndex; - while (top > 0 && !isDivider(cleanLine(lines[top - 1]!))) top -= 1; - const dialog = lines.slice(top, firstRowIndex).map(cleanLine).filter(Boolean).join("\n"); + // The id hashes the whole screen above the rows as drawn, not only the title and body the card + // shows: where the dialog starts is a guess (a heredoc can hold a rule; its indentation is part of + // the command), so any change above the rows makes the card stale. Output that changes there while + // the dialog is open makes it stale too, and the answer goes to the terminal instead. + const dialog = lines.slice(0, firstRowIndex).join("\n"); return finishPrompt("cmd", { kind: "approval", title, question, body, options: rows.map((row) => ({ label: row.label, description: null })),