Run OpenAI Codex and Google Antigravity execution lanes from Claude Code with background event delivery, status tracking, and raw command protection.

A native Claude Code plugin that runs OpenAI Codex, Google Antigravity and Claude panel lanes.
Claude is the head. cdx keeps the books in one SQLite file, sandboxes every lane, and wakes the head when a lane needs it.
<img src="assets/demo.svg" alt="cdx spawning detached workers, checking status, and collecting reports" width="760">
cdx is a Claude Code plugin and a standalone CLI for OpenAI Codex and Google Antigravity lanes. Claude Code is the owner's liaison: it briefs outcomes, answers questions, arranges independent review, and lands. cdx records lane state, reports, questions, logs and token use, runs each lane's gate, and proves what it lands. SKILL.md is the head's playbook; this file is the operator manual.
cdx runs inside Claude Code as a function hooks module. At session start the mod registers tools under mcp__cdx__, the /lanes command, a live band above the prompt, and a two-second poll of the event table. The head spawns a lane with one tool call and ends its turn. cdx wakes the head when the lane finishes, asks a question, stalls, or hits an outage.
sequenceDiagram
participant Head as Claude Code head
participant Mod as cdx mod (in-process)
participant CLI as cdx CLI
participant Lane as sandboxed Codex or Antigravity lane
Head->>Mod: mcp__cdx__spawn { lane, outcome, files, acceptance, outOfScope, brief }
Mod->>CLI: cdx spawn --bg -
CLI->>Lane: detached round starts
Note over Head: turn ends, nothing waits
loop every 2 s
Mod->>CLI: cdx events --json --snapshot
end
Lane-->>CLI: question, report, stall, 503
CLI-->>Mod: wake event, digest of at most 5 lines
Mod-->>Head: [cdx] prompt when idle, tool context mid-turn
Head->>Mod: mcp__cdx__reply, review, land
| In the session | What it does |
|---|---|
mcp__cdx__spawn, resume, review, consult, panel | Start work. The brief travels as a tool field, never through the shell. |
mcp__cdx__reply, send, msg | Answer a question, steer a running lane, message a session. |
mcp__cdx__status, events, report, tail, questions, inbox, usage | Check in without waiting. |
mcp__cdx__land, close, kill, gate, gate-receipt, job, ask, doctor | Land, finish, stop, gate, run detached jobs, ask a code question, diagnose. |
[cdx] prompts and toasts | Wake events arrive as a prompt when the head is idle and as context on the next tool result mid-turn. |
/lanes | Opens a live Pane with lane details and recent transcript lines. Arguments forward to cdx. |
| Live band | This session's running lanes and jobs, refreshed every two seconds. The band is a table (NAME, KIND, ENGINE, EFFORT, STAGE, AGE, STEPS, FILES, NOW) with the stage coloured. EFFORT is the reasoning effort the lane's current round runs at. A narrow terminal drops EFFORT, then ENGINE, then KIND. |
There is no wait tool by design. The CLI keeps cdx wait for supervisors and people at a terminal.
You need Bun and at least one engine. Install and sign in to Codex CLI 0.159+ for the default gpt engine, or install and authorize Google Antigravity CLI (agy) for --engine gemini. cdx panel also needs the claude binary and macOS sandbox-exec.
git clone https://github.com/RedesignedRobot/cdx.git ~/.claude/skills/cdx && ln -s ~/.claude/skills/cdx/cdx.ts ~/.local/bin/cdx
cdx doctor --fix
cdx doctor --probe
Cloning into ~/.claude/skills/ loads the plugin in the next Claude Code session; the symlink makes cdx a terminal command. Works on macOS, Linux and WSL. The sandbox, ask and panel need macOS.
doctor checks engine binaries, login and usage, configuration, the Codex model catalog, function hooks, mod polling, the state database, stale review snapshots and idle worktrees. It reads <primary Codex home>/models_cache.json and fails codex models when model, thinkerModel or an alias target is missing; run codex update, then codex debug models. For Antigravity it checks agent files, loaded hooks and model availability; missing agy is a warning unless the config enables Gemini. --probe runs a short request through each installed engine, and starts a Codex thread with the real review parameters to check that a write to the checkout fails.
doctor --fix renders the Codex lane homes, installs the Gemini agents and hooks under ~/.gemini/config/, removes retired rules from config.json, repairs stale rounds, and removes stale snapshots and idle cdx worktrees. Existing engine processes continue unchanged; new rounds use the new homes. Reload the Claude plugin after updating cdx to expose new native tools.
ledger.json, which 10.0 never reads.cdx doctor --fix. Remove visibility.heartbeatMinutes and visibility.fileEdits from config.json; 10.0 refuses unknown keys.cdx migrate once./reload-plugins in each, before the first spawn. A session that still holds the 9.x mod calls the removed takeover tool, sends the old spawn schema that 10.0 refuses, polls with the 9.x cursor, and runs land with a 120 s timeout that kills it mid-merge.cdx migrate imports version 5 ledger lanes (closed ones straight into the archive), feed events, jobs and questions into state/cdx.db in one transaction, then moves the old files to state/legacy/ as the backup. It refuses a second run. Lanes already in the database win over the JSON copy. The lifecycle events started, active, progress, gate-started and report-written are dropped. Nothing migrates on read: until cdx migrate runs, cdx status prints a hint on stderr and cdx doctor warns. A round spec from 9.x lacks the rendered lane instructions and the runner refuses it; give such a lane a fresh round.
--engine is optional on spawn and review and defaults to gpt; omitting it prints cdx: engine gpt (default). Resume inherits the lane engine.
model, default gpt-6.1-sol. repoRouting defaults to {}. gpt-6-sol is retired: in config, on --model or stored on a lane it runs gpt-6.1-sol, and a resume moves the lane's thread to it.--supervisor lanes run thinkerModel, default gpt-6-astra. A child lane can never run gpt-6-astra; the refusal is checked on the resolved model before any account probe or process start.cdx: routing reserves Gemini for read-only work (consults, reviews, pre-reads); this work lane runs anyway. Gemini always runs gemini-3.8-flash-high at effort high, gets a 90-minute --max-runtime unless the flag says otherwise, and warns on briefs over 1,500 words.--engine on spawn and consult accepts only gpt and gemini.--model M takes an alias or a raw model id. The built-in aliases are astra and sol; the models config map adds more. A lane keeps its work model across resume. A review round records its model as reviewModel; the lane's model stays the work model. Before opening a GPT round, cdx checks the resolved model against the selected account's cached model catalog and refuses only when a complete catalog excludes it.
repoRouting maps absolute canonical repository paths to work models, for example { "/Users/me/code/app": { "model": "gpt-6-astra" } }. Linked worktrees and subdirectories use the main repository identity. An explicit --model or --engine gemini wins, a respawn keeps its stored model, and children never receive Astra from a route. Spawn prints the selection reason.
--supervisor lets a GPT lane drive its own children through spawn, resume, review, consult, send, reply, kill, close and gate. It needs ## Children in the brief with two or more child file sets; one file set belongs to a direct Sol lane. Native Codex subagents are disabled in every cdx-launched GPT session (owner ruling 2026-09-12), so every child is a tracked cdx lane with its own cost and gate.
# One bounded change: a Sol work lane with the brief contract
cdx spawn search-timeout --cd ~/code/myapp --worktree search-timeout --gate "bun test src/search" --bg - <<'EOF'
## Outcome
Search requests over 2 s return a partial page instead of a 504.
## Files
- src/search/handler.ts
- src/search/handler.test.ts
## Acceptance
handler.test.ts has a case where the index answers after 3 s and the response is 200 with partial=true.
## Out of scope
Index tuning, the export endpoint.
EOF
# A read-only question, no lane
cdx ask --cd ~/code/myapp "Where is the search deadline set?"
# Correct a running lane without waiting for the round to finish
cdx send search-timeout "The deadline lives in config/search.ts, not the handler."
# Review under any name; the proof binds to the tree
cdx review search-timeout-review --cd ~/code/wt/search-timeout --uncommitted
# Land: commit, gate the merge once, fast-forward, push, clean up
cdx land search-timeout
# Several green lanes, one gate and one push
cdx land --batch api-docs dead-code slow-query
A work brief given to cdx spawn, a respawn included, must carry four sections and a gate:
| Section | Holds |
|---|---|
## Outcome | What must be true when the lane is done |
## Files | The files the lane owns |
## Acceptance | The assertion that separates success from a plausible wrong answer |
## Out of scope | What the lane must not do or touch |
Any heading level counts and titles match loosely: "Acceptance criteria", "Out-of-scope" and "Non-goals" all count. A section with an empty body does not. The gate comes from --gate or the repository's .cdx-gate. --supervisor also needs ## Children (or "Child file sets") with two or more list items. Resume, consults and reviews are exempt. The refusal names every missing piece in one line:
work brief refused, missing "## Outcome", "## Files", ...; consults and reviews are exempt
The MCP spawn tool takes outcome, files[], acceptance, outOfScope and children[] as fields and renders them as sections ahead of brief.
--scope-policy ask|extend|stop, default extend, is stored on the lane and added to the ground rules at spawn and resume:
extend: the lane edits what the outcome needs and lists each file outside its Files section under ## Scope extensions in its report. The gate receipt records those items as scopeExtensions.stop: the lane does not edit outside Files; it names the file and the reason in its report and ends the round.ask: the lane asks the head.Under extend and stop, a cdx question that reads as a scope-permission question ("may I edit outside my files") gets an immediate answer from cdx. The question is stored as answered and the head is not woken. The classifier is keyword-based.
A no-op gate is refused when the repository has .cdx-gate, at spawn and at cdx gate: true, :, exit, exit 0, /bin/true, /usr/bin/true, and a bare echo ....
The brief's Ground rules: block is a pointer, not a copy: the role's lane home AGENTS.md, Project rules: read <repo>/.cdx-rules.md when that file exists, and the context digest for HEAD or one of the last 20 commits, with older digests listed as stale. A sample work lane went from 3,291 injected bytes to 156.
| Command | What it does | ||
|---|---|---|---|
cdx spawn <lane> "<brief>" | Start a work lane under the brief contract | ||
| `cdx resume <lane> --fix gate\ | review "<instructions>"` | Repair failed evidence at the same HEAD | |
cdx review <lane> | Review a diff read-only in a fresh session | ||
cdx consult <lane> "<question>" | Read-only advisor lane | ||
cdx panel <name> --cd D "<question>" | Astra, Sol and Claude Fable answer in the background; one merged report | ||
cdx context <repo> | Build the repo's context digest for HEAD as a job | ||
cdx shots grade <dir> --rubric F | Grade screenshots as one job; verdict.json and failed screens only | ||
cdx land <lane> / cdx land --batch <lane>... | Gate the merge result once (as a land-<lane> job), advance the base, push, clean up, close | ||
cdx gate <lane> "<cmd>" / --clear | Set or clear an inactive lane's gate | ||
cdx gate-receipt <lane> [--json] | Content proof for the latest work round | ||
cdx send <lane> "<text>" | Steer the active turn, or queue a follow-up turn | ||
cdx question "<question>" | Inside a lane: raise a QUESTION event and wait for the head | ||
cdx ask --cd /repo "<question>" | Synchronous read-only Gemini code answer, no lane; cannot grant approval | ||
cdx reply <lane> "<answer>" / cdx questions [lane] | Answer or list open questions | ||
| `cdx msg <lane\ | full-session-id> "<text>" / cdx inbox` | Message the head or a session; read messages | |
cdx events | Unread actionable events for the calling session | ||
cdx status | Open lanes with stage, steps, dirty files, timing and last action | ||
| `cdx wait <lane\ | job\ | panel>...` | Block until lanes, jobs or panels finish (terminals and supervisors only) |
cdx job <name> --cd D "<cmd>" | Detached shell job with one log and an event on exit | ||
cdx usage | Quota windows, observed burn, projections, account picks, outcome totals | ||
cdx tail, cdx report, cdx log, cdx feed | Read transcripts, reports, logs and recent events | ||
cdx kill, cdx close, cdx clean | Stop, close, prune | ||
cdx doctor, cdx migrate, cdx brief | Diagnose, import 9.x state, print the session brief (--head makes this session the head) |
cdx spawn <lane> [--engine gpt|gemini] [--model M] [--supervisor] [--scope-policy ask|extend|stop] [--account NAME] [--effort E] [--cd D] [--worktree P] [--test-runs N] [--bg] [--add-dir D]... [--schema F] [--image F]... [--gate CMD] [--pre CMD] [--max-runtime MIN] [--expect MIN] ("<brief>" | -)
cdx resume <lane> --fix gate|review [--effort E] [--test-runs N] [--bg] [--max-runtime MIN] [--expect MIN] ("<fix instructions>" | -)
cdx review <lane> [--engine gpt|gemini] [--model M] [--account NAME] [--effort E] [--cd D] [--bg] [--image F]... [--uncommitted | --base B | --commit SHA] [--scope "<files>"] ["<intent>" | -]
cdx consult <lane> [--engine gpt|gemini] [--supervisor] [--model M] [--account NAME] [--effort E] [--cd D] [--bg] [--image F]... ("<question>" | -)
cdx panel <name> --cd D [--pack F] ("<question>" | -)
cdx context <repo> [--model M]
cdx shots grade <dir> --rubric F [--engine gpt|gemini] [--model M] [--downscale]
cdx land <lane> | cdx land --batch <lane>...
cdx gate <lane> ("<cmd>" | --clear)
cdx gate-receipt <lane> [--json]
cdx send <lane> ("<text>" | -)
cdx question [--timeout MIN] "<question>" # inside a lane: head, raises QUESTION
cdx ask --cd /repo "<question>" # synchronous read-only code answer
cdx reply <lane> [--id SEQ] ("<answer>" | -)
cdx questions [lane]
cdx msg <lane|full-session-id> ("<text>" | -)
cdx inbox [-n N]
cdx events [--json] [--peek] [--snapshot]
cdx status [--all] [--json | --brief | --watch [--interval S]]
cdx wait <lane|job|panel>... [--timeout S] [--json] [--report]
cdx usage [--json] [--totals] | cdx usage --line
cdx tail <lane> [-n N] | cdx tail -f [lane]
cdx feed [-n N]
cdx report <lane> [round]
cdx log <lane> [round] [--transcript | --tools]
cdx kill <lane|job> ["note"]
cdx close <lane> [--remove-worktree | --keep-worktree] ["note" | -]
cdx job <name> --cd D ("<cmd>" | -) | cdx job
cdx clean [--days N]
cdx doctor [--fix] [--probe] [--days N]
cdx migrate
cdx brief
Every command taking free text accepts - to read it from stdin: spawn, resume, consult, review (intent), panel, send, reply, msg, job and close. An empty stdin fails with the command's usage line. msg, send, ask and reply replace CR and LF with spaces. Headless agy expands /skill-name ... at the start of a prompt, so a Gemini brief may open with a project skill the workspace ships under .agents/skills.
Inside a lane, workers cannot run spawn, resume, review, consult, panel, context, shots, land, kill, close, clean, gate, reply, job or migrate. A supervisor may run spawn, resume, review, consult, panel, kill, close, gate and reply on its own children, and land its children into its own branch.
Every lane runs sandboxed. cdx builds the writable roots once and hands them to each engine.
| Role | Writable | Notes |
|---|---|---|
| Codex work lane | lane cwd, each --add-dir, /tmp and TMPDIR, ${CDX_HOME}/state, ${CDX_HOME}/control, the spill dir logs/<lane>-r<round>.out, the repo's .codegraph dir | workspace-write, network on, .git read-only so lanes cannot commit |
| Codex review, consult, consult supervisor | TMPDIR and the reviewed tree's .codegraph/ only; nothing else in the checkout, and .codegraph/.gitignore stays read-only | cdx-review permissions profile selected through the default_permissions config override; commands and network still work |
| Gemini lane | the same roots, plus ~/.gemini/antigravity-cli, TMPDIR and /dev | agy runs under sandbox-exec, without --dangerously-skip-permissions |
| Gemini review | state, control and spill dirs, the round's partial report and progress log, TMPDIR and the reviewed tree's .codegraph/ except its .gitignore | Gemini hooks run inside agy and write them |
| Claude panel member | ~/.claude, ~/.claude.json, TMPDIR, /tmp/claude-<uid> | sandbox-exec; tools Read, Grep, Glob, Bash |
"fullAccess": true in config.json runs Codex work lanes and supervisors with danger-full-access: no seatbelt, so they reach the GPU, Metal compilers, ps and .git. Reviews and consults keep the read-only profile, because review proof depends on it. Lane rules still forbid commits; cdx land commits.
Nothing else under ~/.cdx is writable from a lane: not config.json, not hooks, not the cdx source. cdx ask uses the read-only Gemini profile in every context. Lane instructions route permissions and run approvals to cdx question, which raises the head’s QUESTION event; the brief’s testRuns grants runs up front. Gates run outside the sandbox in their snapshot.
Supervisors run from their own Codex home, <account>/cdx-supervisor, which holds rules/cdx.rules with one exec-policy rule, prefix_rule(pattern = ["cdx"], decision = "allow"). Codex runs a matching cdx ... call outside the sandbox, because Seatbelt does not nest and each child lane needs its own sandbox. Everything that call writes (specs, briefs, logs, reports, usage.json, git state for land) comes from that unsandboxed process. Codex skips the rule for commands with a redirect, $(...), an env assignment or a wildcard, so supervisors call cdx plainly and leave git writes to cdx. A double-quoted brief with backticks, or a '\'' splice, also misses the rule and runs sandboxed, so the supervisor rules tell it to single-quote every brief, gate and question and keep apostrophes out of them.
Every role gets the caps, reviews and supervisors included. Shell output over 4,096 bytes reaches the model as the first 2 KB and the last 1.5 KB, with a notice naming the total byte count and the spill file under logs/<lane>-r<round>.out/. Codex cuts through a PreToolUse hook that cdx trusts at thread start; Gemini through the pre-tool hook's overwrite field. Read-only Codex lanes cannot write the spill file and see the head and tail plus a note to narrow the command. Commands that invoke cdx are not wrapped.
Lanes have no MCP servers: Codex ignores hook rewrites and per-tool limits for MCP output, so codegraph runs through the shell as perl -e 'alarm 60; exec @ARGV' codegraph explore "<question>". On timeout (exit 142) or failure, lanes fall back to rg and file reads and say so. cdx looks for .codegraph/codegraph.db from the lane cwd up to the enclosing git checkout and never climbs past it into a parent index such as ~/code/.codegraph; a cwd outside any checkout gets no index. A linked worktree with no index of its own borrows the primary checkout's, and the runner, after worktree setup, adds the fact codegraph explore -p <primary> to the brief; answers then come from the primary, so lanes read the files they changed from the worktree. Codegraph opens its index read-write, so the index dir is writable for every lane role. Codex reviews and consults get it through the cdx-review permissions profile, which reads everywhere and writes only TMPDIR and the index dirs (including the resolved dir of a review snapshot's linked index, where SQLite puts its -wal and -shm files). Codex 0.156 fails every turn when a profile is selected through the thread or turn permissions field, so cdx sets it as the default_permissions config override. An account config could outrank that override, so after thread start or resume the runner refuses a review or consult whose thread is not on cdx-review or has any writable root beyond TMPDIR and the index dirs. It also refuses when a .codex/config.toml between the cwd and the checkout root sets permissions, because a trusted project config would widen the profile. The tracked .codegraph/.gitignore is read-only for every review (a Codex profile rule, an agy deny), so whatever a reviewer writes under .codegraph/ stays ignored by git; each review and consult round fingerprints that file before and after, and a change fails the round. A supervisor cannot open a child worktree's index either, so its rules say to review child trees with git and file reads. An index built at worktree setup misses later edits, so after cdx land a supervisor runs codegraph sync . in its own tree when that tree has its own index.
Lane shells get BUN_INSTALL_CACHE_DIR under TMPDIR, because the sandbox denies writes to ~/.bun/install/cache. Chromium dies in the Codex sandbox on a denied mach port unless it runs with --single-process; work lane rules say so, and sandboxed shells carry CODEX_SANDBOX=seatbelt for a launcher to test.
All GPT threads disable memories, plugins, apps, the skills catalogue and native subagents. GPT work lanes get model_auto_compact_token_limit (default 150000) and tool_output_token_limit (default 6000).
Standing lane rules live in a Codex lane home per role under each account home: cdx-lane, cdx-supervisor, cdx-review and cdx-review-supervisor. Each has an AGENTS.md cdx renders at launch, with the owner rules from config and the test-run limit included, and shares the account's auth, config and sessions without rewriting the interactive home. Gemini's static rules live in agents/cdx-lane/agent.md and agents/cdx-review/agent.md; Gemini briefs still inline
hooks/register.ts 400 lines1import type { BoxProps, ElementConstructor, EngineInterface, On, RenderElement, RenderSurface, TextProps, Timer } from "claude-code";
2import { blockingCdxCommand, blockingCdxRefusal, invokedRawEngine, nativeCdxCommand, nativeCdxRefusal, rawEngineRefusal } from "../guard";
3import {
4 afterPoll,
5 afterToolCall,
6 clearBuffer,
7 initialDeliveryState,
8 onPromptSubmit,
9 onSubmitRefused,
10 onTurnComplete,
11 onTurnStart,
12 type DeliveryState,
13 type PendingEvent,
14 type LiveSnapshot,
15 type BandCell,
16 bandTable,
17 orderedRows,
18} from "./delivery";
19import {
20 CDX_TOOL_PREFIX,
21 MAX_PROCESS_TIMEOUT_MS,
22 nativeToolResult, formatToolOutput,
23 runFromCwd,
24 SESSION_TOOLS,
25 TOOL_NAMES,
26 TOOLS,
27 TOOLS_BY_NAME,
28} from "./tools";
29
30const NATIVE_TOOLS: ReadonlySet<string> = new Set(TOOL_NAMES);
31
32let session = "";
33let root = "";
34let CDX: string[] = [];
35let surface: RenderSurface | null = null;
36let deliveryState: DeliveryState = initialDeliveryState();
37let pollInFlight = false;
38let pollTimer: Timer | undefined;
39let outputSequence = 0;
40const liveRef = { plugin: "cdx", key: "live" } as const;
41const pollerRef = { plugin: "cdx", key: "poller" } as const;
42const POLL_INTERVAL_MS = 2000;
43const INSTANCE = `${Date.now()}-${Math.random()}`;
44// Each refusal message is logged once; the poll runs every two seconds and
45// a repeated log would flood the transcript.
46const loggedRefusals = new Set<string>();
47
48const BUDGET_SPENT_NOTICE = "cdx: the engine's per-session prompt budget is spent (50 prompts); lane events no longer wake an idle head. "
49 + "They still land on the next tool result or typed prompt, and a fresh wake goes into the prompt box as a Tab suggestion. "
50 + "A new session restores wakes.";
51
52async function poll($: EngineInterface) {
53 if (pollInFlight || !session || !root) {
54 return;
55 }
56 pollInFlight = true;
57 try {
58 const eventsResult = await $.process.run(CDX.concat(["events", "--json", "--snapshot"]), {
59 env: { CLAUDE_CODE_SESSION_ID: session },
60 cwd: root,
61 });
62
63 let incomingEvents: PendingEvent[] = [];
64 let snapshot: LiveSnapshot | undefined;
65 if (eventsResult.exitCode === 0 && eventsResult.stdout.trim()) {
66 try {
67 const parsed = JSON.parse(eventsResult.stdout);
68 if (parsed && Array.isArray(parsed.events)) {
69 incomingEvents = parsed.events;
70 }
71 if (parsed && Array.isArray(parsed.rows) && typeof parsed.now === "number") {
72 snapshot = { rows: parsed.rows, now: parsed.now };
73 }
74 } catch {
75 // ignore malformed JSON
76 }
77 }
78 if (snapshot) {
79 await $.state.set(liveRef, snapshot);
80 }
81
82 // Everything the submit would carry, kept so a refused submit can put it
83 // back. The submit is issued before any await so no turn can start in
84 // between; it is not awaited because the prompt runs when the session is
85 // idle and the poll must not wait for that.
86 const drained = [...deliveryState.pending, ...incomingEvents];
87 const outcome = afterPoll(deliveryState, incomingEvents);
88 deliveryState = outcome.state;
89
90 if (outcome.submit) {
91 $.prompt.submit(outcome.submit).catch(async (error: unknown) => {
92 const message = error instanceof Error ? error.message : String(error);
93 deliveryState = onSubmitRefused(deliveryState, drained, message);
94 if (loggedRefusals.has(message)) return;
95 loggedRefusals.add(message);
96 await $.ui.log(deliveryState.budgetSpent
97 ? BUDGET_SPENT_NOTICE
98 : `cdx: prompt not submitted, events kept for the next tool result: ${message}`);
99 });
100 }
101
102 if (outcome.suggest && surface !== null) {
103 await $.prompt.suggest(outcome.suggest).catch(() => undefined);
104 }
105
106 if (surface !== null) {
107 for (const toastText of outcome.toasts) {
108 await $.ui.toast(toastText, { timeoutMs: 8000 });
109 }
110 }
111 } finally {
112 pollInFlight = false;
113 }
114}
115
116// A hot reload runs this module afresh with every variable above empty, and
117// session.start does not always fire again, so each hook that needs the
118// session context loads it here. The engine may not drop the old instance's
119// timer either: the newest instance claims the poller in $.state and an
120// older timer stops at its next tick.
121async function ensure($: EngineInterface) {
122 if (!session) session = await $.session.id();
123 if (root) return;
124 root = $.plugin.root;
125 CDX = ["bun", `${root}/cdx.ts`];
126 if (surface === null) surface = (await $.session.surfaces())[0] ?? null;
127 await startPolling($);
128}
129
130// The host holds $.state for the session, so after a /clear or /resume the
131// session the process moved to has no claim and the running timer would
132// read that as a newer instance and stop. That hook starts the timer again.
133async function startPolling($: EngineInterface) {
134 pollTimer?.cancel();
135 const timer = $.clock.every(POLL_INTERVAL_MS, async () => {
136 if ((await $.state.get(pollerRef)).value !== INSTANCE) return timer.cancel();
137 await ensure($);
138 await poll($);
139 });
140 pollTimer = timer;
141 await $.state.set(pollerRef, INSTANCE);
142}
143
144// The poll drains the feed every two seconds into the buffer, so the CLI
145// alone would answer "nothing" while wake events sit in memory. The tool
146// answers with the buffer first, then whatever the feed still held, and
147// empties the buffer so the after-hook does not deliver it a second time.
148function eventsToolResult(exitCode: number, stdout: string, stderr: string): string {
149 const buffered = deliveryState.pending.map((e) => e.text);
150 deliveryState = clearBuffer(deliveryState);
151 let fresh: string[] = [];
152 if (exitCode === 0 && stdout.trim()) {
153 try {
154 const parsed = JSON.parse(stdout);
155 if (parsed && Array.isArray(parsed.events)) fresh = parsed.events.map((e: PendingEvent) => e.text);
156 } catch {
157 // malformed JSON: report the raw output below
158 }
159 }
160 if (exitCode !== 0) return [...buffered, stdout].join("\n");
161 const lines = [...buffered, ...fresh];
162 return lines.length ? lines.join("\n") : "no pending events";
163}
164
165function bandLine(Box: ElementConstructor<BoxProps>, Text: ElementConstructor<TextProps>, cells: BandCell[]): RenderElement {
166 return Box({ flexDirection: "row", children: cells.map((cell) =>
167 Text({ ...(cell.color ? { color: cell.color } : {}), ...(cell.bold ? { bold: true } : {}), ...(cell.dim ? { dimColor: true } : {}),
168 wrap: "truncate", children: cell.text })) });
169}
170
171export function register(on: On) {
172 on("ui.render", { component: "AbovePrompt" }, async ($, e, next) => {
173 const { value } = await $.state.get(liveRef);
174 if (!value?.rows.length || e.props.hasSurvey) return next(e);
175 const { Box, Text } = $.ui.resolve(e);
176 // The margin is the blank line between the chat and the band.
177 return Box({ flexDirection: "column", marginTop: 1, children: bandTable(orderedRows(value.rows), value.now, e.props.bodyColumns)
178 .map((cells) => bandLine(Box, Text, cells)) });
179 });
180
181 on("ui.render", { component: "Pane" }, async ($, e, next) => {
182 if (e.requestId !== "cdx-lanes") return next(e);
183 const { value } = await $.state.get(liveRef);
184 const { Box, Text } = $.ui.resolve(e);
185 const rows = orderedRows(value?.rows ?? []);
186 if (!rows.length) return Box({ flexDirection: "column", children: [Text({ children: "No running lanes or jobs" })] });
187 const [header, ...lines] = bandTable(rows, value?.now ?? Date.now(), e.props.bodyColumns);
188 return Box({ flexDirection: "column", children: [bandLine(Box, Text, header!), ...rows.flatMap((row, index) => [
189 bandLine(Box, Text, lines[index]!),
190 ...(row.transcript ?? []).map((text) => Text({ dimColor: true, wrap: "truncate",
191 children: ` ${Array.from(text).slice(0, Math.max(0, e.props.bodyColumns - 3)).join("")}` })),
192 ])] });
193 });
194
195 on("session.start", async ($, e, next) => {
196 surface = e.surface;
197 await ensure($);
198
199 for (const tool of TOOLS) {
200 await $.tool.register({
201 name: tool.name,
202 description: tool.description,
203 inputSchema: tool.inputSchema,
204 });
205 }
206
207 // /cdx is the user's skill, so the engine refuses that name. A refused
208 // command must never stop the tools and the poll from starting.
209 try {
210 await $.command.register({
211 name: "lanes",
212 description: "Open live lanes, or forward arguments to cdx",
213 });
214 } catch (error) {
215 await $.ui.log(`cdx: /lanes not registered: ${error instanceof Error ? error.message : String(error)}`);
216 }
217
218 // cdx elects the head only from sessions that drove it. A person at the
219 // prompt claims the wakes at start; a -p run or an SDK host never does.
220 const briefResult = await $.process.run(CDX.concat(e.isInteractive ? ["brief", "--head"] : ["brief"]), {
221 env: { CLAUDE_CODE_SESSION_ID: session },
222 cwd: root,
223 });
224 const briefText = briefResult.stdout.trim();
225 if (briefText) {
226 if (surface !== null) {
227 await $.ui.log(briefText);
228 }
229 deliveryState = {
230 ...deliveryState,
231 pending: [...deliveryState.pending, { text: briefText, wake: false }],
232 };
233 }
234
235 return next(e);
236 });
237
238 // A subagent's loop raises its own turn events with agentId set. Only the
239 // head's turn decides whether the session is idle.
240 on("turn.start", async ($, e, next) => {
241 await ensure($);
242 if (!(e as { agentId?: string }).agentId) deliveryState = onTurnStart(deliveryState);
243 return next(e);
244 });
245
246 on("turn.complete", async ($, e, next) => {
247 if (!e.agentId) deliveryState = onTurnComplete(deliveryState);
248 return next(e);
249 });
250
251 on("command.run", { command: "lanes" }, async ($, e) => {
252 await ensure($);
253 if (!e.args.trim()) {
254 const opened = await $.ui.open({ id: "cdx-lanes", title: "Lanes", focus: true });
255 return { text: opened.isPlaced ? "Lanes pane opened" : `Lanes pane unavailable: ${opened.reason ?? "surface refused"}` };
256 }
257 const args = e.args.trim().split(/\s+/);
258 const res = await $.process.run(CDX.concat(args), {
259 env: { CLAUDE_CODE_SESSION_ID: session },
260 cwd: root,
261 });
262 if (res.exitCode !== 0) {
263 return { text: res.stderr || res.stdout || `exit ${res.exitCode}` };
264 }
265 return { text: res.stdout };
266 });
267
268 on("command.run", { command: ["clear", "resume"] }, async ($, e, next) => {
269 const result = await next(e);
270 await ensure($);
271 session = await $.session.id();
272 deliveryState = clearBuffer(deliveryState);
273 await startPolling($);
274 // The user typed /clear or /resume here, so this session keeps the head
275 // under its new id.
276 const briefResult = await $.process.run(CDX.concat(["brief", "--head"]), {
277 env: { CLAUDE_CODE_SESSION_ID: session },
278 cwd: root,
279 });
280 const briefText = briefResult.stdout.trim();
281 if (briefText) {
282 if (surface !== null) {
283 await $.ui.log(briefText);
284 }
285 deliveryState = {
286 ...deliveryState,
287 pending: [{ text: briefText, wake: false }],
288 };
289 }
290 return result;
291 });
292
293 on("tool.call", { tool: "Bash" }, async ($, e, next) => {
294 const command = typeof (e as { command?: unknown }).command === "string"
295 ? (e as { command: string }).command
296 : "";
297 const engine = invokedRawEngine(command);
298 if (engine) {
299 return { deny: rawEngineRefusal(engine) };
300 }
301 const blocking = blockingCdxCommand(command);
302 if (blocking) {
303 return { deny: blockingCdxRefusal(blocking) };
304 }
305 const native = nativeCdxCommand(command, NATIVE_TOOLS);
306 if (native) {
307 return { deny: nativeCdxRefusal(native) };
308 }
309 return next(e);
310 });
311
312 on("tool.call", async ($, e, next) => {
313 await ensure($);
314 const result = await next(e);
315 if (result && "deny" in result && result.deny !== undefined) {
316 return result;
317 }
318 if (e.agentId) {
319 return result;
320 }
321 const outcome = afterToolCall(deliveryState);
322 deliveryState = outcome.state;
323 if (!outcome.context) {
324 return result;
325 }
326 return {
327 ...result,
328 context: [...(result.context ?? []), outcome.context],
329 };
330 });
331
332 on(
333 "tool.call",
334 { tool: /^mcp__cdx__/ },
335 async ($, e) => {
336 const toolName = e.tool.startsWith(CDX_TOOL_PREFIX)
337 ? e.tool.slice(CDX_TOOL_PREFIX.length)
338 : e.tool;
339 const def = TOOLS_BY_NAME.get(toolName);
340 if (!def) {
341 return { isError: true, result: `unknown tool ${e.tool}` };
342 }
343 await ensure($);
344 if (!session && SESSION_TOOLS.has(toolName)) {
345 return { isError: true, result: `cdx ${toolName}: the engine gave no session id, so the lane or job would have no owner; retry, or run /clear` };
346 }
347 const toolInput = (e as { input?: Record<string, unknown> }).input ?? (e as Record<string, unknown>);
348 let runSpec;
349 try { runSpec = def.run(toolInput); }
350 catch (error) { return { isError: true, result: String(error) }; }
351 // Tool commands run where the head works, not in the plugin root: a
352 // spawn --worktree without cd cuts from the caller's directory, and a
353 // relative cd resolves against it.
354 const procInit: {
355 env: Record<string, string>;
356 cwd: string;
357 stdin?: string;
358 timeoutMs: number;
359 } = {
360 env: { CLAUDE_CODE_SESSION_ID: session },
361 cwd: await $.session.cwd(),
362 timeoutMs: runSpec.timeoutMs ?? MAX_PROCESS_TIMEOUT_MS,
363 };
364 if (runSpec.stdin !== undefined) {
365 procInit.stdin = runSpec.stdin;
366 }
367 let res;
368 try {
369 res = await runFromCwd(procInit.cwd, root, (path) => $.fs.exists(path),
370 (cwd) => $.process.run(CDX.concat(runSpec.argv), { ...procInit, cwd }));
371 } catch (error) {
372 return { isError: true, result: `cdx ${runSpec.argv[0]}: ${error instanceof Error ? error.message : String(error)}` };
373 }
374 const stdout = toolName === "events" ? eventsToolResult(res.exitCode, res.stdout, res.stderr) : res.stdout;
375 const text = await formatToolOutput(res.exitCode, stdout, res.stderr, async (content) => {
376 const home = await $.env.get("CDX_HOME") || `${await $.env.get("HOME")}/.cdx`;
377 const path = `${home}/logs/native-${session}-${Date.now()}-${++outputSequence}.log`;
378 await $.fs.write(path, content);
379 return path;
380 });
381 return nativeToolResult(res.exitCode, text);
382 });
383
384 on("prompt.submit", async ($, e, next) => {
385 await ensure($);
386 if (e.origin && e.origin.kind === "plugin" && e.origin.name === "cdx") {
387 return next(e);
388 }
389 const outcome = onPromptSubmit(deliveryState);
390 deliveryState = outcome.state;
391 if (outcome.context) {
392 return next({
393 ...e,
394 context: [...(e.context ?? []), outcome.context],
395 });
396 }
397 return next(e);
398 });
399}
400guard.ts 425 lines1// Raw engine guard: recognises a shell command that runs Codex or Antigravity
2// directly instead of through cdx. Pure; shared by the Claude Code hooks
3// module and by tests. No Bun or Node API here.
4
5const WORK_VERBS = new Set(["e", "exec", "review", "resume", "fork", "cloud", "apply"]);
6const CONTROL_WORDS = new Set(["if", "then", "elif", "else", "while", "until", "for", "do", "!", "{"]);
7const WRAPPERS = new Set(["command", "exec", "env", "nice", "nohup", "sudo", "time"]);
8const ASSIGNMENT = /^[A-Za-z_][A-Za-z0-9_]*=/;
9const CODEX_GLOBAL_VALUE_OPTIONS = new Set([
10 "-c", "--config", "--enable", "--disable", "--remote", "--remote-auth-token-env",
11 "-i", "--image", "-m", "--model", "--local-provider", "-p", "--profile",
12 "-s", "--sandbox", "-C", "--cd", "--add-dir", "-a", "--ask-for-approval",
13]);
14const CODEX_TERMINAL_OPTIONS = new Set(["-h", "--help", "-V", "--version", "--"]);
15const AGY_HEADLESS_OPTIONS = new Set([
16 "--print", "-p", "--prompt", "--prompt-interactive", "-i", "--input-format",
17 "--continue", "-c", "--conversation",
18]);
19
20export type RawEngine = "gpt" | "gemini";
21
22function stripQuotedSegments(command: string): string {
23 return command.replace(/'[^']*'|"(?:\\.|[^"\\])*"/gs, "");
24}
25
26interface Heredoc {
27 delimiter: string;
28 quoted: boolean;
29}
30
31function heredocIn(line: string): Heredoc | undefined {
32 let quote = "";
33 for (let index = 0; index < line.length; index += 1) {
34 const char = line[index]!;
35 if (quote) {
36 if (quote === '"' && char === "\\") index += 1;
37 else if (char === quote) quote = "";
38 continue;
39 }
40 if (char === "'" || char === '"') {
41 quote = char;
42 continue;
43 }
44 if (char !== "<" || line[index + 1] !== "<" || line[index + 2] === "<") continue;
45
46 index += 2;
47 if (line[index] === "-") index += 1;
48 while (/\s/.test(line[index] ?? "")) index += 1;
49 const delimiterQuote = line[index] === "'" || line[index] === '"' ? line[index]! : "";
50 if (delimiterQuote) {
51 const end = line.indexOf(delimiterQuote, index + 1);
52 if (end < 0) return undefined;
53 return { delimiter: line.slice(index + 1, end), quoted: true };
54 }
55 const delimiter = /^[^\s;&|<>]+/.exec(line.slice(index))?.[0];
56 return delimiter ? { delimiter, quoted: false } : undefined;
57 }
58 return undefined;
59}
60
61function stripHeredocBodies(command: string, original: string): string {
62 const kept: string[] = [];
63 const lines = command.split("\n");
64 const originalLines = original.split("\n");
65 let heredoc: Heredoc | undefined;
66 for (let index = 0; index < lines.length; index += 1) {
67 const line = lines[index]!;
68 const originalLine = originalLines[index] ?? line;
69 if (heredoc) {
70 if (originalLine.trim() === heredoc.delimiter) {
71 heredoc = undefined;
72 } else if (!heredoc.quoted) {
73 for (const match of originalLine.matchAll(/\$\(([^()]*)\)|`([^`]*)`/g)) kept.push(match[0]!);
74 }
75 continue;
76 }
77 kept.push(line);
78 heredoc = heredocIn(originalLine);
79 }
80 return kept.join("\n");
81}
82
83function codexWorkVerb(words: string[], binaryIndex: number): boolean {
84 let index = binaryIndex + 1;
85 while (index < words.length) {
86 const word = words[index]!;
87 if (CODEX_TERMINAL_OPTIONS.has(word)) return false;
88 if (!word.startsWith("-")) return WORK_VERBS.has(word);
89 index += 1;
90 if (!word.includes("=") && CODEX_GLOBAL_VALUE_OPTIONS.has(word)) index += 1;
91 }
92 return false;
93}
94
95function skipRedirections(words: string[], start: number): number {
96 let index = start;
97 for (;;) {
98 const match = /^\d*(?:<>|>>?|<<?|>&|<&)(.*)$/.exec(words[index] ?? "");
99 if (!match) return index;
100 index += 1;
101 if (!match[1]) index += 1;
102 }
103}
104
105function skipPrefixes(words: string[], start: number): number {
106 let index = start;
107 for (;;) {
108 const before = index;
109 index = skipRedirections(words, index);
110 while (ASSIGNMENT.test(words[index] ?? "")) index += 1;
111 if (index === before) return index;
112 }
113}
114
115// The index of the binary a segment runs, past control words, assignments,
116// redirections, and wrappers such as env, nice, or sudo.
117function commandStart(words: string[]): number {
118 let index = 0;
119 while (CONTROL_WORDS.has(words[index] ?? "")) index += 1;
120 index = skipPrefixes(words, index);
121
122 while (WRAPPERS.has((words[index] ?? "").split("/").at(-1) ?? "")) {
123 const wrapper = (words[index] ?? "").split("/").at(-1);
124 index += 1;
125 if (wrapper === "env") {
126 while ((words[index] ?? "").startsWith("-")) {
127 const option = words[index]!;
128 index += 1;
129 if (["-u", "--unset", "-C", "--chdir"].includes(option)) index += 1;
130 }
131 while (ASSIGNMENT.test(words[index] ?? "")) index += 1;
132 } else if (wrapper === "sudo") {
133 while ((words[index] ?? "").startsWith("-")) {
134 const option = words[index]!;
135 index += 1;
136 if (["-C", "-g", "-h", "-p", "-r", "-t", "-u", "--chdir", "--group", "--host", "--prompt", "--role", "--type", "--user"].includes(option)) index += 1;
137 }
138 } else if (wrapper === "nice") {
139 if (["-n", "--adjustment"].includes(words[index] ?? "")) index += 2;
140 } else {
141 while ((words[index] ?? "").startsWith("-")) index += 1;
142 }
143 index = skipPrefixes(words, index);
144 }
145 return index;
146}
147
148function invokedRawEngineIn(command: string): RawEngine | undefined {
149 for (const match of command.matchAll(/\$\(([^()]*)\)|`([^`]*)`/gs)) {
150 const nested = invokedRawEngineIn(match[1] ?? match[2] ?? "");
151 if (nested) return nested;
152 }
153 const withoutArrayData = command.replace(/\b[A-Za-z_][A-Za-z0-9_]*=\([^)]*\)/gs, "");
154 for (const segment of withoutArrayData.split(/[;&|()`\n]+/)) {
155 const words = segment.trim().split(/\s+/).filter(Boolean);
156 const index = commandStart(words);
157
158 const binary = (words[index] ?? "").split("/").at(-1);
159 if (binary === "codex" && codexWorkVerb(words, index)) return "gpt";
160 if (binary === "agy") {
161 const headless = words.slice(index + 1).some((word) => AGY_HEADLESS_OPTIONS.has(word.split("=")[0]!));
162 if (headless) return "gemini";
163 }
164 }
165 return undefined;
166}
167
168// Which engine a shell command would run directly, if any. Quoted text and
169// heredoc bodies are ignored on the first pass; a second pass with quotes and
170// backslash escapes collapsed catches agy "--print=x" and \agy --print=x.
171export function invokedRawEngine(command: string): RawEngine | undefined {
172 const unquoted = stripQuotedSegments(command);
173 const collapsed = command.replace(/\\(.)/g, "$1").replace(/["']/g, "");
174 return invokedRawEngineIn(stripHeredocBodies(unquoted, command))
175 ?? invokedRawEngineIn(stripHeredocBodies(collapsed, command));
176}
177
178export function rawEngineRefusal(engine: RawEngine): string {
179 return engine === "gemini"
180 ? "Use cdx --engine gemini for Antigravity work. Run 'cdx help'."
181 : "Use cdx for Codex work. Run 'cdx help'.";
182}
183
184// The head of a Claude Code session must never block on a lane or job (owner
185// ruling 2026-09-15): the cdx mod wakes it with a [cdx] event. These are the
186// shell shapes that block anyway.
187export type BlockingCdx = "wait" | "status --watch" | "poll loop" | "sleep chain" | "tail -f";
188
189const POLLING_SUBCOMMANDS = new Set([
190 "status", "events", "report", "brief", "log", "tail", "questions", "feed",
191]);
192
193// The cdx subcommand a segment runs, as `cdx ...` or `bun .../cdx.ts ...`.
194function cdxInvocation(words: string[], index: number): { subcommand: string; args: string[] } | undefined {
195 const binary = (words[index] ?? "").split("/").at(-1) ?? "";
196 let next = index + 1;
197
198 if (binary === "bun" || binary === "node") {
199 while ((words[next] ?? "").startsWith("-")) next += 1;
200 if (words[next] === "run") {
201 next += 1;
202 while ((words[next] ?? "").startsWith("-")) next += 1;
203 }
204 const target = (words[next] ?? "").split("/").at(-1) ?? "";
205 if (target !== "cdx.ts" && target !== "cdx") return undefined;
206 next += 1;
207 } else if (binary === "bunx" || binary === "npx") {
208 while ((words[next] ?? "").startsWith("-")) next += 1;
209 const target = (words[next] ?? "").split("/").at(-1) ?? "";
210 if (target !== "cdx" && target !== "cdx.ts") return undefined;
211 next += 1;
212 } else if (binary !== "cdx" && binary !== "cdx.ts") {
213 return undefined;
214 }
215
216 while ((words[next] ?? "").startsWith("-")) {
217 next += ["-C", "--cd", "--cwd"].includes(words[next]!) ? 2 : 1;
218 }
219 const subcommand = words[next];
220 return subcommand === undefined ? undefined : { subcommand, args: words.slice(next + 1) };
221}
222
223function isStatusWatch(invocation: { subcommand: string; args: string[] }): boolean {
224 if (invocation.subcommand !== "status") return false;
225 return invocation.args.some((arg) => arg === "--watch" || arg.startsWith("--watch=") || arg === "-w" || arg.startsWith("-w="));
226}
227
228function isTailFollow(invocation: { subcommand: string; args: string[] }): boolean {
229 if (invocation.subcommand !== "tail") return false;
230 return invocation.args.some((arg) => arg === "-f" || arg === "--follow" || arg.startsWith("--follow=") || /^-[a-zA-Z]*f/.test(arg));
231}
232
233function hasFollowFlag(args: string[]): boolean {
234 return args.some((arg) => arg === "-f" || arg === "-F" || arg === "--follow" || arg.startsWith("--follow=") || /^-[a-zA-Z]*[fF]/.test(arg));
235}
236
237function isCdxLogPath(arg: string): boolean {
238 if (/(?:\.cdx|CDX_HOME|CDX_STATE_HOME)[/\\](?:logs[/\\])?.*(?:\.log|\.jsonl)\b/i.test(arg)) return true;
239 if (/(?:^|[/\\])\.cdx[/\\]logs\b/i.test(arg)) return true;
240 if (arg === "__CDX_LOG__" || /\$\{(?:CDX_HOME|CDX_STATE_HOME)\}[/\\]logs[/\\]/.test(arg)) return true;
241 return false;
242}
243
244function watchCdxTarget(words: string[], index: number): { subcommand: string; args: string[] } | undefined {
245 let next = index + 1;
246 while (next < words.length) {
247 const word = words[next]!;
248 if (word === "-n" || word === "--interval") {
249 next += 2;
250 continue;
251 }
252 if (word.startsWith("-")) {
253 next += 1;
254 continue;
255 }
256 break;
257 }
258 return cdxInvocation(words, next);
259}
260
261type LoopKind = "while" | "until" | "for-finite-batch" | "for-poll";
262
263function loopKindAtStart(words: string[]): LoopKind | undefined {
264 for (let i = 0; i < words.length; i += 1) {
265 const word = words[i]!;
266 if (word === "while") return "while";
267 if (word === "until") return "until";
268 if (word === "for") {
269 const items = words.slice(i + 3);
270 if (words[i + 2] === "in" && items.length > 0 && items.every((item) => !/[$`{}]/.test(item))) {
271 return "for-finite-batch";
272 }
273 return "for-poll";
274 }
275 if (!CONTROL_WORDS.has(word) && !ASSIGNMENT.test(word)) break;
276 }
277 return undefined;
278}
279
280function hasDoneKeywordAtStart(words: string[]): boolean {
281 for (let i = 0; i < words.length; i += 1) {
282 const word = words[i]!;
283 if (word === "done") return true;
284 if (!CONTROL_WORDS.has(word) && !ASSIGNMENT.test(word)) break;
285 }
286 return false;
287}
288
289function isCdxLogSubstitution(sub: string): boolean {
290 return /\bcdx(?:\.ts)?\s+log\b/.test(sub);
291}
292
293function extractSubstitutions(text: string): string[] {
294 const result: string[] = [];
295 for (const match of text.matchAll(/\$\(([^()]*)\)|(?:\\`|`)([^`]*?)(?:\\`|`)/gs)) {
296 const inner = (match[1] ?? match[2] ?? "").replace(/^\\+/, "").replace(/\\+$/, "").trim();
297 if (inner) result.push(inner);
298 }
299 return result;
300}
301
302function replaceUnquotedSubstitutions(text: string): string {
303 return text.replace(/\$\(([^()]*)\)|(?:\\`|`)([^`]*?)(?:\\`|`)/gs, (_, p1, p2) => {
304 const inner = (p1 ?? p2 ?? "").replace(/^\\+/, "").replace(/\\+$/, "").trim();
305 if (!inner) return " ";
306 return isCdxLogSubstitution(inner) ? ` __CDX_LOG__ ; ${inner} ; ` : ` ; ${inner} ; `;
307 });
308}
309
310function stripQuotedPreservingSubstitutions(command: string): string {
311 const withoutSingle = command.replace(/'([^']*)'/gs, (_, content: string) => isCdxLogPath(content) ? " __CDX_LOG__ " : " ");
312 const withoutDouble = withoutSingle.replace(/"((?:\\.|[^"\\])*)"/gs, (_, content: string) => {
313 const subs = extractSubstitutions(content);
314 if (subs.length > 0) {
315 const hasLog = subs.some(isCdxLogSubstitution);
316 const prefix = hasLog ? " __CDX_LOG__ ; " : " ";
317 return `${prefix} ; ${subs.join(" ; ")} ; `;
318 }
319 return isCdxLogPath(content) ? " __CDX_LOG__ " : " ";
320 });
321 return replaceUnquotedSubstitutions(withoutDouble);
322}
323
324export function blockingCdxCommand(command: string): BlockingCdx | undefined {
325 const clean = stripHeredocBodies(stripQuotedPreservingSubstitutions(command), command);
326 const loopStack: LoopKind[] = [];
327 let cdxInLoop = false;
328 let hasSleepOutsideLoop = false;
329 let hasPollingOutsideLoop = false;
330
331 for (const segment of clean.split(/[;&|()\n]+/)) {
332 const words = segment.trim().split(/\s+/).filter(Boolean);
333 if (words.length === 0) continue;
334
335 const loopKind = loopKindAtStart(words);
336 if (loopKind) {
337 loopStack.push(loopKind);
338 }
339
340 const start = commandStart(words);
341 const binary = (words[start] ?? "").split("/").at(-1) ?? "";
342
343 if (binary === "watch") {
344 const target = watchCdxTarget(words, start);
345 if (target) {
346 if (isStatusWatch(target) || target.subcommand === "status") return "status --watch";
347 if (target.subcommand === "wait") return "wait";
348 if (POLLING_SUBCOMMANDS.has(target.subcommand)) return "poll loop";
349 }
350 } else if (binary === "tail") {
351 const tailArgs = words.slice(start + 1);
352 if (hasFollowFlag(tailArgs)) {
353 if (tailArgs.some(isCdxLogPath) || (/cdx\s+log\b/.test(command) && tailArgs.some((arg) => arg.includes("$")))) {
354 return "tail -f";
355 }
356 }
357 } else if (binary === "sleep") {
358 if (loopStack.length === 0) {
359 hasSleepOutsideLoop = true;
360 }
361 } else {
362 const invocation = cdxInvocation(words, start);
363 if (invocation) {
364 if (invocation.subcommand === "wait") return "wait";
365 if (isStatusWatch(invocation)) return "status --watch";
366 if (isTailFollow(invocation)) return "tail -f";
367 if (loopStack.length > 0) {
368 const isFiniteBatch = loopStack.every((k) => k === "for-finite-batch")
369 && (!POLLING_SUBCOMMANDS.has(invocation.subcommand) || invocation.subcommand === "report" || invocation.subcommand === "brief");
370 if (!isFiniteBatch) {
371 cdxInLoop = true;
372 }
373 } else if (POLLING_SUBCOMMANDS.has(invocation.subcommand)) {
374 hasPollingOutsideLoop = true;
375 }
376 }
377 }
378
379 if (hasDoneKeywordAtStart(words)) {
380 loopStack.pop();
381 }
382 }
383
384 if (cdxInLoop) return "poll loop";
385 if (hasSleepOutsideLoop && hasPollingOutsideLoop) return "sleep chain";
386 return undefined;
387}
388
389export function blockingCdxRefusal(kind: BlockingCdx): string {
390 const what = kind === "wait"
391 ? "cdx wait"
392 : kind === "status --watch"
393 ? "cdx status --watch"
394 : kind === "sleep chain"
395 ? "a sleep chain polling cdx"
396 : kind === "tail -f"
397 ? "tail -f on cdx logs"
398 : "a shell loop polling cdx";
399 return `${what} blocks the head; the head never blocks on a lane or job (owner ruling 2026-09-15). `
400 + "End your turn: a [cdx] event wakes you when it finishes, asks, stalls, or fails. "
401 + "To check right now, call mcp__cdx__status or mcp__cdx__events. "
402 + "If nothing else is pending, ending the turn is the correct move, not a wait.";
403}
404
405// Inside Claude Code every cdx command with a native tool goes through the
406// plugin: the tool runs in the session directory and its result lands in
407// the transcript, while a shell invocation guesses a cwd and drifts with the
408// last `cd`. The shell form stays for lanes and terminals outside Claude Code.
409export function nativeCdxCommand(command: string, nativeTools: ReadonlySet<string>): string | undefined {
410 const clean = stripHeredocBodies(stripQuotedPreservingSubstitutions(command), command);
411 for (const segment of clean.split(/[;&|()\n]+/)) {
412 const words = segment.trim().split(/\s+/).filter(Boolean);
413 if (words.length === 0) continue;
414 const invocation = cdxInvocation(words, commandStart(words));
415 if (invocation && nativeTools.has(invocation.subcommand)) return invocation.subcommand;
416 }
417 return undefined;
418}
419
420export function nativeCdxRefusal(subcommand: string): string {
421 return `cdx ${subcommand} from the shell is refused inside Claude Code; call mcp__cdx__${subcommand} instead. `
422 + "The native tool runs in the session directory and keeps the result in the transcript; "
423 + "the shell form is for lanes and terminals outside Claude Code.";
424}
425hooks/delivery.ts 284 lines1// Delivery buffer and drain rules. Pure module without engine interface calls.
2// Evaluated both in Claude Code hooks and in tests.
3
4import type { LiveRow } from "./contract";
5export type { LiveRow, LiveSnapshot } from "./contract";
6
7export interface PendingEvent {
8 text: string;
9 wake?: boolean;
10 kind?: string;
11}
12
13function elapsed(startedAt: string, now: number): string {
14 const seconds = Math.max(0, Math.floor((now - Date.parse(startedAt)) / 1000) || 0);
15 return seconds < 60 ? `${seconds}s` : seconds < 3600 ? `${Math.floor(seconds / 60)}m${seconds % 60}s`
16 : `${Math.floor(seconds / 3600)}h${Math.floor(seconds % 3600 / 60)}m`;
17}
18
19function cut(text: string, length: number): string {
20 return Array.from(text).slice(0, Math.max(0, length)).join("");
21}
22
23function actionWords(action: string): string {
24 const plain = action.replace(/\s+/g, " ").trim();
25 const command = plain.match(/(?:"(?:command|cmd)"\s*:\s*"|^|:\s*)(bun(?:x)?|npm|git|vp|claude|tsc|rg|grep)\s+([^"}]*)/i);
26 if (command) return `running ${command[1]} ${command[2]}`.trim();
27 const path = plain.match(/(?:"(?:file_path|path)"\s*:\s*")([^"}]+)|\b([\w./-]+\.(?:ts|tsx|js|md|json))\b/);
28 if (/edit|patch|write|fileChange|file_change/i.test(plain) && path) return `editing ${path[1] ?? path[2]}`;
29 if (/read|cat|sed|rg|grep/i.test(plain) && path) return `reading ${path[1] ?? path[2]}`;
30 return cut(plain.replace(/[{}"\\]/g, ""), 64) || "working";
31}
32
33export function orderedRows(rows: readonly LiveRow[]): LiveRow[] {
34 const children = new Map<string, LiveRow[]>();
35 for (const row of rows) {
36 const parent = row.parent && rows.some((item) => item.name === row.parent) ? row.parent : "";
37 children.set(parent, [...(children.get(parent) ?? []), row]);
38 }
39 const ordered: LiveRow[] = [];
40 const visit = (parent: string) => {
41 for (const row of children.get(parent) ?? []) { ordered.push(row); visit(row.name); }
42 };
43 visit("");
44 return ordered;
45}
46
47// One styled run of a band line; its text carries the padding and the gap
48// after it, so the texts of a line joined are the line as drawn.
49export interface BandCell {
50 text: string;
51 color?: string;
52 bold?: boolean;
53 dim?: boolean;
54}
55
56interface BandColumn {
57 title: string;
58 value: (row: LiveRow, now: number) => string;
59 style: (row: LiveRow) => Omit<BandCell, "text">;
60 gap: number;
61 max?: number;
62 right?: boolean;
63}
64
65const STAGE_COLORS: Record<string, string> = {
66 working: "green", gate: "cyan", review: "cyan", question: "yellow", stalled: "yellow", outage: "red",
67};
68
69export function stageColor(stage: string): string | undefined {
70 return STAGE_COLORS[stage];
71}
72
73function markOf(row: LiveRow): string {
74 return row.question ? "?" : row.stage === "outage" || row.stage === "stalled" ? "!" : row.stage === "gate" ? "◆" : row.stage === "queued" ? "○" : "●";
75}
76
77const plain = () => ({});
78const staged = (row: LiveRow) => ({ color: stageColor(row.stage) });
79const dim = () => ({ dim: true });
80// McLaren papaya, so a lane on the Fast service tier stands out at a glance.
81const FAST_ORANGE = "#FF8000";
82const tierStyle = (row: LiveRow) => row.kind === "lane" && row.engine === "gpt" && row.serviceTier === "priority"
83 ? { color: FAST_ORANGE, bold: true } : { dim: true };
84
85const BAND_COLUMNS: BandColumn[] = [
86 { title: "", value: markOf, style: staged, gap: 1 },
87 { title: "NAME", value: (row) => `${row.parent ? " " : ""}${row.name}`, style: () => ({ bold: true }), gap: 2, max: 28 },
88 { title: "KIND", value: (row) => row.kind, style: plain, gap: 2 },
89 { title: "ENGINE", value: (row) => row.kind === "job" ? "-" : row.model ?? row.engine, style: plain, gap: 2, max: 18 },
90 { title: "EFFORT", value: (row) => row.kind === "job" ? "-" : row.effort ?? "-", style: dim, gap: 2 },
91 { title: "ACCOUNT", value: (row) => row.kind === "lane" && row.engine === "gpt" ? row.account ?? "-" : "-", style: dim, gap: 2 },
92 { title: "TIER", value: (row) => row.kind === "lane" && row.engine === "gpt" ? row.serviceTier === "priority" ? "fast" : row.serviceTier === "default" ? "std" : "-" : "-", style: tierStyle, gap: 2 },
93 { title: "STAGE", value: (row) => row.stage, style: staged, gap: 2 },
94 { title: "AGE", value: (row, now) => elapsed(row.startedAt, now), style: dim, gap: 2 },
95 { title: "STEPS", value: (row) => row.kind === "job" ? "-" : String(row.steps), style: plain, gap: 2, right: true },
96 { title: "FILES", value: (row) => row.files === undefined ? "-" : String(row.files), style: plain, gap: 2, right: true },
97];
98
99// Below this many columns for NOW the band drops TIER, ACCOUNT, EFFORT, ENGINE, KIND.
100const NOW_MIN = 20;
101
102function width(text: string): number {
103 return Array.from(text).length;
104}
105
106function ellipsis(text: string, size: number): string {
107 if (size <= 0) return "";
108 return width(text) > size ? `${cut(text, size - 1)}…` : text;
109}
110
111function detailOf(row: LiveRow): string {
112 return row.question ? `question: ${row.question.replace(/\s+/g, " ")}` : actionWords(row.action);
113}
114
115// The band as a table: a header line, then one line per row in the order
116// given. Fixed columns fit the widest value shown (NAME and ENGINE capped),
117// NOW takes the rest of the width, and no line is wider than columns.
118export function bandTable(rows: readonly LiveRow[], now: number, columns: number): BandCell[][] {
119 let layout = BAND_COLUMNS.map((column) => {
120 const widest = Math.max(width(column.title), ...rows.map((row) => width(column.value(row, now))));
121 return { column, size: Math.min(widest, column.max ?? widest) };
122 });
123 const rest = () => columns - layout.reduce((sum, { column, size }) => sum + size + column.gap, 0);
124 for (const dropped of ["TIER", "ACCOUNT", "EFFORT", "ENGINE", "KIND"]) {
125 if (rest() < NOW_MIN) layout = layout.filter(({ column }) => column.title !== dropped);
126 }
127 const nowSize = Math.max(0, rest());
128 const pad = (text: string, size: number, right?: boolean) => {
129 const shown = ellipsis(text, size);
130 const fill = " ".repeat(size - width(shown));
131 return right ? fill + shown : shown + fill;
132 };
133 const header = [...layout.map(({ column, size }) => ({ text: pad(column.title, size) + " ".repeat(column.gap), dim: true })),
134 { text: ellipsis("NOW", nowSize), dim: true }];
135 const lines = rows.map((row) => [
136 ...layout.map(({ column, size }) => ({ text: pad(column.value(row, now), size, column.right) + " ".repeat(column.gap), ...column.style(row) })),
137 { text: ellipsis(detailOf(row), nowSize), dim: true },
138 ]);
139 return [header, ...lines].map((line) => clip(line, columns));
140}
141
142// A terminal too narrow for the fixed columns still gets lines no wider
143// than it: the cell at the edge is cut and the rest dropped.
144function clip(line: BandCell[], columns: number): BandCell[] {
145 const kept: BandCell[] = [];
146 let left = columns;
147 for (const cell of line) {
148 if (left <= 0) break;
149 const text = cut(cell.text, left);
150 kept.push({ ...cell, text });
151 left -= width(text);
152 }
153 return kept.filter((cell) => cell.text);
154}
155
156export function bandText(line: readonly BandCell[]): string {
157 return line.map((cell) => cell.text).join("");
158}
159
160export interface DeliveryState {
161 pending: PendingEvent[];
162 inTurn: boolean;
163 // When the first undelivered wake event arrived while idle; the submit
164 // waits WAKE_COALESCE_MS from then so one burst costs one prompt.
165 wakeSince?: number;
166 // Prompts the engine accepted this session.
167 submits: number;
168 // The engine refused a submit on its per-session prompt budget; no submit
169 // is tried again this session.
170 budgetSpent: boolean;
171}
172
173// The engine caps a plugin's prompts per session (50 in Claude Code 2.1.x),
174// so wake events are held this long and sent as one prompt.
175export const WAKE_COALESCE_MS = 15_000;
176
177export function initialDeliveryState(): DeliveryState {
178 return { pending: [], inTurn: false, submits: 0, budgetSpent: false };
179}
180
181export function formatSubmitText(events: readonly PendingEvent[]): string {
182 const joined = events.map((e) => e.text).join("\n");
183 return joined.startsWith("[cdx]") ? joined : `[cdx] ${joined}`;
184}
185
186export function formatContextText(events: readonly PendingEvent[]): string {
187 return ["[cdx] events", ...events.map((e) => e.text)].join("\n");
188}
189
190export function afterPoll(
191 state: DeliveryState,
192 events: readonly PendingEvent[],
193 now = Date.now(),
194): { state: DeliveryState; toasts: string[]; submit?: { text: string }; suggest?: { text: string } } {
195 // cdx events already filters to the kinds the head acts on.
196 const toasts = events.filter((e) => Boolean(e.wake)).map((e) => e.text);
197 const pending = [...state.pending, ...events];
198 const wakePending = pending.some((e) => Boolean(e.wake));
199
200 if (state.inTurn || !wakePending) {
201 return { state: { ...state, pending }, toasts };
202 }
203
204 // No prompt left: the events wait for the next tool result or typed
205 // prompt, and a fresh wake goes into the prompt box as a suggestion.
206 if (state.budgetSpent) {
207 const fresh = events.some((e) => Boolean(e.wake));
208 return {
209 state: { ...state, pending },
210 toasts,
211 ...(fresh ? { suggest: { text: formatSubmitText(pending) } } : {}),
212 };
213 }
214
215 const wakeSince = state.wakeSince ?? now;
216 if (now - wakeSince < WAKE_COALESCE_MS) {
217 return { state: { ...state, pending, wakeSince }, toasts };
218 }
219
220 return {
221 state: { ...state, pending: [], wakeSince: undefined, submits: state.submits + 1 },
222 toasts,
223 submit: { text: formatSubmitText(pending) },
224 };
225}
226
227// A refused submit puts its events back. A budget refusal ends submitting
228// for the session; any other refusal is retried after the coalesce window.
229export function onSubmitRefused(
230 state: DeliveryState,
231 drained: readonly PendingEvent[],
232 message: string,
233): DeliveryState {
234 return {
235 ...state,
236 pending: [...drained, ...state.pending],
237 wakeSince: undefined,
238 submits: Math.max(0, state.submits - 1),
239 budgetSpent: state.budgetSpent || /budget/i.test(message),
240 };
241}
242
243export function afterToolCall(
244 state: DeliveryState,
245 options?: { isSubagent?: boolean } | boolean,
246): { state: DeliveryState; context?: string } {
247 const isSubagent = typeof options === "boolean" ? options : Boolean(options?.isSubagent);
248 if (isSubagent || state.pending.length === 0) {
249 return { state };
250 }
251
252 return {
253 state: { ...state, pending: [] },
254 context: formatContextText(state.pending),
255 };
256}
257
258export function onPromptSubmit(
259 state: DeliveryState,
260): { state: DeliveryState; context?: string } {
261 if (state.pending.length === 0) {
262 return { state };
263 }
264
265 return {
266 state: { ...state, pending: [] },
267 context: formatContextText(state.pending),
268 };
269}
270
271// A running turn drains the buffer through tool results, so a held wake
272// stops waiting for its prompt.
273export function onTurnStart(state: DeliveryState): DeliveryState {
274 return { ...state, inTurn: true, wakeSince: undefined };
275}
276
277export function onTurnComplete(state: DeliveryState): DeliveryState {
278 return { ...state, inTurn: false };
279}
280
281export function clearBuffer(state: DeliveryState): DeliveryState {
282 return { ...state, pending: [] };
283}
284hooks/tools.ts 542 lines1import { renderBrief } from "../brief-contract";
2import { safeText } from "../safe-text";
3// Table of tools exposed by the cdx mod. Pure definitions and argv builders.
4// Evaluated both in Claude Code hooks and in tests.
5
6export interface ToolRunResult {
7 argv: string[];
8 stdin?: string;
9 // A shorter bound for a command known to be quick; absent means the ceiling.
10 timeoutMs?: number;
11}
12
13export interface ToolDefinition {
14 name: string;
15 description: string;
16 inputSchema: Record<string, unknown>;
17 run: (input: Record<string, unknown>) => ToolRunResult;
18}
19
20// $.process.run rejects any timeoutMs above ten minutes, and a rejecting
21// handler reaches the head as "no tool.call hook answered". Its own default
22// is 30 seconds, shorter than the foreground part of a spawn (git worktree
23// add plus the configured worktreeSetup) or a close removing a worktree, so
24// every tool runs with the ceiling unless it names a shorter bound.
25export const MAX_PROCESS_TIMEOUT_MS = 10 * 60_000;
26
27export const TOOLS: ToolDefinition[] = [
28 {
29 name: "land", description: "Commit green lanes, gate the merge result once unless a receipt already proves it, fast-forward the base, push, remove worktrees and branches, and close. Pass lanes for a batch; a red batch names the lane that broke it and lands the green prefix. A land a receipt proves runs inline; one that must run its gate detaches into a land-<lane> job and returns at once, and that job's exit event carries the land result.",
30 inputSchema: { type: "object", properties: { lane: { type: "string" }, lanes: { type: "array", items: { type: "string" }, description: "Land several lanes with one gate" } } },
31 run: (input) => {
32 if (Array.isArray(input.lanes) && input.lanes.length) {
33 if (input.lane !== undefined) throw new Error("land takes lane or lanes, not both");
34 if (!input.lanes.every(isName)) throw new Error("invalid lanes: every item must be a lane name");
35 return { argv: ["land", "--batch", ...input.lanes] };
36 }
37 if (!isName(input.lane)) throw new Error("missing required field: lane or lanes");
38 return { argv: ["land", input.lane] };
39 },
40 },
41 {
42 name: "ask", description: "Ask Gemini a synchronous read-only code question without creating a lane. Returns file and line evidence within 90 seconds.",
43 inputSchema: { type: "object", properties: { question: { type: "string" }, cd: { type: "string" } }, required: ["question", "cd"] },
44 run: (input) => ({ argv: ["ask", "--cd", String(input.cd), "-"], stdin: String(input.question), timeoutMs: 100_000 }),
45 },
46 {
47 name: "spawn",
48 description:
49 "Spawn a new cdx work lane. A work brief needs an outcome, owned files, acceptance, out-of-scope and a gate (gate or the repository's .cdx-gate); pass them as fields or as \"## Outcome\", \"## Files\", \"## Acceptance\", \"## Out of scope\" sections in brief. A supervisor also needs two or more children file sets. The brief is delivered whole through stdin; completion arrives as a [cdx] event.",
50 inputSchema: {
51 type: "object",
52 properties: {
53 lane: { type: "string", description: "Name for the new lane" },
54 brief: { type: "string", description: "Task brief for the lane: context and constraints, plus any sections not passed as fields" },
55 outcome: { type: "string", description: "What must be true when the lane is done" },
56 files: { type: "array", items: { type: "string" }, description: "Files the lane owns" },
57 acceptance: { type: "string", description: "The assertion that separates success from a plausible wrong answer" },
58 outOfScope: { type: "string", description: "What the lane must not do or touch" },
59 children: { type: "array", items: { type: "string" }, description: "Supervisor only: one child file set per item, two or more" },
60 scopePolicy: { type: "string", enum: ["ask", "extend", "stop"], description: "Files outside the brief: extend edits and lists them (default), stop ends the round, ask asks the head" },
61 testRuns: { type: "integer", minimum: 1, description: "Test invocations allowed this round, including the gate; defaults to visibility.testRuns or 3" },
62 engine: { type: "string", enum: ["gpt", "gemini"], description: "Execution engine" },
63 model: { type: "string", description: "Model alias or id" },
64 supervisor: { type: "boolean", description: "Run lane as supervisor" },
65 cd: { type: "string", description: "Absolute path of the repository the lane runs in (required: the session directory follows the shell, so the tool never guesses); with worktree, the repository the worktree is cut from" },
66 worktree: { type: "string", description: "Worktree path or name" },
67 gate: { type: "string", description: "Verification command to run before reporting" },
68 pre: { type: "string", description: "Setup command to run before starting work" },
69 effort: { type: "string", description: "Reasoning effort" },
70 maxRuntime: { type: "number", description: "Maximum runtime in minutes" },
71 expect: { type: "number", description: "Expected duration in minutes before an overrun notice" },
72 account: { type: "string", description: "Account name" },
73 addDirs: { type: "array", items: { type: "string" }, description: "Additional directories" },
74 schema: { type: "string", description: "Structured output JSON schema path" },
75 images: { type: "array", items: { type: "string" }, description: "Image paths to attach" },
76 },
77 required: ["lane", "brief", "cd"],
78 },
79 run: (input) => {
80 const argv = ["spawn", String(input.lane)];
81 if (input.engine) argv.push("--engine", String(input.engine));
82 if (input.model) argv.push("--model", String(input.model));
83 if (input.supervisor) argv.push("--supervisor");
84 if (input.scopePolicy) argv.push("--scope-policy", String(input.scopePolicy));
85 if (input.testRuns != null) argv.push("--test-runs", String(input.testRuns));
86 if (input.cd) argv.push("--cd", String(input.cd));
87 if (input.worktree) argv.push("--worktree", String(input.worktree));
88 if (input.gate) argv.push("--gate", String(input.gate));
89 if (input.pre) argv.push("--pre", String(input.pre));
90 if (input.effort) argv.push("--effort", String(input.effort));
91 if (input.maxRuntime != null) argv.push("--max-runtime", String(input.maxRuntime));
92 if (input.expect != null) argv.push("--expect", String(input.expect));
93 if (input.account) argv.push("--account", String(input.account));
94 if (Array.isArray(input.addDirs)) {
95 for (const dir of input.addDirs) argv.push("--add-dir", String(dir));
96 }
97 if (input.schema) argv.push("--schema", String(input.schema));
98 if (Array.isArray(input.images)) {
99 for (const img of input.images) argv.push("--image", String(img));
100 }
101 argv.push("--bg", "-");
102 const list = (value: unknown) => Array.isArray(value) ? value.map(String) : undefined;
103 const text = (value: unknown) => typeof value === "string" ? value : undefined;
104 const brief = renderBrief({ outcome: text(input.outcome), files: list(input.files), acceptance: text(input.acceptance),
105 outOfScope: text(input.outOfScope), children: list(input.children) }, String(input.brief));
106 return { argv, stdin: brief };
107 },
108 },
109 {
110 name: "resume",
111 description: "Repair a failed gate or P1/P2 review on the same diff. New scope needs a fresh lane seeded from the report.",
112 inputSchema: {
113 type: "object",
114 properties: {
115 lane: { type: "string", description: "Name of the lane to resume" },
116 followUp: { type: "string", description: "Fix instructions for the same diff" },
117 testRuns: { type: "integer", minimum: 1, description: "Test invocations allowed this round, including the gate; defaults to visibility.testRuns or 3" },
118 fix: { type: "string", enum: ["gate", "review"], description: "Evidence being repaired" },
119 effort: { type: "string", description: "Reasoning effort" },
120 maxRuntime: { type: "number", description: "Maximum runtime in minutes" },
121 expect: { type: "number", description: "Expected duration in minutes before an overrun notice" },
122 },
123 required: ["lane", "followUp", "fix"],
124 },
125 run: (input) => {
126 const argv = ["resume", String(input.lane), "--fix", String(input.fix)];
127 if (input.testRuns != null) argv.push("--test-runs", String(input.testRuns));
128 if (input.effort) argv.push("--effort", String(input.effort));
129 if (input.maxRuntime != null) argv.push("--max-runtime", String(input.maxRuntime));
130 if (input.expect != null) argv.push("--expect", String(input.expect));
131 argv.push("--bg", "-");
132 return { argv, stdin: String(input.followUp) };
133 },
134 },
135 {
136 name: "consult",
137 description: "Start a read-only consultation lane to analyze code and answer a question.",
138 inputSchema: {
139 type: "object",
140 properties: {
141 lane: { type: "string", description: "Name for the consultation lane" },
142 question: { type: "string", description: "Question to investigate" },
143 engine: { type: "string", enum: ["gpt", "gemini"], description: "Execution engine" },
144 supervisor: { type: "boolean", description: "Run consultation as supervisor" },
145 model: { type: "string", description: "Model alias or id" },
146 effort: { type: "string", description: "Reasoning effort" },
147 cd: { type: "string", description: "Absolute path of the repository the lane runs in (required: the session directory follows the shell, so the tool never guesses); with worktree, the repository the worktree is cut from" },
148 account: { type: "string", description: "Account name" },
149 },
150 required: ["lane", "question", "cd"],
151 },
152 run: (input) => {
153 const argv = ["consult", String(input.lane)];
154 if (input.engine) argv.push("--engine", String(input.engine));
155 if (input.supervisor) argv.push("--supervisor");
156 if (input.model) argv.push("--model", String(input.model));
157 if (input.effort) argv.push("--effort", String(input.effort));
158 if (input.cd) argv.push("--cd", String(input.cd));
159 if (input.account) argv.push("--account", String(input.account));
160 argv.push("--bg", "-");
161 return { argv, stdin: String(input.question) };
162 },
163 },
164 {
165 name: "panel",
166 description: "Ask Astra, Sol and Claude Fable the same read-only question. cdx merges the three answers by cited path into reports/panels/<name>/panel.md, keeps disagreement, and sends one completion line.",
167 inputSchema: {
168 type: "object",
169 properties: {
170 name: { type: "string", description: "Panel name; the member lanes become <name>-astra, <name>-sol, <name>-fable" },
171 question: { type: "string", description: "Question every member answers; question plus pack stays under 20k chars" },
172 cd: { type: "string", description: "Absolute path of the repository the members read" },
173 pack: { type: "string", description: "Absolute path of a small context pack every member reads first" },
174 },
175 required: ["name", "question", "cd"],
176 },
177 run: (input) => {
178 const argv = ["panel", String(input.name), "--cd", String(input.cd)];
179 if (input.pack) argv.push("--pack", String(input.pack));
180 argv.push("-");
181 return { argv, stdin: String(input.question) };
182 },
183 },
184 {
185 name: "review",
186 description: "Start an independent code review lane. Two exclusive modes: intent reviews the working tree; uncommitted, base or commit chooses a Git diff target. Passing intent with a target flag is refused.",
187 inputSchema: {
188 type: "object",
189 properties: {
190 lane: { type: "string", description: "Name for the review lane" },
191 engine: { type: "string", enum: ["gpt", "gemini"], description: "Execution engine" },
192 model: { type: "string", description: "Model alias or id" },
193 effort: { type: "string", description: "Reasoning effort" },
194 cd: { type: "string", description: "Absolute path of the repository the lane runs in (required: the session directory follows the shell, so the tool never guesses); with worktree, the repository the worktree is cut from" },
195 uncommitted: { type: "boolean", description: "Review uncommitted changes" },
196 base: { type: "string", description: "Base branch to compare against" },
197 commit: { type: "string", description: "Specific commit to review" },
198 scope: { type: "string", description: "File path pattern scope" },
199 intent: { type: "string", description: "Review intent or focus" },
200 },
201 required: ["lane", "cd"],
202 },
203 run: (input) => {
204 const argv = ["review", String(input.lane)];
205 if (input.engine) argv.push("--engine", String(input.engine));
206 if (input.model) argv.push("--model", String(input.model));
207 if (input.effort) argv.push("--effort", String(input.effort));
208 if (input.cd) argv.push("--cd", String(input.cd));
209 if (input.uncommitted) argv.push("--uncommitted");
210 if (input.base) argv.push("--base", String(input.base));
211 if (input.commit) argv.push("--commit", String(input.commit));
212 if (input.scope) argv.push("--scope", String(input.scope));
213 argv.push("--bg");
214 if (input.intent !== undefined && input.intent !== null && String(input.intent).length > 0) {
215 argv.push("-");
216 return { argv, stdin: String(input.intent) };
217 }
218 return { argv };
219 },
220 },
221 {
222 name: "events",
223 description: "Return every owned event not yet delivered: the mod's buffer, then the feed.",
224 inputSchema: {
225 type: "object",
226 properties: {},
227 },
228 run: () => ({ argv: ["events", "--json"] }),
229 },
230 {
231 name: "send",
232 description: "Send steering instructions or a message to a running lane.",
233 inputSchema: {
234 type: "object",
235 properties: {
236 lane: { type: "string", description: "Target lane name" },
237 text: { type: "string", description: "Message text to deliver" },
238 },
239 required: ["lane", "text"],
240 },
241 run: (input) => ({
242 argv: ["send", String(input.lane), "-"],
243 stdin: String(input.text),
244 }),
245 },
246 {
247 name: "reply",
248 description: "Answer an open question asked by a lane.",
249 inputSchema: {
250 type: "object",
251 properties: {
252 lane: { type: "string", description: "Target lane name" },
253 answer: { type: "string", description: "Answer text" },
254 id: { type: ["number", "string"], description: "Optional question sequence id" },
255 },
256 required: ["lane", "answer"],
257 },
258 run: (input) => {
259 const argv = ["reply", String(input.lane)];
260 if (input.id !== undefined && input.id !== null) {
261 argv.push("--id", String(input.id));
262 }
263 argv.push("-");
264 return { argv, stdin: String(input.answer) };
265 },
266 },
267 {
268 name: "questions",
269 description: "List open questions across all lanes or for a specific lane.",
270 inputSchema: {
271 type: "object",
272 properties: {
273 lane: { type: "string", description: "Optional lane filter" },
274 },
275 },
276 run: (input) => {
277 const argv = ["questions"];
278 if (input.lane) argv.push(String(input.lane));
279 return { argv };
280 },
281 },
282 {
283 name: "status",
284 description: "Show the status of active and recent cdx lanes. brief returns one line per running or unclosed lane plus running jobs; the default is the detailed block per lane.",
285 inputSchema: {
286 type: "object",
287 properties: {
288 all: { type: "boolean", description: "Include closed lanes" },
289 brief: { type: "boolean", description: "One line per lane and job, the same text as the session brief" },
290 },
291 },
292 run: (input) => {
293 if (input.brief === true) return { argv: ["brief"] };
294 const argv = ["status"];
295 if (input.all) argv.push("--all");
296 return { argv };
297 },
298 },
299 {
300 name: "report",
301 description: "Read the final report written by a finished lane.",
302 inputSchema: {
303 type: "object",
304 properties: {
305 lane: { type: "string", description: "Lane name" },
306 },
307 required: ["lane"],
308 },
309 run: (input) => ({ argv: ["report", String(input.lane)] }),
310 },
311 {
312 name: "tail",
313 description: "Inspect the latest execution log lines for a running or finished lane.",
314 inputSchema: {
315 type: "object",
316 properties: {
317 lane: { type: "string", description: "Lane name" },
318 lines: { type: "number", description: "Number of lines to read" },
319 },
320 required: ["lane"],
321 },
322 run: (input) => {
323 const argv = ["tail", String(input.lane)];
324 if (input.lines !== undefined && input.lines !== null) {
325 argv.push("-n", String(input.lines));
326 }
327 return { argv };
328 },
329 },
330 {
331 name: "close",
332 description: "Close a completed lane and archive its status.",
333 inputSchema: {
334 type: "object",
335 properties: {
336 lane: { type: "string", description: "Lane name" },
337 keepWorktree: { type: "boolean", description: "Close without removing the worktree or branch; print manual cleanup commands" },
338 note: { type: "string", description: "Optional closing note" },
339 },
340 required: ["lane"],
341 },
342 run: (input) => {
343 const argv = ["close", String(input.lane)];
344 if (input.keepWorktree) argv.push("--keep-worktree");
345 if (input.note !== undefined && input.note !== null && String(input.note).length > 0) {
346 argv.push("-");
347 return { argv, stdin: String(input.note) };
348 }
349 return { argv };
350 },
351 },
352 {
353 name: "kill",
354 description: "Terminate a running lane process immediately.",
355 inputSchema: {
356 type: "object",
357 properties: {
358 lane: { type: "string", description: "Lane name" },
359 },
360 required: ["lane"],
361 },
362 run: (input) => ({ argv: ["kill", String(input.lane)] }),
363 },
364 {
365 name: "gate",
366 description: "Set or clear the verification gate command for a lane.",
367 inputSchema: {
368 type: "object",
369 properties: {
370 lane: { type: "string", description: "Lane name" },
371 cmd: { type: "string", description: "New gate command to run" },
372 clear: { type: "boolean", description: "Clear existing gate command" },
373 },
374 required: ["lane"],
375 },
376 run: (input) => {
377 if (input.clear) return { argv: ["gate", String(input.lane), "--clear"] };
378 if (!isName(input.cmd)) throw new Error("gate needs cmd or clear");
379 return { argv: ["gate", String(input.lane), input.cmd] };
380 },
381 },
382 {
383 name: "gate-receipt",
384 description: "Read content-bound acceptance proof for the latest work round.",
385 inputSchema: { type: "object", properties: { lane: { type: "string", description: "Lane name" } }, required: ["lane"] },
386 run: (input) => ({ argv: ["gate-receipt", String(input.lane), "--json"] }),
387 },
388 {
389 name: "job",
390 description: "Launch a detached background job command beside lanes.",
391 inputSchema: {
392 type: "object",
393 properties: {
394 name: { type: "string", description: "Job name" },
395 cmd: { type: "string", description: "Shell command to run" },
396 cd: { type: "string", description: "Explicit working directory for the job" },
397 expect: { type: "number", description: "Expected duration in minutes before an overrun notice" },
398 },
399 required: ["name", "cmd", "cd"],
400 },
401 run: (input) => {
402 const argv = ["job", String(input.name)];
403 if (input.cd) argv.push("--cd", String(input.cd));
404 if (input.expect != null) argv.push("--expect", String(input.expect));
405 argv.push("-");
406 return { argv, stdin: String(input.cmd) };
407 },
408 },
409 {
410 name: "msg",
411 description: "Send a notification message to a session or lane.",
412 inputSchema: {
413 type: "object",
414 properties: {
415 target: { type: "string", description: "Target session or lane" },
416 text: { type: "string", description: "Message body" },
417 },
418 required: ["target", "text"],
419 },
420 run: (input) => ({
421 argv: ["msg", String(input.target), "-"],
422 stdin: String(input.text),
423 }),
424 },
425 {
426 name: "inbox",
427 description: "Read incoming messages sent to this session.",
428 inputSchema: {
429 type: "object",
430 properties: {
431 lines: { type: "number", description: "Number of message lines to read" },
432 },
433 },
434 run: (input) => {
435 const argv = ["inbox"];
436 if (input.lines !== undefined && input.lines !== null) {
437 argv.push("-n", String(input.lines));
438 }
439 return { argv };
440 },
441 },
442 {
443 name: "usage",
444 description: "Report Codex and Gemini quota rows, observed burn, projected forfeiture and exhaustion, holds, and GPT account picks. json includes evidence and ledger totals; totals adds ledger totals to text.",
445 inputSchema: {
446 type: "object",
447 properties: {
448 totals: { type: "boolean", description: "Include all-time ledger totals in text." },
449 json: { type: "boolean", description: "Machine-readable output instead of the text report." },
450 },
451 },
452 run: (input) => ({ argv: ["usage", ...(input.json === true ? ["--json"] : []), ...(input.totals === true ? ["--totals"] : [])] }),
453 },
454 {
455 name: "doctor",
456 description: "Diagnose plugin installation, engine accounts, and background workers.",
457 inputSchema: {
458 type: "object",
459 properties: {
460 fix: { type: "boolean", description: "Attempt automated repairs" },
461 probe: { type: "boolean", description: "Probe live engine credentials and rate limits" },
462 },
463 },
464 run: (input) => {
465 const argv = ["doctor"];
466 if (input.fix) argv.push("--fix");
467 if (input.probe) argv.push("--probe");
468 return { argv, timeoutMs: 120000 };
469 },
470 },
471];
472
473export const TOOL_NAMES = TOOLS.map((t) => t.name);
474export const CDX_TOOL_PREFIX = "mcp__cdx__";
475
476// A model that drops a field can still send the text "undefined".
477function isName(value: unknown): value is string {
478 return typeof value === "string" && value.trim() !== "" && value !== "undefined";
479}
480
481// Validate before any argv or stdin conversion, including direct table callers.
482export function requiredInput(schema: Record<string, unknown>, input: Record<string, unknown>): void {
483 for (const field of (schema.required as string[] ?? [])) {
484 const value = input[field];
485 if (!isName(value)) throw new Error(`missing required field: ${field}`);
486 const property = (schema.properties as Record<string, { enum?: string[] }> | undefined)?.[field];
487 if (property?.enum && !property.enum.includes(value)) throw new Error(`invalid ${field}: expected ${property.enum.join(" or ")}`);
488 }
489}
490// Tools that drive one lane. cdx refuses them on a lane another live Claude
491// session owns unless force is set.
492const LANE_TOOLS = new Set(["land", "spawn", "resume", "consult", "review", "send", "reply", "close", "kill", "gate"]);
493// Tools whose lane or job belongs to the calling session. Without a session
494// id cdx would run them as the terminal, owner checks and stamps skipped.
495export const SESSION_TOOLS: ReadonlySet<string> = new Set([...LANE_TOOLS, "job", "panel"]);
496
497for (const tool of TOOLS) {
498 const run = tool.run;
499 const lane = LANE_TOOLS.has(tool.name);
500 if (lane) (tool.inputSchema.properties as Record<string, unknown>).force = { type: "boolean", description: "Take over a lane another live Claude session owns" };
501 tool.run = (input) => {
502 requiredInput(tool.inputSchema, input);
503 const result = run(input);
504 if (lane && input.force === true) result.argv.push("--force");
505 return result;
506 };
507}
508
509export function nativeToolResult(exitCode: number, result: string) {
510 return exitCode === 0 ? { result } : { result, isError: true as const };
511}
512
513export const TOOLS_BY_NAME = new Map<string, ToolDefinition>(
514 TOOLS.map((tool) => [tool.name, tool]),
515);
516
517const OUTPUT_LIMIT = 20_000;
518
519const byteLength = (text: string) => new TextEncoder().encode(text).length;
520
521export async function formatToolOutput(exitCode: number, stdout: string, stderr: string,
522 retain?: (text: string) => Promise<string>): Promise<string> {
523 const text = safeText(exitCode === 0 ? stdout : [stdout, stderr, `exit ${exitCode}`].filter((part) => part.trim()).join("\n"));
524 if (byteLength(text) <= OUTPUT_LIMIT) return text;
525 if (!retain) throw new Error("large tool output requires a retained file");
526 const path = await retain(text);
527 const marker = `\n... full output: ${path} ...\n`;
528 // A UTF-16 code unit needs at most three UTF-8 bytes.
529 const size = Math.max(0, Math.floor((OUTPUT_LIMIT - byteLength(marker)) / 6) - 1);
530 if (!size) throw new Error("retained output path exceeds output limit");
531 return text.slice(0, size) + marker + text.slice(-size);
532}
533
534// A head whose cwd was a landed lane's worktree runs from the plugin root.
535// The check uses exists, not stat: the runtime's stat rejection carries ENOENT
536// only in its message, so a code test never matched. Only this check, before
537// any command runs, can select the fallback.
538export async function runFromCwd<T>(cwd: string, root: string,
539 exists: (path: string) => Promise<boolean>, run: (cwd: string) => Promise<T>): Promise<T> {
540 return run(await exists(cwd) ? cwd : root);
541}
542brief-contract.ts 121 lines1// The work brief contract and the scope policy. Pure: the MCP tool table
2// imports it to render briefs, so it must not reach the state store.
3
4// Lanes asked 477 questions over 740 lanes; half were "may I edit outside my
5// files". A work brief now names its scope up front and the policy answers
6// that question before it is asked.
7export type ScopePolicy = "ask" | "extend" | "stop";
8export const SCOPE_POLICIES: readonly ScopePolicy[] = ["ask", "extend", "stop"];
9
10export interface BriefFields {
11 outcome?: string;
12 files?: string[];
13 acceptance?: string;
14 outOfScope?: string;
15 children?: string[];
16}
17
18type Section = "outcome" | "files" | "acceptance" | "outOfScope" | "children";
19
20const HEADINGS: Record<Section, string> = {
21 outcome: "Outcome", files: "Files", acceptance: "Acceptance", outOfScope: "Out of scope", children: "Children",
22};
23
24// First match wins, so "Out of scope files" is out of scope and "Child file
25// sets" is children, never files.
26const CLASSIFIERS: [Section, RegExp][] = [
27 ["outOfScope", /\bout of scope\b|\bnon goals?\b/],
28 ["children", /\bchild(?:ren)?\b/],
29 ["acceptance", /\bacceptance\b/],
30 ["outcome", /\boutcome\b/],
31 ["files", /\bfiles?\b/],
32];
33
34const LIST_ITEM = /^\s*(?:[-*+]|\d+[.)])\s+\S/;
35
36// Markdown headings of any level split the brief. A section counts only when
37// its body has text.
38export function briefSections(brief: string): Partial<Record<Section, string>> {
39 const sections: Partial<Record<Section, string>> = {};
40 let current: Section | undefined;
41 for (const line of brief.split("\n")) {
42 const heading = line.match(/^\s{0,3}#{1,6}\s+(.+?)\s*#*\s*$/);
43 if (heading) {
44 const title = heading[1]!.toLowerCase().replace(/[-_:]/g, " ").replace(/\s+/g, " ");
45 current = CLASSIFIERS.find(([, pattern]) => pattern.test(title))?.[0];
46 if (current) sections[current] ??= "";
47 continue;
48 }
49 if (current) sections[current] += `${line}\n`;
50 }
51 for (const key of Object.keys(sections) as Section[]) {
52 const body = sections[key]!.trim();
53 if (body) sections[key] = body;
54 else delete sections[key];
55 }
56 return sections;
57}
58
59export function childFileSets(brief: string): number {
60 return (briefSections(brief).children ?? "").split("\n").filter((line) => LIST_ITEM.test(line)).length;
61}
62
63// One line naming every missing element, or undefined when the brief holds.
64export function briefContractRefusal(brief: string, hasGate: boolean, supervisor: boolean): string | undefined {
65 const sections = briefSections(brief);
66 const missing = (["outcome", "files", "acceptance", "outOfScope"] as const)
67 .filter((key) => !sections[key]).map((key) => `"## ${HEADINGS[key]}"`);
68 if (!hasGate) missing.push("a gate (--gate or .cdx-gate)");
69 if (supervisor && childFileSets(brief) < 2) missing.push(`"## ${HEADINGS.children}" with two or more child file sets, one list item each`);
70 if (missing.length === 0) return;
71 return `work brief refused, missing ${missing.join(", ")}; consults and reviews are exempt`;
72}
73
74export function renderBrief(fields: BriefFields, body: string): string {
75 const list = (items: string[] | undefined) => items?.map((item) => `- ${item}`).join("\n");
76 const sections: [Section, string | undefined][] = [
77 ["outcome", fields.outcome], ["files", list(fields.files)], ["acceptance", fields.acceptance],
78 ["outOfScope", fields.outOfScope], ["children", list(fields.children)],
79 ];
80 const rendered = sections.filter(([, text]) => text?.trim()).map(([key, text]) => `## ${HEADINGS[key]}\n\n${text!.trim()}`);
81 return [...rendered, body.trim()].filter(Boolean).join("\n\n");
82}
83
84// Commands that pass no matter what the tree holds.
85export function isNoOpGate(command: string): boolean {
86 return /^(?:true|:|exit(?:\s+0)?|\/(?:usr\/)?bin\/true|echo\b[^;&|`$]*)\s*;?$/.test(command.trim());
87}
88
89export function scopeRule(policy: ScopePolicy): string {
90 if (policy === "extend") return 'Scope policy extend: edit any file the outcome needs, inside or outside the Files section, without asking. List every file outside it under "## Scope extensions" in your report, one line each with the reason.';
91 if (policy === "stop") return "Scope policy stop: if the outcome needs a file outside the Files section, do not edit it and do not ask. Stop, name the file and the reason in your report, and end the round.";
92 return "Scope policy ask: before editing a file outside the Files section, ask with cdx question and wait for the answer.";
93}
94
95export function scopeAnswer(policy: ScopePolicy): string {
96 return policy === "extend"
97 ? 'cdx answered from the scope policy (extend): yes. Edit what the outcome needs and list each file outside your Files section under "## Scope extensions" in your report. Do not ask again.'
98 : "cdx answered from the scope policy (stop): no. Do not edit outside your Files section. Stop, name the file and the reason in your report, and end the round.";
99}
100
101const PERMISSION = /\b(?:may i|can i|could i|should i|shall i|am i (?:allowed|permitted)|is it (?:ok|okay|fine)|ok to|okay to|permission|approve|authori[sz]e|allowed to)\b/i;
102const SCOPE = /\b(?:outside|beyond|not (?:in|on|part of|listed in)|extend|expand|widen)\b[^.?!]*\b(?:scope|files?|file set|owned|ownership|brief|list)\b|\bout of scope\b|\bscope extension\b/i;
103
104// Keyword classifier: a permission word plus a scope phrase.
105export function isScopePermissionAsk(question: string): boolean {
106 return PERMISSION.test(question) && SCOPE.test(question);
107}
108
109// List items under "## Scope extensions"; "None" and prose do not count.
110export function scopeExtensions(report: string): string[] {
111 const lines = report.split("\n");
112 const start = lines.findIndex((line) => /^\s{0,3}#{1,6}\s+scope extensions\b/i.test(line));
113 if (start < 0) return [];
114 const items: string[] = [];
115 for (const line of lines.slice(start + 1)) {
116 if (/^\s{0,3}#{1,6}\s/.test(line)) break;
117 if (LIST_ITEM.test(line)) items.push(line.replace(/^\s*(?:[-*+]|\d+[.)])\s+/, "").trim());
118 }
119 return items;
120}
121safe-text.ts 25 lines1// Shared by the CLI and sandboxed hooks; no runtime imports.
2export function safeText(text: string): string {
3 const env = (globalThis as { process?: { env?: Record<string, string | undefined> } }).process?.env ?? {};
4 for (const [name, value] of Object.entries(env)) {
5 if (value && value.length >= 8 && /(?:KEY|TOKEN|SECRET|PASSWORD|CREDENTIAL)/i.test(name)) text = text.split(value).join("[redacted]");
6 }
7 return text
8 .replace(/(\b[A-Z_][A-Z0-9_]*(?:KEY|TOKEN|SECRET|PASSWORD)\s*=\s*|\b(?:KEY|TOKEN)\s*=\s*|--api-key\s+)(?:"[^"\r\n]*"|'[^'\r\n]*'|[^\s;"']+)/gi, "$1[redacted]")
9 .replace(/(\bAuthorization\s*:\s*)(?:Bearer\s+|Basic\s+)?[^\r\n"']+/gi, "$1[redacted]")
10 .replace(/\b(key|token|secret|password)\b[^\r\n]*/gi, (line) => line.replace(/[A-Za-z0-9+/_-]{32,}={0,2}/g, "[redacted]"))
11 .replace(/ctx7sk[-_A-Za-z0-9]{8,}|sk-(?:ant-)?[A-Za-z0-9_-]{16,}|AIza[A-Za-z0-9_-]{20,}|gh[pousr]_[A-Za-z0-9]{20,}|github_pat_[A-Za-z0-9_]{20,}|npm_[A-Za-z0-9]{20,}|Bearer [A-Za-z0-9._-]{16,}/g, "[redacted]");
12}
13
14export function safeJSON(value: unknown, space?: number): string {
15 return JSON.stringify(value, (key, item) => {
16 if (typeof item !== "string") return item;
17 if (/^(?:authorization|(?:api[-_]?|access[-_]?)?key|(?:access[-_]?|auth[-_]?)?token|password|secret)$/i.test(key)) return "[redacted]";
18 // Tool arguments may themselves be JSON with escaped shell quotes.
19 if (/^\s*[\[{]/.test(item)) {
20 try { return safeJSON(JSON.parse(item)); } catch { /* ordinary text */ }
21 }
22 return safeText(item);
23 }, space);
24}
25hooks/contract.d.ts 27 lines1export interface LiveRow {
2 name: string;
3 parent?: string;
4 kind: "lane" | "job";
5 engine: string;
6 model?: string;
7 // The current round's effort as openRound stored it on the lane.
8 effort?: string;
9 account?: string;
10 serviceTier?: "priority" | "default";
11 stage: string;
12 startedAt: string;
13 steps: number;
14 files?: number;
15 action: string;
16 question?: string;
17 transcript?: string[];
18}
19
20export interface LiveSnapshot { rows: LiveRow[]; now: number }
21
22
23declare module "claude-code" {
24 // poller: the id of the one module instance whose timer polls cdx.
25 interface PluginState { cdx: { live: LiveSnapshot; poller: string } }
26}
27