SLOPSHOPPER

codex-herdr-pane

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…

paneguardcommandtoasttimer
★ 4v0.9.0MITupdated 2026-10-21shakestzd/wipnote/mods/codex-herdr-pane
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · codex-herdr-pane
› fix the failing auth test and add an audit log call ⏺ Read(src/auth.ts) ⎿ Read 6 lines ⏺ Update(src/auth.ts) ⎿ Added 2 lines, removed 1 line ⏺ Bash(bun test) ⎿ 3 pass, 1 fail ● Done. refresh now rejects expired claims and logs an audit event. ✻ Worked for 42s · done 4:20 PM › /codex-runs ⎿ codex-herdr-pane: No delegation run is followed in this session yet. ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts
README

wipnote

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.

What this is NOT

  • Not a hosted platform. Local-first. Your data stays on your machine — no cloud sync, no telemetry.
  • Not a general-purpose dev tool yet. Today it's power-user and Claude-Code-centric. Other CLI integrations exist but are less mature.
  • Not a behavioral agent coordinator. It observes and attributes what agents do — it does not dispatch, steer, or enforce behavior on agents at runtime.

Architecture

LayerRole
.wipnote/*.htmlCanonical 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.

Install

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

Upgrading

wipnote upgrade

Verify

wipnote version    # should print 0.60.1 (or later)

The install.sh script:

  1. Detects your OS (uname -s: darwin or linux) and architecture (uname -m: x86_64→amd64, arm64/aarch64→arm64). Errors clearly on unsupported combinations.
  2. Resolves the version — from WIPNOTE_VERSION env var, or fetches the latest tag from https://api.github.com/repos/shakestzd/wipnote/releases/latest.
  3. Downloads wipnote_${VERSION}_${OS}_${ARCH}.tar.gz and wipnote_${VERSION}_checksums.txt to a temp dir (cleaned up via trap … EXIT).
  4. Verifies the sha256 checksum via 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).
  5. Extracts the tarball, mkdir -ps the binary dir (WIPNOTE_BIN_DIR, default $HOME/.local/bin), moves the binary, chmod +x.
  6. Installs the bundled plugin trees (plugin/, codex-marketplace/, antigravity-extension/) to the share dir (WIPNOTE_SHARE_DIR, default $HOME/.local/share/wipnote/).
  7. On macOS: removes the quarantine attribute (xattr -d com.apple.quarantine) to avoid Gatekeeper blocking.
  8. Checks whether the binary dir is on your PATH. If not, prints instructions — it does NOT mutate your shell rc files.
  9. Prints ==> 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.

Using wipnote with each harness

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.

Quick Start

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

What It Does

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.

Work Item Types

TypePrefixPurpose
Featurefeat-Units of deliverable work
Bugbug-Defects to fix
Spikespk-Time-boxed investigations
Tracktrk-Initiatives grouping related work
Planplan-CRISPI implementation plans

Roadmap

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:

  • Spec-as-node — treating feature specs as first-class lineage nodes so acceptance criteria appear in the causal chain alongside the code that satisfies them.
  • Cross-project lineage — tracing chains across multiple repos registered in ~/.local/share/wipnote/projects.json. Today each project's lineage is self-contained.

CLI Reference

wipnote help --compact

See full CLI documentation at shakestzd.github.io/wipnote/reference/cli.

Contributing

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

License

MIT

Links

Source 13 files
hooks/register.ts 96 lines
1import 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}
96
hooks/wrap.ts 183 lines
1// 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}
183
hooks/runpane.tsx 47 lines
1// 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}
47
hooks/runstate.ts 201 lines
1// 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}
201
hooks/tools.ts 138 lines
1// 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}
138
hooks/mdpipe.ts 48 lines
1// 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; }'
48
hooks/panescript.ts 256 lines
1// 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
256
hooks/layout.ts 79 lines
1// 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}
79
hooks/livedoc.ts 106 lines
1// 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}
106
hooks/runview.tsx 111 lines
1// 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}
111
hooks/runhtml.ts 114 lines
1// 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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;')
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}
114
hooks/shim.ts 120 lines
1import { 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