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
4 changes: 4 additions & 0 deletions changelog.d/cmd-agent-support.md
Original file line number Diff line number Diff line change
@@ -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.
1 change: 1 addition & 0 deletions docs/guide.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down
199 changes: 199 additions & 0 deletions server/cmd.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,199 @@
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();
});

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);
// 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 <<EOF\n${line}\nEOF`))?.id;
expect(heredoc("keep this")).not.toBe(heredoc("replace that"));
// a rule inside a heredoc is no dialog border, and a heredoc's indentation is part of the command
const ruled = (first: string) => 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", () => {
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");
});
151 changes: 151 additions & 0 deletions server/cmd.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,151 @@
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 } from "./herdr/client.ts";

/**
* Command Code's session store. One directory per project (the cwd), under
* `~/.commandcode/projects/<slug>`, where `<slug>` 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; }
}
}
}

type CmdTranscriptOptions = {
agent?: Awaited<ReturnType<typeof agentGet>> | null;
home?: string;
};

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);

// 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 candidate = (session?.agent === "cmd" && session.source === "herdr:cmd" && session.kind === "path"
&& typeof session.value === "string") ? session.value : null;
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<string, number> } | 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) {
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<string, number>();
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;
}
Loading
Loading