Runs every Codex delegation (codex exec, codex review), every ccx task and every headless Claude run (claude -p / --print, bare or behind dev exec) with a live…

Local-first causal lineage and observability for AI-assisted development.
Answer "why does this code exist?" in one command. wipnote traces causal chains across work items, commits, sessions, and agent spawns — then stores everything as HTML files in your repo. No external infrastructure required.
| Layer | Role |
|---|---|
.wipnote/*.html | Canonical CRUD store — single source of truth |
SQLite (.wipnote/wipnote.db) | Rebuildable read cache for fast queries |
Go binary (wipnote) | CLI + hook handler |
HTML is the source of truth; SQLite is derived. If they drift, wipnote reindex drops the database and rebuilds it from the HTML files. No external infrastructure — no Postgres, no Redis, no cloud sync.
curl -fsSL https://raw.githubusercontent.com/shakestzd/wipnote/main/scripts/install.sh | bash
Supported platforms: darwin_amd64, darwin_arm64, linux_amd64, linux_arm64.
The installer places the wipnote binary in ~/.local/bin/ and the bundled plugin trees (plugin/, codex-marketplace/, antigravity-extension/) in ~/.local/share/wipnote/. Both locations can be customized via environment variables.
Pinned to a specific version:
curl -fsSL https://raw.githubusercontent.com/shakestzd/wipnote/main/scripts/install.sh | WIPNOTE_VERSION=0.60.1 bash
Custom install directory:
curl -fsSL https://raw.githubusercontent.com/shakestzd/wipnote/main/scripts/install.sh | WIPNOTE_BIN_DIR=$HOME/bin WIPNOTE_SHARE_DIR=$HOME/share/wipnote bash
wipnote upgrade
wipnote version # should print 0.60.1 (or later)
The install.sh script:
uname -s: darwin or linux) and architecture (uname -m: x86_64→amd64, arm64/aarch64→arm64). Errors clearly on unsupported combinations.WIPNOTE_VERSION env var, or fetches the latest tag from https://api.github.com/repos/shakestzd/wipnote/releases/latest.wipnote_${VERSION}_${OS}_${ARCH}.tar.gz and wipnote_${VERSION}_checksums.txt to a temp dir (cleaned up via trap … EXIT).sha256sum (Linux) or shasum -a 256 (macOS). Hard-fails on mismatch; prints a clear warning if neither tool is available and skips (does NOT silently bypass).mkdir -ps the binary dir (WIPNOTE_BIN_DIR, default $HOME/.local/bin), moves the binary, chmod +x.plugin/, codex-marketplace/, antigravity-extension/) to the share dir (WIPNOTE_SHARE_DIR, default $HOME/.local/share/wipnote/).xattr -d com.apple.quarantine) to avoid Gatekeeper blocking.PATH. If not, prints instructions — it does NOT mutate your shell rc files.==> Installed wipnote vX.Y.Z and runs wipnote version.Manual install equivalent:
VERSION=0.60.1
OS=$(uname -s | tr '[:upper:]' '[:lower:]')
ARCH=$(uname -m | sed 's/x86_64/amd64/; s/aarch64/arm64/')
TMPD=$(mktemp -d)
curl -fsSL "https://github.com/shakestzd/wipnote/releases/download/v${VERSION}/wipnote_${VERSION}_${OS}_${ARCH}.tar.gz" \
-o "$TMPD/wipnote.tar.gz"
curl -fsSL "https://github.com/shakestzd/wipnote/releases/download/v${VERSION}/wipnote_${VERSION}_checksums.txt" \
-o "$TMPD/checksums.txt"
# Verify checksum (Linux: sha256sum, macOS: shasum -a 256)
sha256sum --check --ignore-missing "$TMPD/checksums.txt" # Linux
# shasum -a 256 --check "$TMPD/checksums.txt" # macOS
tar -xzf "$TMPD/wipnote.tar.gz" -C "$TMPD"
mkdir -p "$HOME/.local/bin" "$HOME/.local/share/wipnote"
mv "$TMPD/wipnote" "$HOME/.local/bin/wipnote"
chmod +x "$HOME/.local/bin/wipnote"
# Install bundled plugin trees
for tree in plugin codex-marketplace antigravity-extension; do
[[ -d "$TMPD/$tree" ]] && mv "$TMPD/$tree" "$HOME/.local/share/wipnote/$tree"
done
xattr -d com.apple.quarantine "$HOME/.local/bin/wipnote" 2>/dev/null || true # macOS only
rm -rf "$TMPD"
wipnote version
For other platforms (e.g. Windows), build from source:
git clone https://github.com/shakestzd/wipnote && cd wipnote && go build -o ~/.local/bin/wipnote ./cmd/wipnote
The release tarball bundles the plugin trees for Claude Code, Codex CLI, and Antigravity alongside the wipnote binary. There is no separate claude plugin install step — wipnote claude loads the bundled plugin via --plugin-dir automatically.
wipnote claude # Claude Code with bundled plugin
wipnote codex # Codex CLI with bundled marketplace
wipnote antigravity # Antigravity with bundled extension
Each launcher resolves the bundled tree (from ~/.local/share/wipnote/ for the curl/dev install) and points the harness at it. Override the resolved path per-tree with WIPNOTE_PLUGIN_DIR, WIPNOTE_CODEX_DIR, or WIPNOTE_ANTIGRAVITY_DIR.
For users who want the bare claude / codex / agy commands to route through wipnote, opt in via shell aliases:
wipnote shell-alias >> ~/.zshrc # or ~/.bashrc
This is intentionally opt-in — per-project wipnote claude invocation is the recommended flow because it avoids surprising shell behavior in non-wipnote projects.
wipnote init # creates .wipnote/ in your repo
wipnote track create "Auth Overhaul"
wipnote feature create "Add OAuth" --track <trk-id> --description "Implement OAuth2 flow"
wipnote feature start <feat-id>
# ... do work ...
wipnote feature complete <feat-id>
wipnote lineage <feat-id> # why this code exists
Causal lineage — Trace the full causal chain for any work item, commit, session, or file. Three commands cover the common queries:
# Unified causal chain: forward edges (what this caused) + backward edges (what caused this)
wipnote lineage feat-abc1234
# Reverse direction: given a feature ID, list every commit and session it produced
wipnote trace feat-abc1234
# Temporal lineage: git log for a work item's HTML file — every edit, in order
wipnote history feat-abc1234
Work item tracking — Features, bugs, spikes, and tracks as HTML files in .wipnote/. Every change is a git diff. Every item has a lifecycle: create, start, complete.
Session observability — Hooks capture every tool call, every prompt, and attribute them to the active work item. See exactly what happened in any session with wipnote session show and wipnote lineage.
Custom agents — Define specialized agents with specific models, tools, and system prompts. A researcher agent for investigation and coder agents at three capability tiers for implementation — each scoped to its job.
Hooks & automation — Event-driven hooks on SessionStart, PreToolUse, PostToolUse, and Stop. Enforce safety rules, capture telemetry, block dangerous operations, or trigger custom workflows automatically.
Skills & slash commands — Reusable workflows as slash commands: /wipnote:plan, /wipnote:execute, /wipnote:code-quality-skill, /wipnote:ui-stills-verification. Package complex multi-step procedures into single invocations.
Quality gates — Enforce software engineering discipline: build, lint, and test before every commit. Spec compliance scoring, code health metrics, and structured diff reviews.
Claude Code mods — wipnote claude loads an orchestration band under the prompt (work items, tracks and subagents at a glance) and a side pane that tracks a plan's slices and their approval state during review.
Multi-agent attribution and observation — Claude Code, Codex, Antigravity, and GitHub Copilot all read from and write to the same work items via the CLI. Every tool call, file edit, and session is attributed to a work item so you can see what each agent actually did. (Session transcript ingestion currently supports Claude Code JSONL format.)
Plans & specifications — CRISPI plans break initiatives into trackable steps. Feature specs define acceptance criteria. Agents execute against the plan and report progress.
| Type | Prefix | Purpose |
|---|---|---|
| Feature | feat- | Units of deliverable work |
| Bug | bug- | Defects to fix |
| Spike | spk- | Time-boxed investigations |
| Track | trk- | Initiatives grouping related work |
| Plan | plan- | CRISPI implementation plans |
The lineage command family covers work items, commits, sessions, and files within a single repo. Two natural follow-ups are explicitly out of scope for now:
~/.local/share/wipnote/projects.json. Today each project's lineage is self-contained.wipnote help --compact
See full CLI documentation at shakestzd.github.io/wipnote/reference/cli.
wipnote is developed using wipnote itself (dogfooding). .wipnote/ contains real work items — not demos.
git clone https://github.com/shakestzd/wipnote
cd wipnote
go build -o wipnote ./cmd/wipnote/
./wipnote init
Quality gates: go build ./... && go vet ./... && go test ./...
MIT
hooks/register.ts 96 lines1import type { EngineInterface as Api, Register } from 'claude-code'
2import { buildWrapper, isDelegation, paneLabel, runDirPath, scriptPath, stubFor, type ClosePolicy } from './wrap.ts'
3import { runPane } from './runpane.tsx'
4import { callEnded, settleRun, text, trackRun } from './runstate.ts'
5
6const FOREGROUND_MAX_MS = 600000
7
8const closePolicy = (v: unknown): ClosePolicy => (v === 'success' || v === 'never' ? v : 'always')
9
10const SEND = 'copy it to a folder they can open (your scratchpad or the working directory) and send it to them as a file to render.'
11
12// For the model, where nothing in this session draws mod panes: how to show the run to the user.
13// A foreground call has ended, so its page exists or never will; a background one names its page
14// in its report (a "run page:" line) when it ends. Elsewhere the report's line is enough.
15// A call cut off by its timeout has returned for good while its run goes on (in a pane):
16// no report will name the page, so the note names where the poller writes it. A bare run (no
17// host file: outside herdr, no pane to be had) ended with the call and gets no page.
18type CallEnd = 'done' | 'background' | 'interrupted'
19async function pageNote($: Api, id: string, end: CallEnd, dir: string): Promise<string | undefined> {
20 const page = end === 'done' ? await settleRun($, id) : undefined // ends the run here even if it left no exit
21 if ((await $.session.surfaces()).length > 0) return undefined
22 if (end === 'background') return `codex-herdr-pane: nothing in this session draws mod panes. If this delegation's report ends with a "run page:" line, show the run to the user: ${SEND}`
23 if (end === 'interrupted') {
24 const host = (await text($, `${dir}/host`))?.trim()
25 if (host !== 'herdr' && host !== 'claude') return undefined
26 return `codex-herdr-pane: nothing in this session draws mod panes. This call timed out while the delegation runs on, so no report will follow. Once it ends, its page is ${dir}/run.html (the user can also type /codex-runs). When that file exists, show the run to the user: ${SEND}`
27 }
28 return page ? `codex-herdr-pane: nothing in this session draws mod panes, so show the run to the user through its page, ${page}: ${SEND}` : undefined
29}
30
31export const register: Register = (on, options) => {
32 // layout right/down is a fixed split of Claude's pane; anything else tiles. `direction` is the
33 // legacy option, honoured only when layout is auto or not configured.
34 const layout = typeof options.layout === 'string' && options.layout !== 'auto' ? options.layout : undefined
35 const fixedDown = layout === 'down' || (layout === undefined && options.direction === 'down')
36 const fixed = layout === 'right' || fixedDown
37 const direction = fixedDown ? 'down' : 'right'
38 const maxPanes = typeof options.maxPanes === 'number' && options.maxPanes >= 1 ? Math.floor(options.maxPanes) : 6
39 const closePane = closePolicy(options.closePane)
40 const closeDelay = typeof options.closeDelay === 'number' ? options.closeDelay : 3
41 const tailLines = typeof options.tailLines === 'number' ? options.tailLines : 200
42 const maxReturnLines = typeof options.maxReturnLines === 'number' && options.maxReturnLines >= 0 ? Math.floor(options.maxReturnLines) : 60
43 const viewer = options.viewer === 'plain' ? 'plain' : 'textual'
44 const leanProfile = options.leanProfile !== false
45 const leanProfileName = typeof options.leanProfileName === 'string' && options.leanProfileName.trim() ? options.leanProfileName.trim() : 'lean'
46 const runsDir = typeof options.ccxRunsDir === 'string' && options.ccxRunsDir.trim() ? options.ccxRunsDir.trim() : undefined
47 // auto: outside herdr, a run is followed in a Claude Code pane (runpane.tsx); off: run as asked.
48 // Every run is followed to its end either way, for its page (run.html).
49 const claudePane = options.claudePane === 'auto'
50 runPane(on, { closePane, closeDelay })
51 let n = 0
52
53 on('tool.call', { tool: 'Bash' }, async ($, e, next) => {
54 if (!isDelegation(e.command)) return next(e)
55 n += 1
56 const id = `${Date.now().toString(36)}${n}`
57 $.ui.toast(claudePane ? 'Delegation: opening a pane' : 'Delegation: opening a herdr pane')
58 let tmpdir: string | undefined, home: string | undefined
59 try { tmpdir = await $.env.get('TMPDIR') } catch { /* /tmp */ }
60 try { home = await $.env.get('HOME') } catch { /* not followed */ }
61 const runDir = runDirPath(home, id)
62 const script = buildWrapper(e.command, {
63 direction, layout: fixed ? 'fixed' : 'tile', maxPanes, closePane, closeDelay, tailLines, maxReturnLines, viewer, leanProfile, leanProfileName, runsDir, root: $.plugin.root, id,
64 claudePane, runDir,
65 })
66 // Followed by what the script writes in $dir/host: claude (a Claude Code pane), herdr, or none (bare)
67 if (runDir) await trackRun($, { id, dir: runDir, label: paneLabel(e.command) }).catch(() => {})
68 // The script goes to a file written here, so the call the harness shows (and quotes in the
69 // notice of a background call) is the two-line stub. If the write fails or the file is not
70 // there, the call carries the whole script, as before 0.7.0.
71 let command = script
72 try {
73 const path = scriptPath(tmpdir, id)
74 await $.fs.write(path, script)
75 if (await $.fs.exists(path)) command = stubFor(path)
76 } catch { /* keep the inline script */ }
77 // A foreground call may wait on a long Codex run, so one with no timeout gets the
78 // longest wait the Bash tool allows. A timeout the caller set is kept, so a call meant
79 // to give up early still does; the pane keeps running either way.
80 const timeout = e.run_in_background || typeof e.timeout === 'number' ? e.timeout : FOREGROUND_MAX_MS
81 const ran = await next({ ...e, command, timeout })
82 if (!runDir) return ran
83 // Never throws: the .catch below would run the delegation a second time
84 try {
85 await callEnded($, id, ran.result === undefined)
86 if (ran.result === undefined) return ran
87 const interrupted = Boolean((ran.result as { interrupted?: boolean }).interrupted)
88 const end: CallEnd = e.run_in_background ? 'background' : interrupted ? 'interrupted' : 'done'
89 const note = await pageNote($, id, end, runDir)
90 return note ? { ...ran, context: [...(ran.context ?? []), note] } : ran
91 } catch {
92 return ran
93 }
94 }).catch((_$, e, next) => next(e)) // a failure here must never block the delegation
95}
96hooks/wrap.ts 183 lines1// Pure helpers for the codex-herdr-pane mod: build the shell text that runs a
2// delegation (see tools.ts) in a herdr pane.
3
4import { MARKER, TOOLS, isStandalone, redirectsStdout, toolFor, type DelegationTool } from './tools.ts'
5
6export { MARKER, isCodexDelegation, isDelegation, paneLabel } from './tools.ts'
7import { paneLabel } from './tools.ts'
8import { NL_FN } from './mdpipe.ts'
9import { ROUTES, paneScript } from './panescript.ts'
10import { splitSection, unregisterCmd } from './layout.ts'
11
12export type ClosePolicy = 'always' | 'success' | 'never'
13
14export type WrapOptions = {
15 direction: 'right' | 'down' // the fixed split used when layout is 'fixed'
16 layout: 'tile' | 'fixed' // tile: a grid beside Claude (layout.ts); fixed: always split Claude's pane in `direction`
17 maxPanes: number // tile only: live delegation panes allowed before a run goes without a pane
18 closePane: ClosePolicy
19 closeDelay: number
20 tailLines: number
21 maxReturnLines?: number // the most lines of each returned section (answer, log tail, rest of command); 0 or unset: no cap
22 viewer: 'textual' | 'plain'
23 root: string // the plugin's folder: holds viewer/codex_view.py, clean.awk and codex.jq
24 leanProfile: boolean // add -p <leanProfileName> to codex exec when that profile file exists
25 leanProfileName: string
26 id: string
27 runsDir?: string // ccx's run-log directory; the pane's CCX_RUNS_DIR overrides it
28 // Outside herdr, run the delegation headless into runDir instead of running it bare, so the
29 // Claude Code pane (runpane.tsx) can follow it.
30 claudePane?: boolean
31 // The run directory (runDirPath), named by the id so the hook can follow the run to its end
32 runDir?: string
33}
34
35// The hook writes the whole wrapper to <TMPDIR>/<SCRIPT_PREFIX><id>.sh itself and the Bash call
36// becomes this stub, so the harness's notice of a background call quotes two lines, not the script.
37export const SCRIPT_PREFIX = 'codex-herdr-run.'
38export const scriptPath = (tmpdir: string | undefined, id: string): string => {
39 const dir = tmpdir && tmpdir.startsWith('/') ? tmpdir.replace(/\/+$/, '') : '/tmp'
40 return `${dir || ''}/${SCRIPT_PREFIX}${id.replace(/[^A-Za-z0-9]/g, '')}.sh`
41}
42// Where run directories live: under the user's own cache, never a shared /tmp, so no other user
43// can make one of these paths before the run does (the hook reads and writes there by name).
44export const runsRoot = (home: string): string => `${home.replace(/\/+$/, '')}/.cache/codex-herdr-pane/runs`
45// A run's directory, named by the id so the hook knows it before the script runs; undefined
46// without a usable HOME (then the run gets a mktemp directory and is not followed)
47export const runDirPath = (home: string | undefined, id: string): string | undefined =>
48 home && home.startsWith('/') ? `${runsRoot(home)}/${id.replace(/[^A-Za-z0-9]/g, '')}` : undefined
49const shq = (s: string) => `'${s.replace(/'/g, `'\\''`)}'`
50export const stubFor = (path: string): string => `${MARKER}\nexec bash ${shq(path)}`
51
52const word =(s: string) => s.replace(/[^A-Za-z0-9 _.:\/=+-]/g, '')
53
54function heredoc(path: string, delim: string, body: readonly string[]): string[] {
55 return [`cat > "${path}" <<'${delim}'`, ...body, delim]
56}
57
58// What the Bash call returns to Claude. Codex (and headless claude, the same way): the status, then the answer when
59// there is one, else the tail of the cleaned log. ccx: what an unwrapped run
60// returns, its own stdout (status line and result) and its stderr.
61// A chained call (isStandalone in tools.ts) adds, for codex, a header and the whole
62// original command's captured stdout, then its stderr; for ccx that capture already
63// is the status line and result, so it is returned as is.
64//
65// Every section but the status line goes through cap_file: the first __M lines, then one
66// "... N more lines in <file>" line naming the file that holds them. __M=0 returns it whole,
67// and a section of at most __M lines is returned byte for byte as `cat` and nl_if_open did.
68const CAP_FN = 'cap_file() { local n; n=$(awk \'END{print NR}\' "$1"); if [ "$__M" -gt 0 ] && [ "$n" -gt "$__M" ]; then head -n "$__M" "$1"; echo "... $((n - __M)) more lines in ${2:-$1}"; else cat "$1"; nl_if_open "$1"; fi; }'
69// cap_tail is cap_file for a log's end, where the error is: the LAST __M lines, led by a line naming how many earlier ones $2 (the whole log) holds.
70const CAP_TAIL_FN = 'cap_tail() { local n t; n=$(awk \'END{print NR}\' "$1"); if [ "$__M" -gt 0 ] && [ "$n" -gt "$__M" ]; then t=$(awk \'END{print NR}\' "${2:-$1}"); echo "... $((t - __M)) earlier lines in ${2:-$1}"; tail -n "$__M" "$1"; else cat "$1"; fi; nl_if_open "$1"; }'
71
72// The run's page (run.html), named last in the report only once it exists. The hook writes it
73// moments after the run ends, so a followed run (one with a host file) waits for it up to
74// CODEX_HERDR_PAGE_WAIT tenths of a second (30).
75const PAGE_LINE = [
76 'if [ -s "$__D/host" ]; then __w=0; while [ ! -f "$__D/run.html" ] && [ "$__w" -lt "${CODEX_HERDR_PAGE_WAIT:-30}" ]; do sleep 0.1; __w=$((__w+1)); done; fi',
77 '[ -f "$__D/run.html" ] && echo "run page: $__D/run.html"',
78]
79
80function report(o: WrapOptions, tool: DelegationTool, chained: boolean): string[] {
81 const cap = Math.max(0, Math.floor(o.maxReturnLines ?? 0)) || 0
82 const head = [NL_FN, CAP_FN, CAP_TAIL_FN, `__M=${cap}`]
83 if (ROUTES[tool.filter].report === 'stdout') {
84 return [...head, '__X=$(cat "$__D/exit")', 'cap_file "$__D/out.txt"', 'cat "$__D/err.log" >&2; nl_if_open "$__D/err.log" >&2', ...PAGE_LINE]
85 }
86 const tail = Math.max(1, Math.floor(o.tailLines))
87 const who = chained ? 'command' : tool.name
88 return [
89 ...head,
90 '__X=$(cat "$__D/exit")',
91 '__T=$(( $(cat "$__D/end" 2>/dev/null || date +%s) - $(cat "$__D/start" 2>/dev/null || date +%s) ))',
92 '__K=$(cat "$__D/usage" 2>/dev/null)',
93 `echo "${who} exited $__X after \${__T}s\${__K:+, $__K}; raw log $__D/raw.log, answer file $__D/last.md"`,
94 // on failure the end of clean.log: its last __M lines, led by a pointer to the earlier ones in clean.log
95 'if [ "$__X" = 0 ] && [ -s "$__D/last.md" ]; then cap_file "$__D/last.md"; else tail -n ' + tail + ' "$__D/clean.log" > "$__D/tail.out"; cap_tail "$__D/tail.out" "$__D/clean.log"; fi',
96 ...(chained ? [
97 // rest.out / rest.err: orig.out / orig.err with the pane's noise rules applied (panescript.ts)
98 'if [ -s "$__D/rest.out" ] || [ -s "$__D/rest.err" ]; then echo "--- output of the rest of the command ---"; cap_file "$__D/rest.out"; cap_file "$__D/rest.err" >&2; fi',
99 ] : []),
100 ...PAGE_LINE,
101 ]
102}
103
104// The shim the pane sources before the command, written beside it, for a tool whose route has one
105function shimSection(tool: DelegationTool, delim: string): string[] {
106 const lines = ROUTES[tool.filter].shim
107 return lines ? heredoc('$__D/shim.sh', delim, lines) : []
108}
109
110function closeSection(o: WrapOptions): string[] {
111 const delay = Math.max(0, Math.floor(o.closeDelay))
112 const when = o.closePane === 'always' ? 'true' : o.closePane === 'success' ? '[ "$__X" = 0 ]' : 'false'
113 return [
114 `if ${when}; then ( sleep ${delay}; herdr pane close "$__P" >/dev/null 2>&1; ${unregisterCmd(o)} ) >/dev/null 2>&1 & fi`,
115 'exit "$__X"',
116 ]
117}
118
119// Old leftovers in TMPDIR, removed in the background at each run after CODEX_HERDR_KEEP_DAYS days
120// (7): a script the hook wrote for a call that never ran (a denied call), run directories and
121// ccx claims. Days, not hours, for the scripts too: another session's call can wait a long time
122// at its approval prompt, and its script must still be there when it is approved.
123const SWEEP = '( T="${TMPDIR:-/tmp}"; K="${CODEX_HERDR_KEEP_DAYS:-7}"; '
124 + 'find "$T" -maxdepth 1 -type f -name \'' + SCRIPT_PREFIX + '*.sh\' -mtime +"$K" -delete; '
125 + 'find "$T" -maxdepth 1 -type d -name \'codex-herdr.*\' -mtime +"$K" -exec rm -rf {} +; '
126 + 'find "$T/codex-herdr-ccx-claims-$(id -u)" -mindepth 1 -maxdepth 1 -type d -name \'*.jsonl\' -mtime +"$K" -exec rm -rf {} +; '
127 + 'find "$HOME/.cache/codex-herdr-pane/runs" -mindepth 1 -maxdepth 1 -type d -mtime +"$K" -exec rm -rf {} +; '
128 + ') >/dev/null 2>&1 &'
129
130export function buildWrapper(command: string, o: WrapOptions): string {
131 const tool = toolFor(command) ?? TOOLS[0]!
132 const chained = !isStandalone(command)
133 const orig = `__CODEX_HERDR_ORIG_${o.id}__`
134 const shim = `__CODEX_HERDR_SHIM_${o.id}__`
135 const pane = `__CODEX_HERDR_PANE_${o.id}__`
136 const claude = Boolean(o.claudePane && o.runDir)
137 const mktemp = '__D=$(mktemp -d "${TMPDIR:-/tmp}/codex-herdr.XXXXXX") || exit 1'
138 return [
139 MARKER,
140 // mkdir without -p for the run itself: a path already there is never reused (mktemp instead,
141 // and the hook, finding no host file there, does not follow it)
142 o.runDir
143 ? `__R=${shq(o.runDir.replace(/\/[^/]*$/, ''))}; __D=${shq(o.runDir)}; { mkdir -p "$__R" && chmod 700 "$__R" && mkdir -m 700 "$__D"; } 2>/dev/null || { ${mktemp}; }`
144 : mktemp,
145 // A script the hook wrote (see stubFor) moves into the run directory, so it is kept with the logs
146 'case "$0" in */' + SCRIPT_PREFIX + '*.sh) mv "$0" "$__D/wrapper.sh" 2>/dev/null;; esac',
147 SWEEP,
148 ...heredoc('$__D/orig.sh', orig, [command]),
149 ...(claude ? [
150 ...shimSection(tool, shim),
151 ...heredoc('$__D/pane.sh', pane, paneScript(o, tool, chained, !chained && !redirectsStdout(command))),
152 `printf '%s' ${JSON.stringify(word(paneLabel(command)))} > "$__D/label"`,
153 // Outside herdr, or without the binary: the same run with no viewer, which the Claude Code
154 // pane follows (host tells it which runs are its own), then the same report.
155 'if [ "${HERDR_ENV:-}" != 1 ] || ! command -v herdr >/dev/null 2>&1; then',
156 ' echo claude > "$__D/host"',
157 ` bash "$__D/pane.sh" "$__D" ${shq(o.root)} none ${chained ? 1 : 0} </dev/null >/dev/null 2>&1`,
158 ...report(o, tool, chained).map((l) => ' ' + l),
159 ' exit "$__X"',
160 'fi',
161 ] : [
162 // Outside herdr, or without the binary, run the command exactly as asked.
163 'if [ "${HERDR_ENV:-}" != 1 ] || ! command -v herdr >/dev/null 2>&1; then exec bash "$__D/orig.sh"; fi',
164 ...shimSection(tool, shim),
165 ...heredoc('$__D/pane.sh', pane, paneScript(o, tool, chained, !chained && !redirectsStdout(command))),
166 `printf '%s' ${JSON.stringify(word(paneLabel(command)))} > "$__D/label"`,
167 ]),
168 ...splitSection(o),
169 'if [ -z "$__P" ]; then exec bash "$__D/orig.sh"; fi',
170 // Only now is there a herdr pane that will end the run: the hook follows it to its end, for the page
171 'echo herdr > "$__D/host"',
172 `herdr pane run "$__P" bash "$__D/pane.sh" "$__D" ${shq(o.root)} ${o.viewer} ${chained ? 1 : 0} >/dev/null`,
173 // After the run, so the shell's own title for the typed command does not replace it
174 'herdr pane rename "$__P" "$(cat "$__D/label")" >/dev/null 2>&1 || true',
175 'while [ ! -f "$__D/exit" ]; do',
176 ` herdr pane get "$__P" >/dev/null 2>&1 || { ${unregisterCmd(o)}; echo "the herdr pane $__P was closed before ${tool.name} finished (log: $__D/raw.log)"; date +%s > "$__D/end"; echo 130 > "$__D/exit"; exit 130; }`,
177 ' sleep 1',
178 'done',
179 ...report(o, tool, chained),
180 ...closeSection(o),
181 ].join('\n')
182}
183hooks/runpane.tsx 47 lines1// The Claude Code side of a delegation run: outside herdr (claudePane auto) a run is headless and
2// is drawn here, in a pane of Claude Code's own (one per run; several are tabs). runstate.ts
3// follows every run to its end and writes its page; runview.tsx draws the pane. /codex-runs shows
4// the newest run: in its pane where a surface draws one, else as text in the chat.
5
6import type { EngineInterface as Api, On } from 'claude-code'
7import { summarizeRun } from './livedoc.ts'
8import { loadParts, renderPane } from './runview.tsx'
9import { PANE_PREFIX, allRuns, paneId, poll, runStatus, setPaneOptions, type PaneOptions } from './runstate.ts'
10
11export { paneId, PANE_PREFIX } from './runstate.ts'
12
13// /codex-runs: the newest run in its pane, opened as the person asked (placed at any width)
14async function showLatest($: Api) {
15 const latest = Object.values(await allRuns($)).filter((r) => r.host !== 'pending').sort((a, b) => b.registered - a.registered)[0]
16 if (!latest) return { text: 'No delegation run is followed in this session yet.' }
17 // a live herdr run is drawn by its herdr pane; this side reads it only once it ends
18 if (latest.host === 'herdr' && latest.exit === undefined) return { text: `${latest.label} is running in its herdr pane.` }
19 const opened = await $.ui.open({ id: paneId(latest.id), title: latest.label.slice(0, 60), focus: true })
20 if (opened.isPlaced) return { text: `Showing ${latest.label}.` }
21 // No surface draws the pane (a cloud session viewed in the app, a -p run): the run goes in the chat
22 const { parts } = await loadParts($, latest)
23 const head = `${latest.label}: ${runStatus(latest, await $.clock.now())}`
24 const page = latest.page ? `\n\nAs a page with collapsible tool results: ${latest.page}` : ''
25 return { text: summarizeRun(parts, head, latest.answer, `${latest.dir}/clean.md`) + page }
26}
27
28export function runPane(on: On, o: PaneOptions) {
29 setPaneOptions(o)
30 // A reload keeps $.state but not the timer: follow the runs still live
31 on('session.start', async ($, e, next) => {
32 await $.command.register({ name: 'codex-runs', description: 'Show the newest Codex, ccx or Claude delegation run in a pane' })
33 if (Object.values(await allRuns($)).some((r) => r.exit === undefined)) poll($)
34 return next(e)
35 })
36
37 on('command.run', { command: 'codex-runs' }, async ($) => showLatest($))
38
39 on('ui.render', { component: 'Pane' }, async ($, e, next) => {
40 if (!e.requestId.startsWith(PANE_PREFIX)) return next(e)
41 const ui = $.ui.resolve(e)
42 const r = (await allRuns($))[e.requestId.slice(PANE_PREFIX.length)]
43 if (!r) return <ui.Text dimColor>This run is no longer followed.</ui.Text>
44 return renderPane(ui, $, r, await $.clock.now())
45 })
46}
47hooks/runstate.ts 201 lines1// Follows each delegation's run directory (wrap.ts) from the moment the hook sees the call to the
2// run's end, then writes run.html (runhtml.ts). The state holds what the pane needs to know, not
3// the log: the pane reads clean.md when it draws (runview.tsx).
4//
5// One step per run at a time (stepRun): the poll timer and the tool.call hook's settleRun both go
6// through it, so a run is finished, its page written and its pane closed once.
7
8import { atom, read, update } from 'claude-code'
9import type { EngineInterface as Api } from 'claude-code'
10import type { RunView } from '../types'
11import { parseRun } from './livedoc.ts'
12import { runPage } from './runhtml.ts'
13import type { ClosePolicy } from './wrap.ts'
14
15export const PANE_PREFIX = 'codex-run-'
16export const paneId = (id: string) => PANE_PREFIX + id
17const POLL_MS = 700
18const NO_HOST_MS = 30000 // after the call returned with no host file: it ran bare, or never ran
19const STALL_MS = 30 * 60000 // a run whose log has not grown for this long is given up on
20const KEEP_DONE = 20
21const MAX_ANSWER = 20000
22
23const runs = atom({ plugin: 'codex-herdr-pane', key: 'runs' } as const, {})
24
25// Every followed run, by id (the state is read here only: the scan needs the atom in this module)
26export async function allRuns($: Api): Promise<Record<string, RunView>> {
27 return read($, runs)
28}
29
30export type PaneOptions = { closePane: ClosePolicy; closeDelay: number }
31let opts: PaneOptions = { closePane: 'always', closeDelay: 3 }
32export function setPaneOptions(o: PaneOptions) { opts = o }
33
34let timer: { cancel: () => void } | undefined
35let ticking = false
36const inflight = new Map<string, Promise<void>>()
37
38export const clock = (ms: number) => {
39 const s = Math.max(0, Math.floor(ms / 1000)), m = Math.floor(s / 60)
40 return m ? `${m}m${String(s % 60).padStart(2, '0')}s` : `${s}s`
41}
42
43export function runStatus(r: RunView, now: number): string {
44 const took = clock((r.ended ?? now) - (r.started ?? r.registered))
45 if (r.exit === undefined) return `running for ${took}`
46 if (r.exit === 'gone') return `ended without an exit status after ${took}`
47 return `${r.exit === '0' ? 'finished' : `failed with exit ${r.exit}`} in ${took}${r.usage ? ` · ${r.usage}` : ''}`
48}
49
50export async function patch($: Api, id: string, fn: (r: RunView) => RunView | undefined) {
51 await update($, runs, (all) => {
52 const cur = all[id]
53 if (!cur) return all
54 const next = fn(cur)
55 const { [id]: _gone, ...rest } = all
56 return next ? { ...rest, [id]: next } : rest
57 })
58}
59
60export async function text($: Api, path: string) {
61 try { return await $.fs.read(path) } catch { return undefined }
62}
63
64async function num($: Api, path: string) {
65 return Number((await text($, path))?.trim()) || 0
66}
67
68// The host the wrapper wrote: claude opens this run's pane, herdr is only followed to its end
69async function resolveHost($: Api, r: RunView, now: number) {
70 const host = (await text($, `${r.dir}/host`))?.trim()
71 if (host !== 'claude' && host !== 'herdr') {
72 if (r.callEnded !== undefined && now - r.callEnded > NO_HOST_MS) await patch($, r.id, () => undefined)
73 return
74 }
75 const label = (await text($, `${r.dir}/label`))?.trim() || r.label
76 await patch($, r.id, (c) => ({ ...c, host, label }))
77 if (host !== 'claude') return
78 // Opened unasked, a surface may hold it undrawn (a narrow one); /codex-runs opens it as asked
79 const opened = await $.ui.open({ id: paneId(r.id), title: label.slice(0, 60) })
80 if (!opened.isPlaced) $.ui.log(`codex-herdr-pane: the run's pane waits undrawn (${opened.reason}); /codex-runs shows it`)
81}
82
83// The page, written unless run.html is already there as a link (only this user writes in the
84// run directory, so that is never expected; it is refused all the same)
85async function writePage($: Api, r: RunView, now: number): Promise<string | undefined> {
86 const page = `${r.dir}/run.html`
87 try {
88 if (await $.fs.exists(page) && (await $.fs.stat(page)).isLink) return undefined
89 const md = (await text($, `${r.dir}/clean.md`)) ?? ''
90 await $.fs.write(page, runPage({
91 label: r.label, status: runStatus(r, now), ok: r.exit === '0', parts: parseRun(md, true), answer: r.answer, logPath: `${r.dir}/clean.md`,
92 }))
93 return page
94 } catch (err) {
95 $.ui.log(`codex-herdr-pane: run page not written: ${String(err)}`, { to: 'debug' })
96 return undefined
97 }
98}
99
100// Ends a run: its exit (or 'gone'), usage, answer and times, then its page; a claude pane closes
101// per closePane
102async function finish($: Api, r: RunView, exit: string, now: number) {
103 const usage = (await text($, `${r.dir}/usage`))?.trim() || undefined
104 const answer = ((await text($, `${r.dir}/last.md`)) ?? '').trim().slice(0, MAX_ANSWER)
105 const start = await num($, `${r.dir}/start`)
106 const end = await num($, `${r.dir}/end`) || Math.floor(now / 1000)
107 const done: RunView = { ...r, exit, usage, answer, started: start * 1000 || r.started, ended: end * 1000, tab: exit === '0' && answer ? 'answer' : 'live' }
108 const page = await writePage($, done, now)
109 await patch($, r.id, (c) => ({ ...c, exit, usage, answer, started: done.started, ended: done.ended, page, tab: c.tab === 'live' && done.tab === 'answer' ? 'answer' : c.tab }))
110 if (r.host !== 'claude') return
111 const close = opts.closePane === 'always' || (opts.closePane === 'success' && exit === '0')
112 if (close) $.clock.after(Math.max(0, opts.closeDelay) * 1000, () => void $.ui.close({ id: paneId(r.id) }).catch(() => {}))
113}
114
115async function step($: Api, r: RunView, now: number) {
116 if (r.exit !== undefined) return
117 if (r.host === 'pending') return resolveHost($, r, now)
118 const exit = (await text($, `${r.dir}/exit`))?.trim()
119 if (exit) return finish($, r, exit, now)
120 let size = r.size
121 try { size = (await $.fs.stat(`${r.dir}/clean.md`)).size } catch { /* not there yet */ }
122 if (size !== r.size) {
123 const start = await num($, `${r.dir}/start`)
124 await patch($, r.id, (c) => ({ ...c, size, lastChange: now, started: start * 1000 || c.started }))
125 } else if (now - r.lastChange > STALL_MS) {
126 await finish($, r, 'gone', now)
127 }
128}
129
130// One piece of work at a time per key: a second caller waits for the one in flight, then runs its
131// own (which reads the run as it is by then)
132export function serial(key: string, work: () => Promise<void>): Promise<void> {
133 const before = inflight.get(key) ?? Promise.resolve()
134 const p: Promise<void> = before.catch(() => {}).then(work).finally(() => { if (inflight.get(key) === p) inflight.delete(key) })
135 inflight.set(key, p)
136 return p
137}
138
139async function exclusive($: Api, id: string, work: (r: RunView, now: number) => Promise<void>): Promise<void> {
140 return serial(id, async () => {
141 const r = (await read($, runs))[id]
142 if (r) await work(r, await $.clock.now())
143 })
144}
145
146export function stepRun($: Api, id: string): Promise<void> {
147 return exclusive($, id, (r, now) => step($, r, now))
148}
149
150async function tick($: Api) {
151 if (ticking) return
152 ticking = true
153 try {
154 const live = Object.values(await read($, runs)).filter((r) => r.exit === undefined)
155 if (live.length === 0) {
156 timer?.cancel(); timer = undefined
157 // a run registered while the state was read found the old timer and started none: start one
158 if (Object.values(await read($, runs)).some((r) => r.exit === undefined)) poll($)
159 return
160 }
161 for (const r of live) await stepRun($, r.id)
162 } finally { ticking = false }
163}
164
165export function poll($: Api) {
166 timer ??= $.clock.every(POLL_MS, () => void tick($).catch((err) => $.ui.log(`codex-herdr-pane: ${String(err)}`, { to: 'debug' })))
167}
168
169// Called by the tool.call hook for each delegation it wraps, before the call (and its approval)
170export async function trackRun($: Api, run: { id: string; dir: string; label: string }) {
171 const now = await $.clock.now()
172 const view: RunView = { ...run, host: 'pending', registered: now, size: 0, lastChange: now, answer: '', tab: 'live', open: {} }
173 // Every delegation is followed, so only the newest KEEP_DONE finished runs are kept
174 await update($, runs, (all) => {
175 const done = Object.values(all).filter((r) => r.exit !== undefined).sort((a, b) => b.registered - a.registered)
176 const drop = new Set(done.slice(KEEP_DONE).map((r) => r.id))
177 return { ...Object.fromEntries(Object.entries(all).filter(([id]) => !drop.has(id))), [run.id]: view }
178 })
179 poll($)
180}
181
182// Called once the Bash call returned: a refused call is dropped; otherwise the no-host clock starts
183export async function callEnded($: Api, id: string, refused: boolean) {
184 const now = await $.clock.now()
185 await patch($, id, (c) => (refused ? undefined : { ...c, callEnded: now }))
186}
187
188// For a foreground call that returned: its wrapper has ended, so the run has ended. Reads its end
189// now (or gives it up as 'gone' when it left none), so the page exists before the model reads the
190// result. The page's path, or undefined (a bare run, or one whose page could not be written).
191export async function settleRun($: Api, id: string): Promise<string | undefined> {
192 for (let i = 0; i < 3; i++) {
193 const r = (await read($, runs))[id]
194 if (!r) return undefined
195 if (r.exit !== undefined) return r.page
196 if (i < 2) await stepRun($, id)
197 else await exclusive($, id, (c, now) => (c.exit === undefined && c.host !== 'pending' ? finish($, c, 'gone', now) : Promise.resolve()))
198 }
199 return (await read($, runs))[id]?.page
200}
201hooks/tools.ts 138 lines1// The delegation tools the mod puts in a herdr pane: one table, one recogniser.
2// Adding a tool is one entry here; a new filter also needs its route in panescript.ts's
3// ROUTES (how it runs in the pane, its shim, its report), which the type system requires.
4
5export const MARKER = '# codex-herdr-pane'
6
7export type DelegationTool = {
8 name: string
9 // The tool at a command position: the start of the command, or after ; & | ( or
10 // a newline, behind optional VAR=value words (and a directory path if allowed).
11 pattern: RegExp
12 // The same invocation anywhere, with the subcommand captured: for the label.
13 loose: RegExp
14 title: (sub: string, text: string) => string // the pane title up to the colon; text is the label's prompt
15 filter: 'codex' | 'ccx' | 'claude' // how the pane turns the tool's output into a readable log
16}
17
18function invocation(name: string, subs: string, path: boolean) {
19 const bin = `${path ? '(?:\\S*\\/)?' : ''}${name}`
20 return {
21 pattern: new RegExp(`(?:^|[;&|(\\n])\\s*(?:[A-Za-z_][A-Za-z0-9_]*=\\S*\\s+)*${bin}\\s+(?:${subs})(?=\\s|$)`),
22 loose: new RegExp(`${name}\\s+(${subs})\\b`),
23 }
24}
25
26// Headless Claude: claude with -p or --print among its own words (on its own line, or across
27// backslash-newlines), bare or behind `dev exec [-d <dir>]` (a helper that runs its arguments in
28// a Docker Compose container). Bare means the word claude: the shim (shim.ts) is a shell function,
29// which a path such as /usr/local/bin/claude would bypass; behind dev exec any path is the
30// container's and dev() sees it. A first word naming a subcommand (claude mcp, claude plugin, ...)
31// is never a run, whatever follows it.
32const CLAUDE_SUBS = 'mcp|plugins?|auth|agents|attach|auto-mode|daemon|doctor|gateway|import|install|logs|purge|remote-control|respawn|rm|self-hosted-runner|setup-token|stop|kill|ultrareview|update'
33const WS = String.raw`(?:[ \t]|\\\n)+`
34const DEV_EXEC = String.raw`(?:dev${WS}exec${WS}(?:-d(?:${WS}|=)?\S+${WS})?(?:\S*\/)?)?`
35const claudePrint = {
36 pattern: new RegExp(String.raw`(?:^|[;&|(\n])\s*(?:[A-Za-z_][A-Za-z0-9_]*=\S*\s+)*${DEV_EXEC}claude`
37 + String.raw`(?!${WS}(?:${CLAUDE_SUBS})(?=\s|$))(?:${WS}[^\s;&|()]+)*?${WS}(?:-p|--print)(?=\s|$)`),
38 loose: /claude\b[^;&|\n]*?\s(-p|--print)(?=\s|$)/,
39}
40
41export const TOOLS: readonly DelegationTool[] = [
42 {
43 name: 'codex', ...invocation('codex', 'exec|e|review', false),
44 title: (sub) => (sub === 'review' ? 'cdx review' : 'cdx'), filter: 'codex', // short pane titles
45 },
46 {
47 name: 'ccx', ...invocation('ccx', 'task', true),
48 title: () => 'ccx task', filter: 'ccx',
49 },
50 {
51 name: 'claude', ...claudePrint,
52 title: (_sub, text) => (text.startsWith('/code-review') ? 'Claude review' : 'Claude task'), filter: 'claude',
53 },
54]
55
56const INFO_ONLY = /\s(?:--help|-h|--version|-V)(?=\s|$)/
57// A claude run that names its own --output-format is left as asked, unwrapped: its stdout (json,
58// text) is the caller's to read, and holds nothing the pane could show while it runs.
59const OWN_FORMAT = /\s--output-format(?:=|\s|$)/
60
61// Quoted strings and heredoc bodies hold prompts and text, not commands, so
62// they are blanked before the command is searched for an invocation.
63function blankText(command: string): string {
64 return command
65 .replace(/<<-?\s*(['"]?)(\w+)\1[^\n]*\n[\s\S]*?\n\s*\2(?=\n|$)/g, '')
66 .replace(/'[^']*'|"(?:\\.|[^"\\])*"/g, '""')
67}
68
69// The words of the invocation the tool's pattern found, up to the next separator
70function ownWords(tool: DelegationTool, text: string): string {
71 const at = tool.pattern.exec(text)?.index ?? 0
72 return text.slice(at).replace(/\\\n/g, ' ').replace(/^[;&|(\n]/, '').split(/[;&|\n]/)[0] ?? ''
73}
74
75// codex and ccx: an info flag anywhere means no run. claude: only its own words are looked at,
76// so `claude -p "x" | sort -h` is still a run.
77export function toolFor(command: string): DelegationTool | undefined {
78 if (command.includes(MARKER)) return undefined
79 const text = blankText(command)
80 const tool = TOOLS.find((t) => t.pattern.test(text))
81 if (!tool || tool.name !== 'claude') return tool && !INFO_ONLY.test(text) ? tool : undefined
82 const own = ownWords(tool, text)
83 return INFO_ONLY.test(own) || OWN_FORMAT.test(own) ? undefined : tool
84}
85
86// Standalone or chained. A call is STANDALONE when, with quoted text and heredoc
87// bodies blanked and every redirection removed, it is exactly one delegation
88// invocation: it starts at the tool (behind optional VAR=value words, or dev exec) and holds no
89// ; & | newline, backtick, $( or ( ) { }. Then the pane's stdout is the tool's own
90// raw stream (Codex JSONL), which the caller must not see, and the report alone is
91// returned. Anything else is CHAINED: other commands, or a pipe, whose output the
92// caller expects, so wrap.ts returns the report plus the whole command's captured
93// output and the shim gives codex's stdout the answer text instead of JSONL.
94const REDIRECT = /\s*(?:&>>?|\d*>>?|\d*<)&?[^\s;&|]*/g
95
96export function isStandalone(command: string): boolean {
97 if (!toolFor(command)) return false // the tool is then at a command position; no separator remains, so it is the first word
98 const text = blankText(command).replace(REDIRECT, ' ').trim()
99 return !/[;&|\n`(){}]|\$\(/.test(text)
100}
101
102// Whether the command sends stdout somewhere itself (>, >>, 1>, &>, >&), outside quoted text and
103// heredoc bodies. A standalone review that does keeps its stdout; the shim (shim.ts) is told so
104// by the wrapper, since the descriptor's type alone cannot tell the pane's pipe from the caller's.
105export const redirectsStdout = (command: string): boolean => /(?:^|[\s;&|])(?:1|&)?>/.test(blankText(command))
106
107export const isDelegation = (command: string): boolean => toolFor(command) !== undefined
108export const isCodexDelegation = isDelegation
109
110// What a codex review with no prompt looks at, from its flags; plain `codex review` reviews the
111// working tree.
112function reviewTarget(command: string): string {
113 const flag = (name: string) => new RegExp(`--${name}(?:=|\\s+)([^\\s;&|]+)`).exec(command)?.[1]
114 const base = flag('base'), commit = flag('commit')
115 if (base) return `changes against ${base}`
116 if (commit) return `commit ${commit.slice(0, 12)}`
117 return 'current changes'
118}
119
120// A short title for the pane: the start of the quoted prompt, else what a review looks at, else "running".
121// The first line of text of the first heredoc in the command, or undefined.
122function heredocText(command: string): string | undefined {
123 const body = new RegExp(`<<-?\\s*(['"]?)(\\w+)\\1[^\\n]*\\n([\\s\\S]*?)\\n\\s*\\2(?=\\n|$|\\))`).exec(command)?.[3]
124 return body?.split('\n').map((l) => l.trim()).find((l) => l !== '')
125}
126
127export function paneLabel(command: string): string {
128 const tool = toolFor(command) ?? TOOLS.find((t) => t.loose.test(command)) ?? TOOLS[0]!
129 const sub = tool.loose.exec(command)?.[1] ?? ''
130 const prompt = new RegExp(`${tool.loose.source}[^"'\\n]*?(["'])((?:\\\\.|(?!\\2)[^\\\\\\n])+)\\2`).exec(command)
131 // A prompt fed by a heredoc (codex exec "$(cat <<'EOF' … EOF)", or codex exec - <<EOF) is the
132 // label, its first line of text: the first quoted text is then its delimiter or an option's
133 // value (-C "$PWD")
134 const quoted = prompt?.[3]
135 const text = heredocText(command) || quoted?.replace(/\s+/g, ' ').trim() || (sub === 'review' ? reviewTarget(command) : '')
136 return `${tool.title(sub, text)}: ${text ? text.slice(0, 48).trim() : 'running'}`
137}
138hooks/mdpipe.ts 48 lines1// The Markdown half of the run log, and the noise rules for the text returned to Claude:
2// shell text shared by the pane script and the shim so each pipeline is written once.
3// `root` is a shell expression for the plugin folder ($ROOT in the pane, $__CODEX_ROOT in
4// the shim).
5//
6// mdcodex raw Codex stream on stdin -> Markdown on stdout. The noise rules run first
7// (on the raw lines, which are JSONL or noise), because once a noise line is
8// inside a fence clean.awk's line-start patterns no longer match it. joinfence.awk
9// then runs neighbouring text fences together (codex review is plain text, one
10// fence per line otherwise).
11// mdrest the rest of a chained command's output -> Markdown: the same clean.awk, then
12// rest.awk (drops a copy of the final answer), then md.jq's mdline.
13// restfilter the same rules as plain text (what the Bash call returns, and the rest of a chained
14// command in clean.log); $1 is the
15// "MCP note already made" file for that text.
16// MCP_SEEN_MD / MCP_SEEN_LOG are set by the pane script in a chained call only, so the MCP
17// note is made once per run directory instead of once per process.
18export function mdFunctions(root: string): string[] {
19 const jq = (mode: string) => `jq --unbuffered -R -r --arg mode ${mode} -f "${root}/viewer/codex.jq" 2>/dev/null`
20 const clean = (seen: string, review = '') => `awk -v seen="${seen}"${review} -f "${root}/viewer/clean.awk"`
21 const rest = `awk -v last="\${__CODEX_LAST:-}" -f "${root}/viewer/rest.awk"`
22 return [
23 `mdcodex() { ${clean('${MCP_SEEN_MD:-}', ' -v review=1')} | ${jq('md')} | awk -f "${root}/viewer/joinfence.awk"; }`,
24 `mdrest() { ${clean('${MCP_SEEN_MD:-}')} | ${rest} | ${jq('mdraw')}; }`,
25 `restfilter() { ${clean('$1')} | ${rest} | jq --unbuffered -R -r --arg mode rest -f "${root}/viewer/codex.jq" 2>/dev/null; }`,
26 ]
27}
28
29// jqs <mode>: stream.jq (claude's stream-json, for ccx and headless claude) in one of its modes;
30// every mode but the line-at-a-time ones (view, pane) reads the whole stream, so needs -n.
31export const streamJq = (root: string): string =>
32 `jqs() { local nf=; case "$1" in view|pane) ;; *) nf=-n;; esac; jq --unbuffered $nf -R -r --arg mode "$1" -f "${root}/viewer/stream.jq" 2>/dev/null; }`
33
34// mdwriter <clean.md> <status file>: claude's live Markdown writer, run as `tee ... >(mdwriter ...)`.
35// A process substitution's subshell holds a copy of tee's stdout, and the pipeline ends only once
36// every copy is closed. Were jq the subshell's last command, bash would exec it with that stdout
37// redirected, the copy would close at once and the pipeline would end without waiting for the
38// writer. The commands after jq keep the subshell, so the pipeline waits for the writer: they
39// record jq's status (a failed writer's clean.md is repaired by its caller) and drain stdin, so a
40// writer that died never cuts tee off with SIGPIPE and loses raw.log. mdcodex is waited for the
41// same way: it is a pipeline.
42export const MDWRITER_FN = 'mdwriter() { jqs md >> "$1"; echo "$?" > "$2"; cat > /dev/null; }'
43
44// Prints a newline when the file's last byte is not one (so a report never ends mid-line and
45// never gets a blank line it did not need). $(tail -c1) is empty exactly when the file is
46// empty or ends in a newline.
47export const NL_FN = 'nl_if_open() { [ -n "$(tail -c1 "$1" 2>/dev/null)" ] && echo; return 0; }'
48hooks/panescript.ts 256 lines1// The script that runs inside the herdr pane (see wrap.ts for the Bash-call side).
2
3import type { DelegationTool } from './tools.ts'
4import type { WrapOptions } from './wrap.ts'
5import { MDWRITER_FN, mdFunctions, streamJq } from './mdpipe.ts'
6import { CLAUDE_SHIM, SHIM } from './shim.ts'
7
8// The Textual versions the viewer was tested on, defined once. The `uv run --with` in the pane
9// script uses it; codex_view.py's inline header repeats it because a script header cannot
10// import, and register.test.ts fails if the two differ.
11export const TEXTUAL_REQ = 'textual>=8.2,<9'
12
13// Chained calls (isStandalone in tools.ts) capture the whole original command's
14// stdout and stderr into orig.out and orig.err while the same streams still feed the
15// pane's live log (clean.log), by two tees. The command's own status goes through
16// orig.st, since a pipeline reports the last stage.
17const RUN_ORIG = [
18 'run_orig() {',
19 ' { { "$@" 2>&1 1>&3 3>&-; echo $? > "$D/orig.st"; } | tee "$D/orig.err" >(mdrest >> "$D/clean.md") | restfilter "${MCP_SEEN_LOG:-}" >> "$D/clean.log"; } 3>&1 | tee "$D/orig.out" >(mdrest >> "$D/clean.md") | restfilter "${MCP_SEEN_LOG:-}" >> "$D/clean.log"',
20 ' return "$(cat "$D/orig.st")"',
21 '}',
22]
23
24// After a chained run: rest.out / rest.err are orig.out / orig.err put through the same
25// noise rules as clean.md (restfilter, mdpipe.ts); the report returns them. The
26// unfiltered streams stay in orig.out / orig.err.
27const MKREST = 'mkrest() { for s in out err; do restfilter "$D/mcp.ret.seen" < "$D/orig.$s" > "$D/rest.$s"; done; }'
28
29const USAGE = [
30 ' jq -R -r --arg mode usage -f "$ROOT/viewer/codex.jq" "$D/raw.log" 2>/dev/null | tail -n 1 > "$D/usage"',
31 ' [ -s "$D/usage" ] || { k=$(grep -A1 "^tokens used" "$D/clean.log" 2>/dev/null | tail -1); [ -n "$k" ] && echo "$k tokens" > "$D/usage"; }',
32 ' echo "$X" > "$D/exit.tmp"; date +%s > "$D/end"; mv "$D/exit.tmp" "$D/exit"',
33]
34
35// The background half of the pane for codex. Codex's output (JSONL for exec, plain
36// text for review, stderr merged) goes to raw.log untouched, then through codex.jq
37// (events become text, other lines pass) and clean.awk (noise rules) into clean.log.
38// A second consumer of the same tee, mdcodex (mdpipe.ts), writes clean.md live; once the
39// run ends clean.md is rebuilt whole from raw.log, which makes it complete however the
40// live writer was scheduled.
41// After the run, `usage` gets the token split from the same codex.jq, else the
42// "tokens used" figure; the report and the viewer both read that one file.
43const CODEX_RUN = [
44 ' __CODEX_PROFILE="__PROFILE__" __CODEX_LAST="$D/last.md" __CODEX_REVIEW_LAST=__REVIEWLAST__ bash -c \'. "$1/shim.sh"; . "$1/orig.sh"\' _ "$D" 2>&1 \\',
45 ' | tee "$D/raw.log" >(mdcodex >> "$D/clean.md") | jq --unbuffered -R -r -f "$ROOT/viewer/codex.jq" 2>/dev/null \\',
46 ' | awk -v review=1 -f "$ROOT/viewer/clean.awk" > "$D/clean.log"',
47 ' X="${PIPESTATUS[0]}"',
48 ' mdcodex < "$D/raw.log" > "$D/clean.md.tmp" && mv "$D/clean.md.tmp" "$D/clean.md"',
49 ...USAGE,
50]
51
52// Chained: the shim (shim.ts) feeds raw.log and clean.log itself and gives the
53// command's stdout the answer text.
54const CODEX_CHAIN_RUN = [
55 ' : > "$D/raw.log"; : > "$D/clean.log"',
56 ' export __CODEX_PROFILE="__PROFILE__" __CODEX_LAST="$D/last.md" __CODEX_CHAIN=1 __CODEX_D="$D" __CODEX_ROOT="$ROOT"',
57 ' run_orig bash -c \'. "$1/shim.sh"; . "$1/orig.sh"\' _ "$D"; X=$?',
58 ' mkrest',
59 ...USAGE,
60]
61
62// For claude: the shim (shim.ts) adds stream-json, so codex's route serves with stream.jq in
63// codex.jq's place: the output (stderr merged) goes to raw.log untouched, then through
64// stream.jq's pane mode (events become text, any other line passes) into clean.log; its md
65// mode writes clean.md live, and the run ends only once that writer has (mdwriter, mdpipe.ts),
66// so clean.md is whole without a second render; only a writer that failed has it rendered again
67// from raw.log. One more pass over raw.log (stream.jq's end mode) gives the usage, the tokens and
68// cost of every result, then the answer, the last result's text, which a chained call's shim has
69// already written. If that pass fails, the answer is read alone and the log says the usage could
70// not be, so a fault in one never costs the other. When stdout is the
71// caller's (a chained call, or a standalone one that redirects it: reviewToLast false) the shim
72// feeds the logs and writes the answer itself; __CODEX_LAST names the answer for rest.awk, which
73// drops its copy from the rest of the command.
74const CLAUDE_ENV = 'export __CLAUDE_LAST="$D/last.md" __CLAUDE_D="$D" __CLAUDE_ROOT="$ROOT"'
75const CLAUDE_END = [
76 ' if jqs end < "$D/raw.log" > "$D/end.out"; then',
77 ' { IFS= read -r u; if [ -n "$u" ]; then printf "%s\\n" "$u"; fi > "$D/usage"; [ -s "$D/last.md" ] || cat > "$D/last.md"; } < "$D/end.out"',
78 ' else',
79 ' : > "$D/usage"; [ -s "$D/last.md" ] || jqs answer < "$D/raw.log" > "$D/last.md"',
80 ' echo "*the usage could not be read from raw.log (stream.jq end pass failed)*" | tee -a "$D/clean.log" >> "$D/clean.md"',
81 ' fi; rm -f "$D/end.out"',
82 ' echo "$X" > "$D/exit.tmp"; date +%s > "$D/end"; mv "$D/exit.tmp" "$D/exit"',
83]
84const CLAUDE_RUN = [
85 ' ' + CLAUDE_ENV,
86 ' ' + MDWRITER_FN,
87 ' bash -c \'. "$1/shim.sh"; . "$1/orig.sh"\' _ "$D" 2>&1 | tee "$D/raw.log" >(mdwriter "$D/clean.md" "$D/md.st") | jqs pane > "$D/clean.log"',
88 ' X="${PIPESTATUS[0]}"',
89 ' [ "$(cat "$D/md.st" 2>/dev/null)" = 0 ] || { jqs md < "$D/raw.log" > "$D/clean.md.tmp" && mv "$D/clean.md.tmp" "$D/clean.md"; }; rm -f "$D/md.st"',
90 ...CLAUDE_END,
91]
92const CLAUDE_CHAIN_RUN = [
93 ' : > "$D/raw.log"; : > "$D/clean.log"',
94 ' ' + CLAUDE_ENV + ' __CLAUDE_CHAIN=1 __CODEX_LAST="$D/last.md"',
95 ' run_orig bash -c \'. "$1/shim.sh"; . "$1/orig.sh"\' _ "$D"; X=$?',
96 ' mkrest',
97 ...CLAUDE_END,
98]
99
100// For ccx: stdout (the status line and the result) goes to out.txt untouched and
101// stderr to err.log. A follower finds the run's stream-json log: a *.jsonl that was not in
102// the runs directory when the pane started and that no other pane has claimed (an atomic
103// mkdir per log name), so two overlapping runs never follow the same log and no timestamp
104// comparison is needed. A launch lock lets only one run at a time wait for its new log, so
105// the log a run claims is the one it started. It renders each complete line through stream.jq (text
106// into clean.log, Markdown into clean.md). It polls by line count, so a half-written line waits for its newline
107// and the final pass after the command ends takes whatever is left. The Markdown is rendered
108// from line 1 each time (a call and its result are paired across lines, so a chunk alone is
109// not enough) and only the new bytes are appended; the final pass (mode md) also shows a
110// call that never got a result.
111const CCX_RUN = [
112 ' RUNS="${CCX_RUNS_DIR:-}"; [ -n "$RUNS" ] || RUNS=__RUNS__; : > "$D/clean.log"',
113 // One claims folder per OS user, so another user's folder in a shared /tmp cannot block this one
114 ' CLAIMS="${TMPDIR:-/tmp}/codex-herdr-ccx-claims-$(id -u)"; mkdir -p -m 700 "$CLAIMS" 2>/dev/null',
115 // Which log is whose: a run may claim a new log only while it holds the launch lock and no
116 // other run's log is UNBOUND (a run in flight whose log could still appear). Waiters wait for
117 // the lock, so nothing else launches while a claimer looks. A holder releases the lock when it
118 // has claimed its log, or when CODEX_HERDR_CCX_WAIT (30 s) passes: then it marks itself unbound
119 // first, so its late log is never taken. A run that finds an unbound run marks itself unbound,
120 // releases the lock at once and claims nothing. A mark lasts until that run's command ends (or
121 // its process is gone). A holder that died is broken by its pid.
122 ' LOCK="$CLAIMS/launch.lock"; UNB="$CLAIMS/unbound"; mkdir -p "$UNB" 2>/dev/null; waited=0',
123 ' note() { echo "*$1*" | tee -a "$D/clean.log" >> "$D/clean.md"; }',
124 // A lock that cannot be made, and is not there, is not contention: the folder is not usable, so
125 // the run claims nothing (OK=0). Only one run at a time may break a dead holder's lock (the
126 // .break folder), and it checks the pid again first, so it never deletes a successor's lock.
127 ' OK=1; r=0; [ -d "$CLAIMS" ] && [ -w "$CLAIMS" ] || OK=0',
128 ' while [ "$OK" = 1 ] && ! mkdir "$LOCK" 2>/dev/null; do',
129 // the lock was released between the mkdir and this check: try again (a few times, then give up)
130 ' [ -d "$LOCK" ] || { r=$((r+1)); [ "$r" -lt 50 ] && continue; OK=0; break; }',
131 ' p=$(cat "$LOCK/pid" 2>/dev/null)',
132 ' if [ -n "$p" ] && ! kill -0 "$p" 2>/dev/null && mkdir "$LOCK.break" 2>/dev/null; then',
133 ' [ "$(cat "$LOCK/pid" 2>/dev/null)" = "$p" ] && rm -rf "$LOCK"; rmdir "$LOCK.break"; continue',
134 ' fi',
135 ' find "$CLAIMS" -maxdepth 1 -name launch.lock.break -mmin +1 -exec rmdir {} + 2>/dev/null',
136 ' [ "$waited" = 0 ] && { note "waiting for another ccx run to start its log"; waited=1; }',
137 ' sleep 0.1',
138 ' done; [ "$OK" = 1 ] && echo $$ > "$LOCK/pid"',
139 ' release() { [ "$(cat "$LOCK/pid" 2>/dev/null)" = "$$" ] && rm -rf "$LOCK"; return 0; }',
140 ' unbound() { for u in "$UNB"/*; do [ -e "$u" ] || continue; kill -0 "${u##*/}" 2>/dev/null && return 0; rm -f "$u"; done; return 1; }',
141 ' if [ "$OK" = 0 ]; then note "ccx claims folder not usable: $CLAIMS: no live log for this run"; fi',
142 ' CAN=$OK; if [ "$OK" = 1 ] && unbound; then CAN=0; : > "$UNB/$$"; release; note "another ccx run has a log not yet bound to its pane: no live log for this run"; fi',
143 ' ls -1 "$RUNS" 2>/dev/null | grep "\\.jsonl$" > "$D/before"',
144 ' if [ ! -d "$RUNS" ]; then printf "*ccx runs directory not found: %s (set ccxRunsDir or CCX_RUNS_DIR)*\\n" "$RUNS" | tee -a "$D/clean.log" >> "$D/clean.md"; fi',
145 ' claim() { ls -1 "$RUNS" 2>/dev/null | grep "\\.jsonl$" | grep -vxFf "$D/before" | while IFS= read -r c; do mkdir "$CLAIMS/$c" 2>/dev/null && { printf "%s\\n" "$RUNS/$c"; break; }; done; }',
146 ' W=0 # bytes of the Markdown render already in clean.md: it is only ever appended to',
147 ' render() { sed -n "$1,$2p" "$f" > "$D/chunk"; jqs view < "$D/chunk" >> "$D/clean.log"',
148 ' sed -n "1,$2p" "$f" | jqs "$3" > "$D/md.tmp"; tail -c +"$((W+1))" "$D/md.tmp" >> "$D/clean.md"; W=$(wc -c < "$D/md.tmp" | tr -d " "); }',
149 ' (',
150 ' f=""; n=0; T0=$(date +%s); gaveup=$((1 - CAN)); WAIT="${CODEX_HERDR_CCX_WAIT:-30}"',
151 ' while :; do',
152 ' done=0; [ -f "$D/cmddone" ] && done=1',
153 ' [ -z "$f" ] && [ "$gaveup" = 0 ] && f=$(claim)',
154 ' [ -n "$f" ] && release',
155 // A run whose log has not appeared in time marks itself unbound, gives the lock up and stops
156 // claiming: it never takes a later run's log, and no later run takes its late log. Its answer
157 // still comes from out.txt.
158 ' if [ -z "$f" ] && [ "$gaveup" = 0 ] && [ $(( $(date +%s) - T0 )) -ge "$WAIT" ]; then',
159 ' : > "$UNB/$$"; release; gaveup=1; note "ccx did not start its log within ${WAIT} s: no live log for this run"',
160 ' fi',
161 ' if [ -n "$f" ]; then',
162 ' t=$(wc -l < "$f" | tr -d " ")',
163 ' [ "$done" = 1 ] && render "$((n+1))" \'$\' md',
164 ' [ "$done" = 0 ] && [ "$t" -gt "$n" ] && { render "$((n+1))" "$t" mdlive; n=$t; }',
165 ' fi',
166 ' [ "$done" = 1 ] && break',
167 ' sleep 0.3',
168 ' done',
169 ' release',
170 ' ) &',
171 ' FOLLOW=$!',
172 ' if [ "$CHAIN" = 1 ]; then run_orig bash "$D/orig.sh"; X=$?; mkrest; cp "$D/rest.out" "$D/out.txt"; cp "$D/rest.err" "$D/err.log"',
173 ' else bash "$D/orig.sh" > "$D/out.txt" 2> "$D/err.log"; X=$?; fi',
174 ' cat "$D/err.log" > "$D/raw.log"',
175 ' touch "$D/cmddone"; wait $FOLLOW; release; rm -f "$UNB/$$"',
176 ' { printf "> %s\\n\\n" "$(head -n 1 "$D/out.txt")"; tail -n +2 "$D/out.txt"; } > "$D/last.md"',
177 ' echo "$X" > "$D/exit.tmp"; date +%s > "$D/end"; mv "$D/exit.tmp" "$D/exit"',
178]
179
180// What runs inside the pane: the tool in the background writing the logs, the
181// viewer in the foreground, a plain fallback if it cannot run.
182// Empty turns the profile off; the name is held to filename characters.
183const profileName = (o: WrapOptions) => (o.leanProfile ? o.leanProfileName.replace(/[^A-Za-z0-9_.-]/g, '') : '')
184
185// ccx's run-log directory when neither the ccxRunsDir setting nor CCX_RUNS_DIR names one: the
186// author's container layout, kept so existing setups keep working. A missing directory is
187// reported in the pane instead of leaving it empty.
188const DEFAULT_RUNS = '"$HOME/work/_shared/claude-container/runs"'
189const shq = (s: string) => `'${s.replace(/'/g, `'\\''`)}'`
190
191// How each tool's log filter (tools.ts) runs: one route per filter, so a new filter without
192// one is a type error rather than a silent fall back to codex's.
193// run the background half of the pane script. reviewToLast: a standalone call that does
194// not redirect stdout itself, so the final answer may go to the answer file (shim.ts)
195// shim sourced before the command, if the tool has one (shim.ts)
196// report answer: a status line, then the answer file or the log's end (codex, claude);
197// stdout: the tool's own stdout and stderr, as an unwrapped run returns (ccx)
198// streamJq the run reads claude's stream-json through stream.jq (mdpipe.ts)
199export type Route = {
200 run: (o: WrapOptions, chained: boolean, reviewToLast: boolean) => readonly string[]
201 shim?: readonly string[]
202 report: 'answer' | 'stdout'
203 streamJq: boolean
204}
205export const ROUTES: Record<DelegationTool['filter'], Route> = {
206 codex: {
207 run: (o, chained, reviewToLast) => (chained ? CODEX_CHAIN_RUN : CODEX_RUN)
208 .map((l) => l.replace('__PROFILE__', profileName(o)).replace('__REVIEWLAST__', reviewToLast ? '1' : '')),
209 shim: SHIM, report: 'answer', streamJq: false,
210 },
211 ccx: {
212 run: (o) => CCX_RUN.map((l) => l.replace('__RUNS__', () => (o.runsDir ? shq(o.runsDir) : DEFAULT_RUNS))),
213 report: 'stdout', streamJq: true,
214 },
215 claude: {
216 run: (_o, _chained, reviewToLast) => (reviewToLast ? CLAUDE_RUN : CLAUDE_CHAIN_RUN),
217 shim: CLAUDE_SHIM, report: 'answer', streamJq: true,
218 },
219}
220
221export function paneScript(o: WrapOptions, tool: DelegationTool, chained: boolean, reviewToLast = !chained): string[] {
222 const route = ROUTES[tool.filter]
223 const run = route.run(o, chained, reviewToLast)
224 return [
225 '#!/usr/bin/env bash',
226 'D="$1"; ROOT="$2"; VIEW="$3"; CHAIN="$4"',
227 ...RUN_ORIG,
228 ...mdFunctions('$ROOT'),
229 ...(route.streamJq ? [streamJq('$ROOT')] : []),
230 MKREST,
231 'if [ "$CHAIN" = 1 ]; then export MCP_SEEN_MD="$D/mcp.md.seen" MCP_SEEN_LOG="$D/mcp.log.seen"; fi',
232 // The pane's own title (the shell set one for the typed command) and a clear screen, so the
233 // command line herdr typed does not stay above the log
234 // VIEW none: a headless run the Claude Code pane follows (runpane.tsx), nothing drawn here
235 '[ "$VIEW" = none ] || printf "\\033]0;%s\\007\\033[H\\033[2J" "$(cat "$D/label" 2>/dev/null)"',
236 'date +%s > "$D/start"; : > "$D/clean.md"',
237 '(',
238 ...run,
239 ') &',
240 'RUN=$!',
241 'if [ "$VIEW" = none ]; then :',
242 'elif [ "$VIEW" = textual ] && command -v uv >/dev/null 2>&1 \\',
243 ' && uv run --quiet --no-project --with "' + TEXTUAL_REQ + '" python "$ROOT/viewer/codex_view.py" "$D"; then :',
244 'else',
245 ' clear; touch "$D/clean.log"; tail -f "$D/clean.log" & T=$!',
246 ' while [ ! -f "$D/exit" ]; do sleep 0.5; done; sleep 0.5; kill $T 2>/dev/null',
247 ' if [ -s "$D/last.md" ]; then',
248 ' printf "\\n--- answer ---\\n"',
249 ' if command -v glow >/dev/null 2>&1; then glow "$D/last.md"; else cat "$D/last.md"; fi',
250 ' fi',
251 'fi',
252 'wait $RUN',
253 ]
254}
255
256hooks/layout.ts 79 lines1// Where a delegation's herdr pane opens. The one place that knows the layout rules and the
2// registry of this session's live delegation panes; wrap.ts only splices the snippets in.
3//
4// herdr (0.9.0) has no equalize command. `pane split` takes --ratio (the share the old
5// pane keeps) and `pane resize` takes an unspecified --amount, so the policy below picks
6// the split target and direction so that every split is an even halving, which needs
7// neither. Verified: `pane split <id> --direction down --ratio 0.7` leaves the old pane 70%.
8//
9// Policy, by n = live delegation panes of this session (Claude's pane is C):
10// n=0 split C right -> C | 1
11// n=1 split pane 1 down -> 1 over 2
12// n=2 split pane 1 right -> top row 1 | 3
13// n=3 split pane 2 right -> bottom row 2 | 4 (a 2x2 grid beside C)
14// n>=4 split the newest pane down; n >= maxPanes: no pane, run in the calling shell.
15// The registry is $TMPDIR/codex-herdr-panes.<HERDR_PANE_ID>, one pane id per line in
16// creation order. An atomic mkdir lock covers prune, decide, split and append, so
17// delegations that start in the same second see each other.
18
19export type LayoutOptions = { layout: 'tile' | 'fixed'; direction: 'right' | 'down'; maxPanes: number }
20
21const NO_LOG = '>/dev/null 2>&1'
22const NODE_ID = `node -e 'let s="";process.stdin.on("data",d=>s+=d).on("end",()=>{try{console.log(JSON.parse(s).result.pane.pane_id)}catch(e){}})' 2>/dev/null`
23
24// The registry helpers. __lock gives up after about 20 s (then the run is plain) and
25// breaks a lock whose owner pid is dead.
26const HELPERS = [
27 '__REG="${TMPDIR:-/tmp}/codex-herdr-panes.${HERDR_PANE_ID:-cur}"',
28 '__lock() { local i=0 p; until mkdir "$__REG.lock" 2>/dev/null; do',
29 ' p=$(cat "$__REG.lock/pid" 2>/dev/null); if [ -n "$p" ] && ! kill -0 "$p" 2>/dev/null; then rm -rf "$__REG.lock"; continue; fi',
30 ' i=$((i+1)); [ "$i" -gt 200 ] && return 1; sleep 0.1; done; echo $$ > "$__REG.lock/pid"; }',
31 '__unlock() { rm -rf "$__REG.lock"; }',
32 // keep only entries whose pane still exists
33 '__prune() { local q; touch "$__REG"; : > "$__REG.new"; while read -r q; do',
34 ` [ -n "$q" ] && herdr pane get "$q" </dev/null ${NO_LOG} && echo "$q" >> "$__REG.new"; done < "$__REG"; mv "$__REG.new" "$__REG"; }`,
35 '__nth() { sed -n "$1p" "$__REG"; }',
36 // drop one pane id from the registry (called when its pane closes)
37 '__unreg() { __lock || return 0; grep -vxF "$1" "$__REG" > "$__REG.new"; mv "$__REG.new" "$__REG"; __unlock; }',
38]
39
40// Sets __P (empty when no pane was made) and, when the cap stopped it, __NOTE.
41function tileSection(o: LayoutOptions): string[] {
42 const max = Math.max(1, Math.floor(o.maxPanes))
43 const opts = '--cwd "$PWD" --no-focus 2>/dev/null'
44 return [
45 '__P=; __NOTE=',
46 'if __lock; then',
47 ' __prune; __n=$(wc -l < "$__REG" | tr -d " ")',
48 ` if [ "$__n" -ge ${max} ]; then __NOTE="herdr pane limit reached ($__n delegation panes open, layout limit ${max}): ran without a pane"`,
49 ' else',
50 ' case "$__n" in',
51 ' 0) __S="--current"; __DIR=right;;',
52 ' 1) __S=$(__nth 1); __DIR=down;;',
53 ' 2) __S=$(__nth 1); __DIR=right;;',
54 ' 3) __S=$(__nth 2); __DIR=right;;',
55 ' *) __S=$(__nth "$__n"); __DIR=down;;',
56 ' esac',
57 ` __P=$(herdr pane split $__S --direction $__DIR ${opts} | ${NODE_ID})`,
58 ' [ -n "$__P" ] && echo "$__P" >> "$__REG"',
59 ' fi',
60 ' __unlock',
61 'fi',
62 '[ -n "$__NOTE" ] && echo "$__NOTE"',
63 ]
64}
65
66function fixedSection(o: LayoutOptions): string[] {
67 return [`__P=$(herdr pane split --current --direction ${o.direction} --cwd "$PWD" --no-focus 2>/dev/null | ${NODE_ID})`]
68}
69
70// Shell lines that define the helpers and leave the new pane's id in __P.
71export function splitSection(o: LayoutOptions): string[] {
72 return o.layout === 'tile' ? [...HELPERS, ...tileSection(o)] : fixedSection(o)
73}
74
75// The shell expression run just before `herdr pane close "$__P"` and when the pane is gone.
76export function unregisterCmd(o: LayoutOptions): string {
77 return o.layout === 'tile' ? '__unreg "$__P"' : 'true'
78}
79hooks/livedoc.ts 106 lines1// Splits a run's clean.md into prose and folds for the Claude Code pane (runpane.tsx): the same
2// rules as viewer/codex_view.py's LiveDoc. A fold is a tool result between the markers md.jq
3// writes; a marker counts only outside a fenced block (CommonMark fences, as md.jq's
4// fence_open / fence_step), so tool output and prose cannot make one.
5
6export type RunPart =
7 | { kind: 'prose'; text: string }
8 | { kind: 'fold'; title: string; body: string; failed: boolean }
9
10type Fence = { c: string; n: number } | null
11
12const FOLD_OPEN = /^<!-- fold (open|closed): (.*) -->$/
13const FOLD_END = '<!-- /fold -->'
14const FENCE_OPEN = /^ {0,3}(`{3,}|~{3,})(.*)$/
15// The call's label (md.jq's toolline), the first line of a fold body
16const LABEL = /^\*\*[A-Za-z0-9_.:-]+\*\*( .*)?$/
17
18export function fenceStep(state: Fence, line: string): Fence {
19 if (state === null) {
20 const m = FENCE_OPEN.exec(line)
21 if (m && (m[1]![0] === '~' || !m[2]!.includes('`'))) return { c: m[1]![0]!, n: m[1]!.length }
22 return null
23 }
24 const close = new RegExp(`^ {0,3}${state.c === '`' ? '`' : '~'}{${state.n},}\\s*$`)
25 return close.test(line) ? null : state
26}
27
28// The fold's Markdown without the label that repeats its title; a cut title ("...") keeps it,
29// the only place the whole command is shown.
30function foldBody(title: string, lines: string[]): string {
31 const rest = lines.length > 0 && !title.includes('...') && LABEL.test(lines[0]!) ? lines.slice(1) : lines
32 return rest.join('\n').replace(/^\n+|\n+$/g, '') || '*(empty)*'
33}
34
35// Only complete lines are read (a trailing partial line waits for its newline). A fold whose end
36// marker has not arrived is held back, unless `final` (the run ended): then it is shown as is.
37export function parseRun(text: string, final = false): RunPart[] {
38 const end = text.lastIndexOf('\n')
39 const lines = (final ? text : text.slice(0, end + 1)).split('\n')
40 if (lines[lines.length - 1] === '') lines.pop()
41 const parts: RunPart[] = []
42 let prose: string[] = []
43 let fence: Fence = null
44 let fold: { title: string; failed: boolean; lines: string[]; fence: Fence } | null = null
45 const flush = () => {
46 if (prose.join('').trim()) parts.push({ kind: 'prose', text: prose.join('\n').replace(/\n+$/, '') })
47 prose = []
48 }
49 for (const line of lines) {
50 if (fold) {
51 if (fold.fence === null && line === FOLD_END) {
52 parts.push({ kind: 'fold', title: fold.title, failed: fold.failed, body: foldBody(fold.title, fold.lines) })
53 fold = null
54 } else {
55 fold.lines.push(line)
56 fold.fence = fenceStep(fold.fence, line)
57 }
58 continue
59 }
60 const m = fence === null ? FOLD_OPEN.exec(line) : null
61 if (m) {
62 flush()
63 fold = { title: m[2]!, failed: m[1] === 'open', lines: [], fence: null }
64 continue
65 }
66 prose.push(line)
67 fence = fenceStep(fence, line)
68 }
69 flush()
70 if (fold && final) parts.push({ kind: 'fold', title: fold.title, failed: fold.failed, body: foldBody(fold.title, fold.lines) })
71 return parts
72}
73
74// The run as plain text, for /codex-runs where no surface draws panes (the reply lands in the
75// chat): the assistant's text, one line per tool result (a failed one with the first lines of its
76// output), then the answer. Headings and fences are dropped so it reads as text; the newest part
77// is kept when it is long.
78const FAILED_LINES = 6
79const MAX_SUMMARY = 6000
80
81function plainLines(md: string): string[] {
82 // headings, fences, the thinking label and codex's token footer (the head already says it)
83 return md.split('\n').filter((l) => !/^#{1,6} /.test(l) && !/^ {0,3}(`{3,}|~{3,})/.test(l) && l !== '**thinking**' && !/^\*input [0-9,]+ .*\*$/.test(l))
84}
85
86export function summarizeRun(parts: RunPart[], head: string, answer: string, logPath: string): string {
87 const body: string[] = []
88 for (const p of parts) {
89 if (p.kind === 'prose') {
90 const text = plainLines(p.text).join('\n').replace(/\n{3,}/g, '\n\n').trim()
91 if (text) body.push(...(body.length ? [''] : []), text, '')
92 continue
93 }
94 body.push(`${p.failed ? '✗' : '✓'} ${p.title}`)
95 if (p.failed) {
96 const out = plainLines(p.body).filter((l) => l.trim() && !l.startsWith('> **error**') && !/^\*\*[A-Za-z0-9_.:-]+\*\*/.test(l))
97 body.push(...out.slice(0, FAILED_LINES).map((l) => ` ${l}`))
98 if (out.length > FAILED_LINES) body.push(` ... ${out.length - FAILED_LINES} more lines`)
99 }
100 }
101 let text = body.join('\n').replace(/\n{3,}/g, '\n\n').trim()
102 if (text.length > MAX_SUMMARY) text = '... (earlier steps cut)\n' + text.slice(-MAX_SUMMARY)
103 const tail = answer.trim() ? `\n\n--- answer ---\n${answer.trim()}` : ''
104 return `${head}\n\n${text || '(no events yet)'}${tail}\n\nFull log: ${logPath}`
105}
106hooks/runview.tsx 111 lines1// Draws one run's Claude Code pane (runpane.tsx's ui.render hook): a header, the Live / Answer /
2// Open-all buttons, then Live (prose, and each tool result as a fold: a Button row that opens it)
3// or Answer. The log is read from clean.md here, its tail only once it is long, so the state never
4// holds it.
5
6import type { EngineInterface as Api } from 'claude-code'
7import type { RunView } from '../types'
8import { parseRun, type RunPart } from './livedoc.ts'
9import { patch, runStatus, text } from './runstate.ts'
10
11type Fold = Extract<RunPart, { kind: 'fold' }>
12type Ui = ReturnType<Api['ui']['resolve']>
13
14export const MAX_CHARS = 80000 // what one drawing shows (the engine cuts a drawing at 100000)
15const TAIL_CHARS = 400000 // of a longer log only this much is read into a drawing
16
17// The log's parts, from a top-level boundary (a fold or a heading) when only its tail is read
18export async function loadParts($: Api, r: RunView): Promise<{ parts: RunPart[]; cut: boolean }> {
19 let md = (await text($, `${r.dir}/clean.md`)) ?? ''
20 const cut = md.length > TAIL_CHARS
21 if (cut) {
22 const tail = md.slice(-TAIL_CHARS)
23 const at = tail.search(/\n(<!-- fold (open|closed): |#{1,6} )/)
24 md = at >= 0 ? tail.slice(at + 1) : tail.slice(tail.indexOf('\n') + 1)
25 }
26 return { parts: parseRun(md, r.exit !== undefined), cut }
27}
28
29// The newest parts that fit MAX_CHARS. The newest alone may not: then it is cut to its last
30// MAX_CHARS characters (a fold's body; prose from a line start).
31export function fitParts(parts: RunPart[], isOpen: (i: number, p: Fold) => boolean) {
32 let room = MAX_CHARS, first = parts.length
33 const shown = parts.slice()
34 while (first > 0) {
35 const i = first - 1, p = shown[i]!
36 const cost = p.kind === 'prose' ? p.text.length : p.title.length + (isOpen(i, p) ? p.body.length : 0)
37 if (cost > room && first < parts.length) break
38 if (cost > room) shown[i] = clip(p, room)
39 room -= Math.min(cost, room); first = i
40 }
41 return { first, shown: shown.slice(first) }
42}
43
44function clip(p: RunPart, room: number): RunPart {
45 const keep = Math.max(0, room - 200)
46 const cutText = (t: string) => { const s = t.slice(-keep); return `*... earlier text cut*\n\n${s.slice(s.indexOf('\n') + 1)}` }
47 return p.kind === 'prose' ? { ...p, text: cutText(p.text) } : { ...p, body: cutText(p.body) }
48}
49
50function foldRow(ui: Ui, $: Api, r: RunView, i: number, p: Fold, open: boolean) {
51 const { Box, Button, Markdown, Text } = ui
52 const toggle = () => void patch($, r.id, (c) => ({ ...c, open: { ...c.open, [String(i)]: !open } }))
53 return (
54 <Box key={`f${i}`} flexDirection="column">
55 <Button key={`fold-${i}`} plain onPress={toggle}>
56 {open ? '▼ ' : '▶ '}<Text color={p.failed ? 'red' : undefined}>{p.title}</Text>
57 </Button>
58 {open && <Box paddingLeft={2}><Markdown key={`b${i}`} text={p.body} /></Box>}
59 </Box>
60 )
61}
62
63function buttons(ui: Ui, $: Api, r: RunView, folds: number[], allOpen: boolean) {
64 const { Box, Button } = ui
65 const setTab = (tab: RunView['tab']) => void patch($, r.id, (c) => ({ ...c, tab }))
66 const setAll = () => void patch($, r.id, (c) => ({ ...c, open: Object.fromEntries(folds.map((i) => [String(i), !allOpen])) }))
67 return (
68 <Box flexDirection="row" gap={1}>
69 <Button key="tab-live" hotkey="l" variant={r.tab === 'live' ? 'primary' : undefined} onPress={() => setTab('live')}>Live</Button>
70 <Button key="tab-answer" hotkey="a" variant={r.tab === 'answer' ? 'primary' : undefined} onPress={() => setTab('answer')}>Answer</Button>
71 {r.tab === 'live' && folds.length > 0 && <Button key="folds" hotkey="f" onPress={setAll}>{allOpen ? 'Close all' : 'Open all'}</Button>}
72 </Box>
73 )
74}
75
76function liveView(ui: Ui, $: Api, r: RunView, parts: RunPart[], cut: boolean) {
77 const { Box, Markdown, Text } = ui
78 const isOpen = (i: number, p: Fold) => r.open[String(i)] ?? p.failed
79 const { first, shown } = fitParts(parts, isOpen)
80 const rows = shown.map((p, k) => {
81 const i = first + k
82 return p.kind === 'prose' ? <Markdown key={`p${i}`} text={p.text} /> : foldRow(ui, $, r, i, p, isOpen(i, p))
83 })
84 return (
85 <Box flexDirection="column" gap={1}>
86 {(first > 0 || cut) && <Text dimColor>Earlier entries not shown: {r.dir}/clean.md</Text>}
87 {parts.length === 0 && <Text dimColor>Waiting for the first event...</Text>}
88 {rows}
89 </Box>
90 )
91}
92
93export async function renderPane(ui: Ui, $: Api, r: RunView, now: number) {
94 const { Box, Markdown, Text } = ui
95 const ok = r.exit === '0'
96 const { parts, cut } = r.tab === 'live' ? await loadParts($, r) : { parts: [] as RunPart[], cut: false }
97 const folds = parts.flatMap((p, i) => (p.kind === 'fold' ? [i] : []))
98 const allOpen = folds.length > 0 && folds.every((i) => r.open[String(i)] ?? (parts[i] as Fold).failed)
99 const answer = r.answer || (r.exit === undefined ? '*The answer appears here when the run finishes.*' : '*No answer was written; see Live.*')
100 return (
101 <Box flexDirection="column" gap={1}>
102 <Box flexDirection="column">
103 <Text bold>{r.label}</Text>
104 <Text color={r.exit === undefined ? undefined : ok ? 'green' : 'red'} dimColor={r.exit === undefined}>{runStatus(r, now)}</Text>
105 </Box>
106 {buttons(ui, $, r, folds, allOpen)}
107 {r.tab === 'answer' ? <Markdown key="answer" text={answer} /> : liveView(ui, $, r, parts, cut)}
108 </Box>
109 )
110}
111hooks/runhtml.ts 114 lines1// A finished run as one HTML page (run.html beside the run's logs): the answer first, then the
2// run with each tool result in a <details> fold (closed when it passed, open when it failed).
3// For surfaces that draw no mod panes: Claude sends the page to the person as a file. Everything
4// taken from the run is escaped; the Markdown is a small subset (headings, fences, quotes, lists,
5// bold, italic and inline code), enough for what codex.jq, stream.jq and an answer write.
6
7import { fenceStep, type RunPart } from './livedoc.ts'
8
9export type RunPageInput = {
10 label: string
11 status: string // "finished in 22s · input ..." as runpane.tsx says it
12 ok: boolean
13 parts: RunPart[]
14 answer: string
15 logPath: string
16}
17
18const esc = (s: string) => s.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"')
19
20// Code spans are cut out first, so `*` and `**` inside them stay as written
21const emphasis = (t: string) => esc(t)
22 .replace(/\*\*([^*]+)\*\*/g, '<strong>$1</strong>')
23 .replace(/(^|[^*])\*([^*\s][^*]*)\*/g, '$1<em>$2</em>')
24// A code span opens and closes on backtick runs of the same length (CommonMark)
25const CODE_SPAN = /(`+)(?!`)([\s\S]*?[^`])\1(?!`)/g
26function inline(t: string): string {
27 let out = '', at = 0
28 for (const m of t.matchAll(CODE_SPAN)) {
29 out += emphasis(t.slice(at, m.index)) + `<code>${esc(m[2]!.trim() || m[2]!)}</code>`
30 at = m.index! + m[0].length
31 }
32 return out + emphasis(t.slice(at))
33}
34
35export function mdToHtml(src: string): string {
36 const lines = src.replace(/\r/g, '').split('\n')
37 const out: string[] = []
38 let i = 0
39 while (i < lines.length) {
40 const l = lines[i]!
41 // the fence rule of livedoc.ts (CommonMark's), so the page and the pane agree on every block
42 let fence = fenceStep(null, l)
43 if (fence) {
44 const body: string[] = []
45 for (i++; i < lines.length && (fence = fenceStep(fence, lines[i]!)) !== null; i++) body.push(lines[i]!)
46 i++
47 out.push(`<pre>${esc(body.join('\n'))}</pre>`)
48 continue
49 }
50 const h = /^(#{1,6}) (.*)$/.exec(l)
51 if (h) {
52 if (h[2] !== 'Turn') out.push(`<h3>${inline(h[2]!)}</h3>`) // codex.jq's turn marker says nothing on a page
53 i++
54 continue
55 }
56 if (l.startsWith('> ')) { out.push(`<blockquote>${inline(l.slice(2))}</blockquote>`); i++; continue }
57 if (/^[-*] /.test(l)) {
58 const items: string[] = []
59 while (i < lines.length && /^[-*] /.test(lines[i]!)) items.push(`<li>${inline(lines[i++]!.slice(2))}</li>`)
60 out.push(`<ul>${items.join('')}</ul>`)
61 continue
62 }
63 if (!l.trim()) { i++; continue }
64 const para: string[] = []
65 while (i < lines.length && lines[i]!.trim() && !fenceStep(null, lines[i]!) && !/^(#{1,6} |> |[-*] )/.test(lines[i]!)) para.push(lines[i++]!)
66 out.push(`<p>${inline(para.join(' '))}</p>`)
67 }
68 return out.join('\n')
69}
70
71const CSS = `:root{--bg:#fbfaf7;--fg:#1f1f1c;--muted:#6b6a64;--line:#e4e1d8;--card:#fff;--ok:#2f7d4f;--bad:#b3362b;--code:#f3f1ea}
72@media (prefers-color-scheme:dark){:root:not([data-theme="light"]){--bg:#171715;--fg:#ecebe6;--muted:#a09e96;--line:#34332f;--card:#1f1f1c;--ok:#6cc08b;--bad:#ef7b6f;--code:#262622}}
73:root[data-theme="dark"]{--bg:#171715;--fg:#ecebe6;--muted:#a09e96;--line:#34332f;--card:#1f1f1c;--ok:#6cc08b;--bad:#ef7b6f;--code:#262622}
74*{box-sizing:border-box}body{margin:0;background:var(--bg);color:var(--fg);font:15px/1.55 ui-sans-serif,system-ui,-apple-system,sans-serif}
75main{max-width:860px;margin:0 auto;padding:24px 16px 48px}
76header{border-left:4px solid var(--state);padding:4px 0 4px 14px;margin-bottom:20px}
77header h1{font-size:18px;margin:0 0 2px;overflow-wrap:anywhere}header p{margin:0;color:var(--muted);font-size:13px}
78.tabs{display:flex;flex-wrap:wrap;gap:8px;margin:0 0 16px}.tabs button{font:inherit;border:1px solid var(--line);background:var(--card);color:var(--fg);padding:5px 12px;border-radius:6px;cursor:pointer}
79.tabs button[aria-pressed="true"]{border-color:var(--fg);font-weight:600}
80h3{font-size:13px;text-transform:uppercase;letter-spacing:.04em;color:var(--muted);margin:18px 0 6px}
81pre{background:var(--code);padding:10px 12px;border-radius:6px;overflow-x:auto;font:13px/1.45 ui-monospace,SFMono-Regular,Menlo,monospace;margin:8px 0}
82code{background:var(--code);padding:1px 4px;border-radius:4px;font-size:.92em}
83blockquote{margin:8px 0;padding:4px 10px;border-left:3px solid var(--bad);color:var(--bad)}
84details{border:1px solid var(--line);background:var(--card);border-radius:8px;margin:8px 0;padding:0 12px}
85details>summary{cursor:pointer;padding:8px 0;font:13px/1.4 ui-monospace,SFMono-Regular,Menlo,monospace;overflow-wrap:anywhere}
86details.failed{border-color:var(--bad)}details.failed>summary{color:var(--bad)}
87details[open]>summary{border-bottom:1px solid var(--line);margin-bottom:6px}
88.answer{background:var(--card);border:1px solid var(--line);border-radius:8px;padding:4px 16px}
89.hidden{display:none}footer{margin-top:24px;color:var(--muted);font-size:12px;overflow-wrap:anywhere}`
90
91const SCRIPT = `const show=t=>{for(const n of['live','answer']){document.getElementById(n).classList.toggle('hidden',n!==t);document.getElementById('t-'+n).setAttribute('aria-pressed',String(n===t))}document.getElementById('t-all').classList.toggle('hidden',t!=='live')};
92document.getElementById('t-live').onclick=()=>show('live');document.getElementById('t-answer').onclick=()=>show('answer');
93document.getElementById('t-all').onclick=e=>{const f=[...document.querySelectorAll('details')];const open=f.some(d=>!d.open);f.forEach(d=>d.open=open);e.target.textContent=open?'Close all':'Open all'};
94show(document.body.dataset.first);`
95
96export function runPage(p: RunPageInput): string {
97 const live = p.parts.map((part) => part.kind === 'prose'
98 ? `<section>${mdToHtml(part.text)}</section>`
99 : `<details${part.failed ? ' class="failed" open' : ''}><summary>${esc(part.title)}</summary>${mdToHtml(part.body)}</details>`).join('\n')
100 const answer = p.answer.trim() ? mdToHtml(p.answer) : '<p><em>No answer was written; see Live.</em></p>'
101 const first = p.ok && p.answer.trim() ? 'answer' : 'live'
102 return `<!doctype html>
103<html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
104<title>Delegation Run</title><style>${CSS}</style></head>
105<body data-first="${first}" style="--state:var(${p.ok ? '--ok' : '--bad'})"><main>
106<header><h1>${esc(p.label)}</h1><p>${esc(p.status)}</p></header>
107<div class="tabs"><button id="t-live" aria-pressed="false">Live</button><button id="t-answer" aria-pressed="false">Answer</button><button id="t-all">Open all</button></div>
108<div id="live">${live || '<p><em>No events were logged.</em></p>'}</div>
109<div id="answer" class="answer">${answer}</div>
110<footer>Full log: ${esc(p.logPath)}</footer>
111<script>${SCRIPT}</script></main></body></html>
112`
113}
114hooks/shim.ts 120 lines1import { MDWRITER_FN, NL_FN, mdFunctions, streamJq } from './mdpipe.ts'
2
3// The shell function that stands in for `codex` inside the pane. Pure text.
4//
5// For exec/e it adds, each only when the call does not already say so:
6// --output-last-message the answer as a clean file
7// -p <profile> the lean profile, only when ~/.codex/<profile>.config.toml
8// exists (codex review has no profile flag and is untouched)
9// --json so the usage event can be read (codex.jq renders it)
10// A standalone review (it has no --json) writes its final message to stdout and its progress
11// to stderr: stdout goes to the answer file, so the call returns the verdict as exec does and
12// the log holds the progress once. A chained review keeps stdout for the caller, and so does a
13// review whose stdout the caller redirected: the wrapper sets __CODEX_REVIEW_LAST only for a
14// standalone call with no stdout redirect of its own (redirectsStdout, tools.ts).
15// CHAINED calls (see isStandalone in tools.ts) set __CODEX_CHAIN: the command's stdout
16// is the caller's, so for exec the shim sends codex's JSONL only to the pane
17// (raw.log, then codex.jq and clean.awk into clean.log) and prints the answer file to
18// its own stdout, which is what a plain `codex exec` prints. A call that names its own -o
19// file takes the same route (issue 225: its stdout used to stay JSONL and reached the pane
20// raw): codex writes the caller's file exactly as asked, and the shim copies it to the answer
21// file, so the report, the Answer tab and the chained stdout all get the answer. Every other
22// call with its own -o gets that copy too. Only a chained call that asks for --json itself
23// keeps codex's stdout as it is.
24// The same stream also feeds clean.md through mdcodex (mdpipe.ts).
25// __CODEX_PROFILE is empty when the option is off. A function, so no quoting of the
26// user's text.
27export const SHIM = [
28 ...mdFunctions('$__CODEX_ROOT'),
29 NL_FN,
30 // The caller's own -o file becomes the answer file too (a relative path is read from the
31 // directory codex was started in, as codex wrote it)
32 'own_to_last() { [ -n "$own" ] && [ "$own" != "$__CODEX_LAST" ] && [ -f "$own" ] && cp "$own" "$__CODEX_LAST" 2>/dev/null; return 0; }',
33 'codex() {',
34 ' local a sub="$1" pre=() has_o="" has_p="" has_j="" own="" want=""',
35 ' if [ -n "${__CODEX_LAST:-}" ] && { [ "$1" = exec ] || [ "$1" = e ]; }; then',
36 ' shift',
37 // own: the caller's -o file, in each spelling clap accepts (-o F, -oF, -o=F, --output-last-message[=]F)
38 ' for a in "$@"; do',
39 ' if [ -n "$want" ]; then own="$a"; want=""; continue; fi',
40 ' case "$a" in',
41 ' --) break;;',
42 ' -o|--output-last-message) has_o=1; want=1;;',
43 ' --output-last-message=*) has_o=1; own="${a#*=}";;',
44 ' -o?*) has_o=1; own="${a#-o}"; own="${own#=}";;',
45 ' -p|--profile|--profile=*) has_p=1;;',
46 ' --json) has_j=1;;',
47 ' esac; done',
48 ' [ -z "$has_o" ] && pre+=(--output-last-message "$__CODEX_LAST")',
49 ' if [ -z "$has_p" ] && [ -n "${__CODEX_PROFILE:-}" ] && [ -f "$HOME/.codex/${__CODEX_PROFILE}.config.toml" ]; then pre+=(-p "$__CODEX_PROFILE"); fi',
50 ' [ -z "$has_j" ] && pre+=(--json)',
51 ' local rc',
52 ' if [ -n "${__CODEX_CHAIN:-}" ] && [ -z "$has_j" ]; then',
53 ' : > "$__CODEX_LAST"',
54 ' command codex "$sub" "${pre[@]}" "$@" | tee -a "$__CODEX_D/raw.log" >(mdcodex >> "$__CODEX_D/clean.md") | jq --unbuffered -R -r -f "$__CODEX_ROOT/viewer/codex.jq" 2>/dev/null \\',
55 ' | awk -v seen="${MCP_SEEN_LOG:-}" -f "$__CODEX_ROOT/viewer/clean.awk" >> "$__CODEX_D/clean.log"',
56 ' rc="${PIPESTATUS[0]}"; own_to_last; cat "$__CODEX_LAST" 2>/dev/null; nl_if_open "$__CODEX_LAST"; return "$rc"',
57 ' fi',
58 ' command codex "$sub" "${pre[@]}" "$@"; rc=$?',
59 ' own_to_last; return "$rc"',
60 ' elif [ -n "${__CODEX_LAST:-}" ] && [ -n "${__CODEX_REVIEW_LAST:-}" ] && [ "$1" = review ]; then',
61 ' command codex "$@" > "$__CODEX_LAST"',
62 ' else',
63 ' command codex "$@"',
64 ' fi',
65 '}',
66]
67
68// The shell functions that stand in for `claude` and `dev` inside the pane. Pure text.
69// A claude call in print mode (-p or --print among its words) that names no --output-format
70// gets `--output-format stream-json --verbose` (print mode refuses stream-json without
71// --verbose), so the pane shows each message, tool call and result as it happens (stream.jq).
72// A call that names its own format, or is not in print mode, runs exactly as asked. A
73// `dev exec [-d <dir>] claude ...` call gets the same flags after its claude word.
74// STANDALONE: the stream is the pane's stdout (raw.log, then stream.jq into clean.log) and the
75// pane script takes the answer from its last result event. When the command's stdout is the
76// caller's (__CLAUDE_CHAIN: a chained call, or one that redirects stdout itself) the shim sends
77// the stream to the pane only and prints the answer (the result event's text), which is what a
78// plain `claude -p` prints. Each call streams into a file of its own, so calls running at once
79// in one command never read each other's answer; the newest to end is the run's last.md. The
80// call returns only once its Markdown is in clean.md (mdwriter, mdpipe.ts), so what the command
81// prints next lands after it. If the writer failed, the call's whole render is added after a note
82// saying the live one was cut: clean.md holds the rest of the command too, so it is never redone.
83export const CLAUDE_SHIM = [
84 streamJq('$__CLAUDE_ROOT'),
85 MDWRITER_FN,
86 NL_FN,
87 // __PRE: the flags to add, none when the call is not print mode or names its own format
88 '__claude_pre() {',
89 ' local a want="" p="" fmt="" v=""; __PRE=()',
90 ' for a in "$@"; do',
91 ' if [ -n "$want" ]; then fmt="$a"; want=""; continue; fi',
92 ' case "$a" in --) break;; -p|--print) p=1;; --verbose) v=1;; --output-format) fmt=set; want=1;; --output-format=*) fmt="${a#*=}";; esac',
93 ' done',
94 ' if [ -n "$p" ] && [ -z "$fmt" ]; then __PRE=(--output-format stream-json); [ -z "$v" ] && __PRE+=(--verbose); fi',
95 ' return 0',
96 '}',
97 // $1: how many words lead up to claude (command claude, or command dev exec ... claude); the rest are claude's own
98 '__claude_go() {',
99 ' local n="$1" rc; shift; local head=("${@:1:$n}"); shift "$n"',
100 ' __claude_pre "$@"',
101 ' if [ "${#__PRE[@]}" = 0 ] || [ -z "${__CLAUDE_LAST:-}" ]; then "${head[@]}" "$@"; return; fi',
102 ' if [ -z "${__CLAUDE_CHAIN:-}" ]; then "${head[@]}" "${__PRE[@]}" "$@"; return; fi',
103 ' local cl; cl=$(mktemp "$__CLAUDE_D/call.XXXXXX") || { "${head[@]}" "$@"; return; }',
104 ' "${head[@]}" "${__PRE[@]}" "$@" | tee -a "$__CLAUDE_D/raw.log" "$cl" >(mdwriter "$__CLAUDE_D/clean.md" "$cl.st") | jqs pane >> "$__CLAUDE_D/clean.log"',
105 ' rc="${PIPESTATUS[0]}"',
106 ' [ "$(cat "$cl.st" 2>/dev/null)" = 0 ] || { printf "\\n*the live log of this call was cut short; its whole log follows*\\n\\n"; jqs md < "$cl"; } >> "$__CLAUDE_D/clean.md"',
107 ' jqs answer < "$cl" > "$cl.md"; cat "$cl.md"; nl_if_open "$cl.md"',
108 ' mv -f "$cl.md" "$__CLAUDE_LAST"; rm -f "$cl" "$cl.st"; return "$rc"',
109 '}',
110 'claude() { __claude_go 2 command claude "$@"; }',
111 // dev exec: skip -d <dir> (or -d=<dir>, -d<dir>); a claude word next is the run, anything else runs untouched
112 'dev() {',
113 ' local i=2',
114 ' if [ "$1" = exec ]; then while [ "$i" -le "$#" ]; do case "${!i}" in -d) i=$((i+2));; -d?*) i=$((i+1));; *) break;; esac; done; fi',
115 ' if [ "$1" = exec ] && [ "$i" -le "$#" ] && case "${!i}" in claude|*/claude) true;; *) false;; esac; then',
116 ' __claude_go $((i+2)) command dev "$@"',
117 ' else command dev "$@"; fi',
118 '}',
119]
120