SLOPSHOPPER

cdx

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

newpanebandguardcommandtoast
v10.0.9AGPL-3.0updated 2026-10-02RedesignedRobot/cdx
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · cdx
│ ┃ Lanes ✕ › fix the failing auth test and add an audit log call │ ┃ No running lanes or jobs │ ● cdx: dev │ ⏺ Read(src/auth.ts) │ ⎿ Read 6 lines │ ⏺ Update(src/auth.ts) │ ⎿ Added 2 lines, removed 1 line │ ⏺ Bash(bun test) │ ⎿ 3 pass, 1 fail │ │ ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ › /lanes │ ⎿ cdx: Lanes pane opened │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Lanes
No running lanes or jobs
README

cdx 10.0.1

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.

Claude Code native plugin Function hooks Version Runtime: Bun Dependencies: zero License

<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.

Native in Claude Code

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 sessionWhat it does
mcp__cdx__spawn, resume, review, consult, panelStart work. The brief travels as a tool field, never through the shell.
mcp__cdx__reply, send, msgAnswer a question, steer a running lane, message a session.
mcp__cdx__status, events, report, tail, questions, inbox, usageCheck in without waiting.
mcp__cdx__land, close, kill, gate, gate-receipt, job, ask, doctorLand, finish, stop, gate, run detached jobs, ask a code question, diagnose.
[cdx] prompts and toastsWake events arrive as a prompt when the head is idle and as context on the next tool result mid-turn.
/lanesOpens a live Pane with lane details and recent transcript lines. Arguments forward to cdx.
Live bandThis 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.

Setup

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.

Upgrading from 9.x

  1. Stop every lane and job. A 9.x runner left running writes ledger.json, which 10.0 never reads.
  2. Install 10.0 and run cdx doctor --fix. Remove visibility.heartbeatMinutes and visibility.fileEdits from config.json; 10.0 refuses unknown keys.
  3. Run cdx migrate once.
  4. Restart every open Claude Code session, or run /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.

Engines and routing

--engine is optional on spawn and review and defaults to gpt; omitting it prints cdx: engine gpt (default). Resume inherits the lane engine.

  • Sol direct is the default in every repository. A new gpt work lane runs 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.
  • Astra thinks. Head-launched review, consult and --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.
  • Gemini is for read-only work: consults, reviews, pre-reads and crawls. A Gemini work lane prints 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.
  • Claude runs only panel members and read-only consults. --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.

Quickstart

# 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

The brief contract

A work brief given to cdx spawn, a respawn included, must carry four sections and a gate:

SectionHolds
## OutcomeWhat must be true when the lane is done
## FilesThe files the lane owns
## AcceptanceThe assertion that separates success from a plausible wrong answer
## Out of scopeWhat 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.

Commands

CommandWhat 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 FGrade 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>" / --clearSet 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 eventsUnread actionable events for the calling session
cdx statusOpen 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 usageQuota windows, observed burn, projections, account picks, outcome totals
cdx tail, cdx report, cdx log, cdx feedRead transcripts, reports, logs and recent events
cdx kill, cdx close, cdx cleanStop, close, prune
cdx doctor, cdx migrate, cdx briefDiagnose, 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.

Sandbox

Every lane runs sandboxed. cdx builds the writable roots once and hands them to each engine.

RoleWritableNotes
Codex work lanelane 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 dirworkspace-write, network on, .git read-only so lanes cannot commit
Codex review, consult, consult supervisorTMPDIR and the reviewed tree's .codegraph/ only; nothing else in the checkout, and .codegraph/.gitignore stays read-onlycdx-review permissions profile selected through the default_permissions config override; commands and network still work
Gemini lanethe same roots, plus ~/.gemini/antigravity-cli, TMPDIR and /devagy runs under sandbox-exec, without --dangerously-skip-permissions
Gemini reviewstate, control and spill dirs, the round's partial report and progress log, TMPDIR and the reviewed tree's .codegraph/ except its .gitignoreGemini 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.

Output caps

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

Lane homes and rules

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

Source 7 files
hooks/register.ts 400 lines
1import 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}
400
guard.ts 425 lines
1// 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}
425
hooks/delivery.ts 284 lines
1// 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}
284
hooks/tools.ts 542 lines
1import { 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}
542
brief-contract.ts 121 lines
1// 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}
121
safe-text.ts 25 lines
1// 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}
25
hooks/contract.d.ts 27 lines
1export 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