Reports a fleet session's exact state and budget from inside Claude Code, answers /fleet and /inbox without a turn, draws a lead's team above its prompt, and…

<img src="docs/logo-banner.svg" alt="ghostfleet" width="440">
<a href="https://www.npmjs.com/package/ghostfleet-cli"><img src="https://img.shields.io/npm/v/ghostfleet-cli?color=cb3837&label=npm" alt="npm version"></a> <a href="https://www.npmjs.com/package/ghostfleet-cli"><img src="https://img.shields.io/npm/dm/ghostfleet-cli?label=downloads" alt="npm downloads"></a> <a href="https://github.com/PabloG55/ghostfleet/actions/workflows/test.yml?query=branch%3Astaging"><img src="https://img.shields.io/github/actions/workflow/status/PabloG55/ghostfleet/test.yml?branch=staging&label=tests" alt="tests"></a> <a href="LICENSE"><img src="https://img.shields.io/github/license/PabloG55/ghostfleet" alt="MIT license"></a>
Run a fleet of Claude Code agents in parallel, from one terminal. Each agent gets its own git worktree — cut, branched, dependency-linked and booted in one keystroke — and you get one screen that shows what every one of them is doing.
A ghost fleet is a fleet of autonomous, unmanned vessels under one command — agents working with nobody in the seat, and one control plane steering them.
<img src="docs/worktree-demo.gif" width="820" alt="The Projects screen, then one project's session grid, then the new-worktree form: a name, a branch, and which agent runs it. The worktree is cut, the agent boots into it, and Projects comes back counting one more session."> <b>Start a worker.</b> <sub><code>w</code> cuts a worktree, branches it, picks which agent runs it, and boots it — you land in the session.</sub>
<img src="docs/stack-demo.gif" width="820" alt="The stack screen listing three idle workers, each ticked, then the stack open: claude, opencode and codex side by side in three panes, each with its own status bar and a border naming its project and session."> <b>Watch three at once.</b> <sub><code>claude</code>, <code>opencode</code> and <code>codex</code> side by side — every pane live and typable, across projects.</sub>
<img src="docs/mobile/phone-demo.gif" width="260" alt="On a phone: the projects list and its profile tabs, one project's grid of session cards, the actions sheet behind the header's dots, a session as a chat with bubbles and a composer being typed into, a swipe to the next session, and the live pane showing a permission prompt waiting on an answer."> <b>Unblock one from anywhere.</b> <sub>The same grid as an installable web app. A session is a chat; its <code>pane</code> tab is the real terminal, so a worker stuck on a permission prompt gets an answer from your pocket. It <b>notifies</b> when a worker is blocked or has an answer, and you can <b>send it a photo</b>.</sub>
<sub>All three recorded against a real fleet by <a href="worktree.tape"><code>worktree.tape</code></a> and <a href="stack.tape"><code>stack.tape</code></a>. Details: <a href="docs/SHORTCUTS.md">keys</a> · <a href="docs/stack-view.md">the stack</a> · <a href="web/README.md">the phone client</a>.</sub>
Orchestrating agents is easy to demo and hard to trust. The parts that took real debugging — and that most wrappers get wrong:
| problem | what ghostfleet does |
|---|---|
| "is it working?" — the transcript's mtime says idle mid-generation, and busy when a background write lands | reads the live pane, the same signal you read |
| a worker needs you, you handle it, the card stays red forever | a need-you older than the session's own activity is treated as spent |
every project has a session called master, so their statuses collide | status is scoped by the fleet's socket, not by name |
| a dispatched prompt silently lands in the input box without submitting | dispatch waits for the paste, submits, then verifies a turn actually started |
| one account: 5 agents drain the budget 5× faster and all stall together | a non-Claude governor meters usage and parks workers at the ceiling, resuming on reset (it can't be the lead — the lead stalls too) |
| one laptop: 5 agents running tests can fill the process table and wedge the machine | the same governor watches the kernel's own memory-pressure level and the process table, and parks workers before the OOM killer does |
| you already run agents by hand in a dozen panes | fleet-adopt finds those conversations and rebuilds them as one fleet |
git worktree add gives you a directory. An agent needs more than that before it can do anything, and the gap is where the fiddly parts live — so fleet-spawn closes it:
| why it matters | |
|---|---|
node_modules symlinked from the main checkout | the worker can run lint, typecheck and tests on its first turn instead of waiting out an install |
| a dev-stack slot allocated | two workers don't both try to bind port 3000 and one of them silently lose |
| the task recorded in a manifest | fleet-worktrees shows what each tree is for, which is how a lead rebuilds its map after a restart instead of guessing |
| reuse before create | it refuses to cut a new tree while a free one is sitting there, and lists them — so the disk doesn't fill with abandoned checkouts |
| a session started in it, on the agent you picked | a worktree with nobody in it isn't a worker |
The branch is cut from your local ref, not the remote tip, so a worker never misses work you committed but haven't pushed. And fleet-spawn refuses to run from inside a worktree: a session already in one is a leaf, and spawning there would put a second tree beside the one you're standing in rather than starting a worker.
Recycling an existing tree onto fresh work is one command — fleet-spawn <name> --reuse <worktree> --branch <new> --from <base> cleans it and checks out the new branch — which is the usual case once a fleet has been running for a while. What gets recycled is the folder, never the session: a worker that finished a task has that task all through its context, so it is retired with fleet-stop --reclaim and the next task starts a new conversation. fleet-send refuses to hand a finished, shipped worker a new brief, and says to spawn instead.
The README is the pitch, the install and the shape of the thing. Everything you look up while using it lives in docs/, one file per question, because three reference sections had grown to 78% of this page and a README is not where you go to check a keystroke.
| docs/SHORTCUTS.md | Every key, and what answers it. Start at §0 — the handful that covers almost everything — then the exhaustive sections: inside a session, the Projects screen, the grid, the stack, the fleet-* commands, and the behaviors that are not guessable from the UI |
| docs/ORCHESTRATION.md | A lead session driving workers. Dispatching briefs to siblings, watching them, unblocking them, reusing worktrees before making more, and metering cost with the governor |
| docs/OPERATIONS.md | The special cases. Adopting Claude sessions you started by hand, the phone client, notifications, work-vs-personal profiles, staying awake, where a worktree goes, the two ways to register a project, which fleet a worker lands on, and updating Claude Code under a live fleet |
| docs/mobile.md · web/README.md | The phone client — the design argument, and the client itself |
| docs/jarvis.md | Jarvis, one session above every fleet — experimental, off by default; fleet-experimental enable jarvis turns it on. ghostfleet jarvis, the digest it reads instead of polling, the confirm-list the tools enforce, conversation mode on the phone (transcribed on the Mac), and its daily fresh start |
| docs/stack-view.md | Why the stack is nested attaches and not join-pane, and what was measured to find out |
| docs/multi-agent-sessions.md | Running codex, opencode, agy and cursor beside claude — the measured capability matrix (hooks, MCP, skill, resume) and what picking a non-default agent costs |
| docs/attachments.md | Sending a photo from the phone: what was measured per agent, why the bytes are converted on the Mac rather than the phone, and the two recommendations the build overturned |
| docs/agent-council.md | Would a second agent checking the first one's work reduce iteration? Measured against 3,494 real turns and this repo's own history: misread intent is the smallest correction category, three quarters of the rest are found by a human looking at a screen, and a browser-based verifier would have passed the bug it was built for |
| docs/ROADMAP.md · docs/IDEAS.md | What is next, and what is only an idea |
| CHANGELOG.md | What changed between releases, and whether it is a reason to upgrade |
| CLAUDE.md | For working on ghostfleet: how to deploy a change, what the tests cover, and the failure modes that have bitten more than once |
| requirement | why |
|---|---|
git | worktrees are the isolation model — a worker is a checkout on its own branch. fleet-spawn refuses to run without it |
claude (Claude Code) | what a session runs by default, and what the pane detectors are written against — missing? the installer offers Claude Code's native installer (into ~/.local/bin, no sudo) |
node (v18+ to run, v20.19+ to build from a clone) | the grid is a zero-npm-dependency Node TUI, and v18 runs it. The phone client is built from web/src with vite, which needs 20.19 — that applies only to a clone, because the published package ships web/ already built. Debian and Ubuntu package 18.x, so a clone there needs a newer node first; the installer checks the version up front and says which |
tmux | the hidden substrate that keeps sessions alive in the background — missing? the installer offers to install it for you (see below). With no terminal attached it has nobody to ask, so a piped or CI install prints the command instead — pass --yes there and it installs without prompting |
jq | the installer wires the hooks and MCP entries with it, and the status hook parses its payload with it. macOS 26 already ships it (/usr/bin/jq); anywhere it is missing the installer offers to install it |
| macOS, Linux, or Windows via WSL2 | sessions are tmux servers, and tmux is POSIX-only — see the native-Windows note below |
codex / opencode / agy / cursor-agent (optional) | alternative agents, chosen per worktree on the w form. Their pane signals are detected separately — see docs/multi-agent-sessions.md |
$EDITOR (optional, default nvim .) | what Ctrl-n's editor tab opens. Any editor works — override with CLAUDE_FLEET_EDITOR. With neither set and no Neovim new enough for LazyVim (0.11.2+), the installer offers the official Neovim release (under ~/.local) and, only where there is no ~/.config/nvim yet, the LazyVim starter |
tailscale (optional) | only for the phone client, and only off-LAN: it is how fleet-serve is reachable without exposing a port — see docs/mobile.md |
zellij (optional) | not required, but the included layout gives you one pane that frees Ctrl-s/arrows from its own bindings |
terminal-notifier + AeroSpace (optional, macOS) | for clickable notifications that jump straight to the fleet — see Notifications |
(Native Windows isn't supported and won't be: sessions are tmux servers, and tmux is POSIX-only. Under WSL2 it's just Linux and works the same — docs/windows.md is the whole path, from a bare Windows machine to an open fleet.)
npx ghostfleet-cli # installs, no clone needed
ghostfleet demo # see it working, on three throwaway projects
On a fresh Linux box there is no npx to run that with. A stock Ubuntu 24.04 image ships none of node, npm, git, tmux, jq or curl — measured, not assumed — so the line above is command not found and nothing tells you which of the six is the one you need. Install them first, and the rest of this page applies unchanged:
sudo apt-get update && sudo apt-get install -y nodejs npm git tmux jq curl
(That nodejs is 18.x, which runs the fleet perfectly well. It cannot build the phone client, which matters only if you clone — see the node row above.)
ghostfleet demo is the fastest way to find out whether you want this. It creates three scratch git repos under ~/gf-demo, registers them in a separate demo profile, and opens the real control plane on them — the same screens the GIFs above were recorded against. It is additive and it never repairs: anything already there is reused and said so, and if it finds something it cannot safely reuse it stops and tells you what it found rather than guessing. Your own profiles are untouched; rm -rf ~/gf-demo ~/.config/ghostfleet/projects.demo ~/.claude-demo removes every trace.
Every screen works before you log anything in — the Projects picker, a project's session grid, the new-worktree form, the stack. What needs a login is an agent taking a turn: Claude Code keeps credentials per config dir, so the demo profile has its own, and a session you open will sit at its login prompt until you do this once:
CLAUDE_CONFIG_DIR=~/.claude-demo claude # then /login
An empty pane before that is the login prompt, not a broken fleet.
Ready to point it at your own work? ghostfleet opens your projects — and with none registered yet it walks you through picking a folder, naming it, and starting the first session, rather than showing you an empty screen.
And put it on your phone:
fleet-phone # what is left to do, and the one command that does it
The same fleet as an installable app — every session as a chat, the real pane one tap away, and a notification when a worker is blocked, so a permission prompt gets answered from your pocket. fleet-phone reports which of the three steps you have done (configure, enrol a passkey, run the daemon) and prints the next one; it never runs any of them for you, because each opens a port, writes a config or spends a passkey. It reaches the phone over Tailscale — fleet-serve refuses a wildcard, a LAN address or a public one before the socket opens, because this endpoint runs commands. See docs/mobile.md for the design and the threat model.
Optional, and local only: the Mac's voice. The phone can read replies aloud with Kokoro running on your machine — better English, real Spanish, chosen sentence by sentence — instead of the phone's own voice. It is ~350 MB, so ./install.sh asks and the default is No (--yes does not answer it). Add it any time:
fleet-jarvis voice --kokoro --install # verified download + a pinned Python 3.12 venv; re-run to repair
Without it nothing is missing: the phone reads with its own voice. fleet-phone and fleet-jarvis status say whether it is installed, not installed or broken. docs/mobile.md has the details.
Two things that cost people time, so they are in that command's output too: the passkey is enforced server-side, so until a phone is enrolled the app sits on its lock screen and the API answers 401 — and an installed iOS PWA resumed from the app switcher does not pick up a new client version. The shell is served cache-first, so reopening is a resume, not a navigation; swipe the app away and relaunch.
Prefer to clone the repo (e.g. to develop against it)?
git clone https://github.com/PabloG55/ghostfleet.git
cd ghostfleet
./install.sh # add --verbose to watch every step
Both run the same install.sh — npx ghostfleet-cli just fetches the package and runs it for you, so nothing is left checked out afterwards. The command it installs is ghostfleet, not ghostfleet-cli: the npm package name and the binary name are separate, and only the package name had to change.
The package is ghostfleet-cli, and ghostfleet on npm is someone else's. ghostfleet@0.0.2 was published by an unrelated project ten days after this one took the name, pitched as "fleets of disposable AI agents in your own cloud" — close enough that npx ghostfleet looks right and installs a stranger's package. Nothing here can be done about that, so the suffix is load-bearing: type -cli.
Cloning is also what you want if you intend to edit ghostfleet itself: cf-sync syncs the runtime from a real repo, and an npx cache is not one.
If you already have a clone, be careful running the npx installer over it. install.sh records where to sync FROM in <runtime>/.source, and cf-sync with no argument reads it — so an installer run from an npx cache used to repoint that at the cache, after which every cf-sync in your clone copied the cache into the live runtime. Nothing errors and the sync still prints synced runtime; your edits just quietly stop arriving. The installer now keeps a recorded clone when it is running from a cache (it says so: pointer stays on your clone: …), so this only bites an older install. To fix one, do either:
cd /path/to/ghostfleet && ./install.sh # re-install from the clone — it re-records it
cf-sync /path/to/ghostfleet # or just repoint it once; later `cf-sync` remembers
Check it any time with cat ~/.local/libexec/ghostfleet/.source. An install run from a clone always repoints — that includes a re-install from the same clone, a moved clone, and a second clone — because the guard only fires when the copy being installed from could not serve as a sync source at all.
Missing tmux? The installer detects it, works out the right package manager for your OS (Homebrew, apt, dnf, yum, pacman, zypper, or apk), and asks before running anything — it never installs (or sudos) without you confirming. No package manager recognized, or you say no? It just tells you the command to run yourself.
Installing with no terminal (CI, a Dockerfile, curl | bash)? There is nobody to ask, so the default is to install nothing and print the command — and tmux is not optional here: a fleet session is a tmux server, so an install that skipped it leaves a grid that cannot start anything. Consent up front instead:
npx ghostfleet-cli --yes # or: ./install.sh --yes, or CLAUDE_FLEET_YES=1
That installs the missing dependencies without prompting, using the same package manager it would have offered — sudo included on Linux, which is why it is opt-in and never the default. With --yes, an install that ends without tmux exits non-zero instead of looking successful. Without it, nothing is installed unless you say yes at the terminal.
**With npx, the flag goes after the package name.** --yes (and -y) is npx's own flag too, so npx --yes ghostfleet-cli is consumed by npm and this installer is invoked with no arguments at all — it then refuses exactly as if you had never passed it (measured on npm 11.18; it notices and says where the flag belongs, but the install still skips tmux). In a Dockerfile or a CI config, the env var is the form nothing can misparse:
ENV CLAUDE_FLEET_YES=1
RUN npx ghostfleet-cli
The installer stages the runtime — it copies bin/, hooks/, mcp/, skill/, and layouts/ out of the repo into ~/.local/libexec/ghostfleet (override with CLAUDE_FLEET_HOME) — then symlinks the commands (ghostfleet, claude-here, cf-sync, and the fleet-* helpers) into ~/.local/bin pointing at the staged copy; wires the status + notification hooks into every Claude config dir it finds (~/.claude, ~/.claude-*, backing each up), plus a PreToolUse guard that stops Claude Code's built-in EnterWorktree from walking a fleet session off its own checkout (it is appended to PreToolUse, so hooks you already have there survive); registers the fleet MCP server into each config dir's .claude.json via claude mcp add -s user (Claude Code reads MCP from .claude.json/.mcp.json, not settings.json) and once, globally, for codex, opencode, agy and cursor-agent, which keep one config each and have no per-profile equivalent (agy and cursor also get the event bridge and the skill, under ~/.gemini/config/ and ~/.cursor/; an edited ~/.cursor file keeps its original beside it as <file>.pre-ghostfleet); installs the ghostfleet-orchestrate skill; and links the zellij layout. Re-run any time; it's idempotent.
macOS guards ~/Documents, ~/Desktop, and ~/Downloads with TCC. An app that hasn't been granted Documents folder / Full Disk Access — notably ClaudeCode.app — gets Operation not permitted when it tries to execute anything stored there. So if you cloned this repo under ~/Documents, running the fleet CLI, the event hook, or the MCP server directly from the repo breaks the instant such an app hosts your session (symlinks don't help — exec follows them back into the protected folder). ~/.local is not TCC-protected, so the installer runs everything from the staged copy there and the repo stays purely for development.
After you edit the repo, run cf-sync to push those edits into the live runtime (it copies the runtime dirs from the recorded source repo into ~/.local/libexec/ghostfleet; the PATH symlinks, hook, and MCP already point there, so no re-link is needed). The alternative — granting ClaudeCode.app Full Disk Access — also works but can reset on app/OS updates; staging survives updates.
One terminal window (or one zellij pane) — ghostfleet is the whole control plane:
flowchart LR
Projects["Projects\npick a project · + add project"] -- "⏎ enter" --> Master["Master Claude\nthe lead — spawns &\ncoordinates workers"]
Master -- "Ctrl-s" --> Grid["The grid\napi · api-1 · api-2 …"]
` `` (backtick) always steps back exactly one level, from anywhere — session → grid → master → Projects — no matter which shortcut you took down.
⏎ drops you straight into its Master Claude. Each project has its own hidden tmux server (cf-<project>) holding its sessions.t from the grid) — several of those sessions on screen at the same time, in split panes, including sessions from different projects.zellij --layout fleet attach -c fleet # one zellij session runs everything
# or just run `ghostfleet` in any pane
What makes it different: it runs beside your setup instead of taking it over. Sessions live on per-project tmux servers ghostfleet manages for you, so you never lose a session by clo
hooks/register.js 1373 lines1// ghostfleet's mod: the fleet's view of a Claude session, from inside the session.
2//
3// Everything the fleet knew about a Claude session it learned from OUTSIDE: a regex over
4// the captured pane for "is it working" (blind at 56 columns, fooled by a leftover login
5// line, by prose that quotes a spinner), the status bar scraped for the 5h budget (gone
6// below ~100 columns, frozen on an idle pane), and a lead spending a whole turn to run
7// fleet-inbox through Bash. From in here there is nothing to guess: a turn starts, a turn
8// completes, a dialog is about to be drawn, the engine measures the account.
9//
10// Seven things, each a section below, all sharing the record I/O at the top:
11// STATE written into the session's status record as it changes
12// BUDGET the account's rate-limit windows, from the engine's own measurement
13// COMMANDS /fleet and /inbox, answered without a turn
14// DELIVERY the fleet's prompts, submitted as turns of their own
15// GUARDS the fleet's refusals in front of Bash and the MCP tools, failing CLOSED
16// BAND a lead's team above its prompt
17// LEDGER every request made of the session, held open until a final message answers it
18// What is written is shaped by ./shape.js, ./handoff.js, ./guard-shape.js, ./band-shape.js and ./ledger.js,
19// plain functions the suite can run with node. The engine follows `$` only into functions
20// declared in this file, never across an import, which is why the hooks are one file and not six.
21//
22// THE OBSERVERS DECIDE NOTHING, THE GUARDS DECIDE AND FAIL CLOSED. STATE, BUDGET, COMMANDS
23// and BAND only observe: a hook of theirs that throws or runs out of time is skipped and the
24// session goes on as if this were not loaded, which is exactly right for an observer, so none
25// carries a `.catch` that could refuse in its place. GUARDS is the one section whose hooks
26// refuse, and each is registered with a `.catch` that refuses too (that section says why).
27// DELIVERY acts, but only on prompts the fleet already decided to send: it refuses nothing,
28// and a failure there leaves the prompt for fleet-send to paste. LEDGER is a nag, not a
29// guard: it observes, and its one act (a single re-prompt per item) fails OPEN.
30// Nothing here touches the network. The one model call is LEDGER's judge, made only when a
31// turn left something to judge. Every file and process call is bounded
32// (IO_MS), because a `$` call in flight does not count against a hook's budget and an
33// unbounded one would hold a turn open for as long as the filesystem stalled.
34
35import {
36 withState, stateAfterTurn, usageRecord, sockOfTmux, asksAPerson,
37} from './shape.js'
38import {
39 mergesAPr, changesABoundary, isMergeTool, prSelector, boundaryOn, mergeRefusal,
40 SETTING_REFUSAL, failedClosed, answerCalls, JARVIS_TOOLS, fleetTool, callArgs, markerSock,
41 jarvisMightAct,
42} from './guard-shape.js'
43import { teamOf, latestBySlot, summarize, prSummary, bandRuns } from './band-shape.js'
44import {
45 ledgerFile, parseLedger, ledgerConfig, sourceOf, openItems, ledgerSummary,
46 soundsLikeAPromise, judgePrompt, parseVerdict, applyVerdict, gateTargets, gatePrompt,
47 markGated, closeByHand, clearOpen, listing, ledgerRuns, stampTurn, withdrawn, dropItems, turnBlocks,
48 judgeBatches, JUDGE_TOKENS,
49 markInterrupted, addPrompt, closeByAgent, dropByAgent, addByAgent, contextNote, TOOL,
50} from './ledger.js'
51import {
52 spoolOf, entryId, waiting, staleReceipts, replyOf, replyMarker, armedMarker, isTurnOf,
53} from './handoff.js'
54
55// ── the record ──────────────────────────────────────────────────────────────
56//
57// ONE RECORD, TWO WRITERS. hooks/fleet-event.sh has always written
58// <fleet dir>/<session_id>.json, and every reader (the grid, the phone, the digest,
59// fleet-list, the governor) already knows how to find it and scope it by socket. So the
60// mod MERGES its fields into that record rather than starting a file beside it, and the
61// shell hook carries them forward when it rewrites the file. A second file would be a
62// second answer to "what is this session doing", and this repo has paid for that shape
63// (two status files for one session, the stale one shadowing the live one).
64//
65// THE SHELL HOOK OWNS THE RECORD'S EXISTENCE. It resolves who the session is (the slot
66// from the pane, a renamed pane, the predecessor of a backgrounded conversation) and it
67// removes the record at SessionEnd. The mod never creates one: a patch with nothing to
68// patch does nothing. Creating it here would re-derive that identity in a second
69// language, and a write landing just after SessionEnd would resurrect an ended session
70// as a "lost" card.
71
72const IO_MS = 1500
73const HEARTBEAT_MS = 60_000
74const RUN_MS = 10_000
75
76// The fleet dir, resolved exactly as hooks/fleet-event.sh resolves it, from the same
77// environment, so the two writers can only ever be looking at the same file.
78async function fleetDir($) {
79 const explicit = await $.env.get('CLAUDE_FLEET_DIR')
80 if (explicit) return explicit
81 const cfg = await $.env.get('CLAUDE_CONFIG_DIR')
82 if (cfg) return `${cfg}/fleet`
83 return `${await $.env.get('HOME')}/.claude/fleet`
84}
85
86// Settles to what `work` settles to, or to `fallback` once `ms` has passed or `work`
87// rejected. Never rejects, never waits longer than `ms`.
88async function bounded($, work, ms, fallback) {
89 const timeout = $.clock.sleep(ms).then(() => fallback, () => fallback)
90 return Promise.race([Promise.resolve(work).catch(() => fallback), timeout])
91}
92
93async function readRecord($, file) {
94 try {
95 const rec = JSON.parse(await $.fs.read(file))
96 return rec && typeof rec === 'object' ? rec : null
97 } catch {
98 return null
99 }
100}
101
102async function ownRecord($) {
103 return readRecord($, `${await fleetDir($)}/${await $.session.id()}.json`)
104}
105
106// Patches apply one at a time, in the order asked, so a heartbeat can never land between
107// a state change's read and its write and undo it.
108let queue = Promise.resolve()
109
110// Applies `change(record)` to this session's record and writes the result atomically.
111// `change` returns the new record, or null to leave the file alone. Resolves true when a
112// write landed, false for every other outcome, failures included.
113function patchRecord($, change) {
114 const run = queue.then(() => bounded($, applyPatch($, change), IO_MS * 2, false))
115 queue = run.then(() => undefined, () => undefined)
116 return run
117}
118
119async function applyPatch($, change) {
120 const id = await $.session.id()
121 if (!id) return false
122 const dir = await fleetDir($)
123 const file = `${dir}/${id}.json`
124 const rec = await readRecord($, file)
125 if (!rec) return false
126 const next = change(rec)
127 if (!next) return false
128 // Beside the record and renamed over it, as the shell hook writes: every reader skips
129 // a record it cannot parse "because the hook writes atomically", and a torn read here
130 // would cost the shell hook every field it carries forward. The leading dot keeps the
131 // temporary file out of every `*.json` glob that reads the directory.
132 const tmp = `${dir}/.${id}.mod.tmp`
133 await $.fs.write(tmp, JSON.stringify(next))
134 const moved = await $.process.run(['mv', '-f', tmp, file], { timeoutMs: IO_MS })
135 return moved.exitCode === 0
136}
137
138// Who this session is in its fleet: the socket and slot its record carries (the shell
139// hook resolved them); failing that, the environment, unless the environment is known
140// not to be this session's.
141//
142// A BACKGROUNDED CONVERSATION RUNS UNDER SOMEBODY ELSE'S ENVIRONMENT (hooks/fleet-event.sh
143// has the measurement): its process was spawned by the config dir's daemon, carries no
144// $TMUX, and carries the CLAUDE_FLEET_* of whichever session first started that daemon.
145// With $CLAUDE_JOB_DIR set and no $TMUX the environment names nobody, and only the
146// record, which the shell hook pointed at the conversation's predecessor, may answer.
147async function identity($, rec) {
148 if (rec && rec.sock && rec.slot) return { sock: String(rec.sock), slot: String(rec.slot) }
149 const tmux = await $.env.get('TMUX')
150 const job = await $.env.get('CLAUDE_JOB_DIR')
151 if (job && !tmux) return { sock: '', slot: '' }
152 const sock = sockOfTmux(tmux) || (await $.env.get('CLAUDE_FLEET_SOCK')) || ''
153 const slot = (await $.env.get('CLAUDE_FLEET_SLOT')) || ''
154 return { sock, slot }
155}
156
157// ── STATE ───────────────────────────────────────────────────────────────────
158//
159// Written the moment it changes, with `source: "mod"`; readers prefer it over the pane
160// while the process that wrote it is alive and its heartbeat is recent
161// (lib/mod-status.mjs), and fall back to the pane regex otherwise.
162
163let pid = 0
164let state = ''
165let heartbeat = null
166
167async function setState($, next, turnId) {
168 state = next
169 const nowMs = await $.clock.now()
170 return patchRecord($, rec => withState(rec, next, { nowMs, pid, turnId }))
171}
172
173// The process this module runs in. `$.process.run` starts its child from Claude Code's
174// own process, so the child's parent is the pid a reader can probe; that is what makes
175// a crash visible at once instead of after a missed heartbeat.
176async function learnPid($) {
177 const out = await bounded($, $.process.run(['/bin/sh', '-c', 'echo $PPID'], { timeoutMs: IO_MS }), IO_MS, null)
178 const n = Number(String(out?.stdout || '').trim())
179 return Number.isInteger(n) && n > 1 ? n : 0
180}
181
182async function startState($) {
183 pid = await learnPid($)
184 // A resumed conversation's record can still say `working` from the process that died
185 // mid-turn; this process has started no turn, so say what it IS doing.
186 const turns = await bounded($, $.session.turns(), IO_MS, 0)
187 const first = turns > 0 ? 'ready' : 'idle'
188 // The shell hook's SessionStart may not have written the record yet: try once more a
189 // moment later, rather than creating it here.
190 if (!(await setState($, first))) {
191 $.clock.after(2000, () => { if (state === first) setState($, first) })
192 }
193 // A state can be right for hours (a ready session nobody talks to), so its age says
194 // nothing about whether anybody is still writing it. The heartbeat is what lets a
195 // reader tell "ready for an hour" from "the plugin was disabled an hour ago".
196 if (heartbeat) heartbeat.cancel()
197 heartbeat = $.clock.every(HEARTBEAT_MS, async () => {
198 const nowMs = await $.clock.now()
199 await patchRecord($, rec => (rec.mod && pid && rec.mod.pid === pid)
200 ? { ...rec, mod: { ...rec.mod, hb: nowMs } } : null)
201 })
202}
203
204async function onTurnStart($, e, next) {
205 currentTurn = e.turnId
206 turnsStarted++
207 await setState($, 'working', e.turnId)
208 await deliveryTurnStart($, e)
209 await ledgerTurnStart($, e)
210 return next(e)
211}
212
213// A subagent's turn completes too while the main turn runs on: only the main loop's end
214// (no agentId) ends what the session is doing.
215async function onTurnComplete($, e, next) {
216 const done = await next(e)
217 if (e.agentId === undefined) await setState($, stateAfterTurn(e.reason))
218 deliveryTurnComplete(e)
219 // Not awaited: the judge is a model call, and the turn's end must not wait on it.
220 if (e.agentId === undefined) void ledgerTurnComplete($, e, takeSteps(e)).catch(() => {})
221 return done
222}
223
224// Every step's text is passed through untouched and noted for the ledger's judge, which
225// reads the whole turn (see turnBlocks in ./ledger.js). A subagent's steps are its own.
226async function* onTurnStep($, e, next) {
227 const r = yield* next(e)
228 if (e.agentId === undefined && r && r.answer) noteStep(e.turnId, r.answer)
229 return r
230}
231
232// THE PERMISSION DIALOG, FROM THE VERDICT THAT OPENS IT. The obvious events are
233// classic.PermissionRequest and classic.Notification, and on this build neither reaches
234// a user-tier mod: the debug log reads `ghostfleet: classic.PermissionRequest bypassed by
235// cc-plugin-sec-default (tier user); beneath runs`, for both, every time. The harness
236// cannot show that (it has no such plugin above the mod), which is how a hook that
237// passed every test never fired once in a real session.
238// `tool.check` is the engine's own decision, and an `ask` on a real call (one with a
239// tool_use_id, not a `$.tool.check` query) is the call being put to "the mode's decider".
240// In manual, accept-edits and plan mode that decider is the dialog (measured: need-you
241// within 2s of the turn starting, where the shell hook's Notification took 6). A bypass
242// session's verdict is `allow`, so it never reads as need-you.
243// NOT HANDLED: in auto mode the decider is a classifier, so a classified call would read
244// need-you until it resolves. A hook cannot read the permission mode on this build (the
245// footer's SessionMode labels were measured empty through manual, accept-edits and plan)
246// and auto mode was not available to measure, so no guess is coded here.
247async function onToolCheck($, e, next) {
248 const verdict = await next(e)
249 if (asksAPerson(e, verdict)) await setState($, 'need-you')
250 return verdict
251}
252
253// The call the dialog was about has run (or been refused): the turn is moving again.
254// tool.call wraps the permission step and the tool, so this resolves after both.
255async function onToolCall($, e, next) {
256 const done = await next(e)
257 if (state === 'need-you') await setState($, 'working')
258 return ledgerNoteOnTool($, done)
259}
260
261// Inside SessionEnd's one short bound (1.5s for every hook together). No state is
262// written: the shell hook removed the record a moment ago, and the only job here is to
263// make sure a write that was already in flight has not put it back. So the queue is
264// drained first and the record removed after it; anything queued later finds no record
265// and, by the rule above, writes nothing.
266async function onSessionEnd($, e, next) {
267 if (e.reason !== 'clear' && heartbeat) { heartbeat.cancel(); heartbeat = null }
268 state = ''
269 await deliveryEnd($)
270 const done = await next(e)
271 await bounded($, queue, 500, null)
272 const dir = await fleetDir($)
273 await bounded($, $.process.run(['rm', '-f', `${dir}/${e.sessionId}.json`, `${dir}/.${e.sessionId}.mod.tmp`],
274 { timeoutMs: 500 }), 500, null)
275 return done
276}
277
278// ── BUDGET ──────────────────────────────────────────────────────────────────
279//
280// `session.measure` is the status bar's figure pushed rather than scraped: after each
281// main-thread turn and whenever a window moves a whole point. bin/fleet-governor reads
282// it from the record instead of the pane, which below ~100 columns carries no figure.
283
284async function onMeasure($, e, next) {
285 const nowMs = await $.clock.now()
286 await patchRecord($, rec => ({ ...rec, usage: usageRecord(e, nowMs) }))
287 return next(e)
288}
289
290// ── COMMANDS ────────────────────────────────────────────────────────────────
291//
292// A lead that wants its inbox asks the model, and the model spends a turn running
293// fleet-inbox through Bash: an API round trip, a tool call, a permission check, and a
294// wait behind whatever the lead was doing. These run the SAME commands (one copy of what
295// the inbox is and of what "seen" means) as plain processes, `immediate`, so they
296// answer mid-turn as well as at the prompt.
297//
298// THE MODEL READS THE OUTPUT: a command's `text` is a transcript row the model reads as
299// well as the person. For /inbox that is the point. Running it marks the rows seen,
300// exactly as fleet-inbox does, so if the model could not read them they would be gone
301// from the one place it looks, and the lead would act on an inbox it never saw. /fleet
302// is read for the same reason: "who is free" is the question asked right before a
303// dispatch.
304
305async function startCommands($) {
306 await bounded($, Promise.all([
307 $.command.register({ name: 'fleet', description: "This fleet's sessions and their states (no turn)", immediate: true }),
308 $.command.register({ name: 'inbox', description: 'What needs the lead, marked seen as fleet-inbox does (no turn)', immediate: true }),
309 ]), IO_MS, null)
310}
311
312// The commands ship beside the plugin: the runtime holds bin/ and mods/ side by side, so
313// the copy that answers is the one cf-sync deployed with this module, never whichever
314// fleet-inbox happens to be first on the session's PATH.
315async function runFleet($, name) {
316 const who = await identity($, await ownRecord($))
317 if (!who.sock) return { text: `${name}: this session is not in a fleet (no socket in its record or its environment).` }
318 // The identity rides in the environment as well as on -s: fleet-inbox decides WHOSE
319 // inbox to read from CLAUDE_FLEET_SLOT, and in a backgrounded conversation the
320 // inherited value is another session's.
321 const env = { CLAUDE_FLEET_SOCK: who.sock, CLAUDE_FLEET_SLOT: who.slot }
322 const argv = [`${$.plugin.root}/../../bin/${name}`, '-s', who.sock]
323 const out = await bounded($, $.process.run(argv, { env, timeoutMs: RUN_MS }), RUN_MS + 1000, null)
324 if (!out) return { text: `${name}: did not answer within ${RUN_MS / 1000}s.` }
325 const text = [out.stdout, out.stderr].map(s => String(s || '').trimEnd()).filter(Boolean).join('\n')
326 return { text: text || `${name}: (no output)` }
327}
328
329async function onFleet($) { return runFleet($, 'fleet-list') }
330async function onInbox($) { return runFleet($, 'fleet-inbox') }
331
332// ── DELIVERY ────────────────────────────────────────────────────────────────
333//
334// The fleet's prompts submitted from in here, not typed into the pane. bin/fleet-send
335// used to deliver the way a person would: paste into the input box, press Enter. Each of
336// its known failures came from standing outside: a paste glued onto a half-typed message
337// (hence the empty-composer guards and deferrals), an Enter that raced the paste and
338// never submitted ("could not confirm submit"), a prompt pasted into a busy session
339// folded into the running turn so the next Stop belonged to work nobody asked for (hence
340// arming reply-to on UserPromptSubmit). From in here it is one call, `$.prompt.submit`,
341// which leaves the person's draft alone, runs as a turn of its own, and whose turn this
342// section can name exactly.
343//
344// THE CHANNEL IS A SPOOL DIRECTORY PER SESSION, polled (./handoff.js has the layout).
345// Claude Code's cross-session messaging was the other candidate and loses on each count
346// that matters here: its sender has to be a Claude session, and fleet-send is run by a
347// shell, by codex, by opencode, by the phone server; its registry is per config dir, so a
348// lead on another profile cannot address it; it lands framed as a peer's message rather
349// than as the prompt; and one in flight when the mod reloads is gone. A file is written
350// by anything, survives a reload, a crash and a restart, and orders by name. The cost is
351// latency, one poll period at worst (POLL_MS), against a turn that takes seconds.
352//
353// EACH STEP IS A RENAME, so exactly one side owns an entry at any moment:
354// fleet-send writes <id>.json and waits for <id>.done;
355// this claims it (.json -> .taken) only while no turn runs, and submits it; at the
356// turn.start whose text is that prompt it writes <id>.done with the turnId and arms
357// the prompt's reply address, if it has one, for THAT turn;
358// fleet-send, finding the entry unclaimed when its window closes, takes it back
359// (.json -> .revoked) and pastes. The loser of a rename knows it lost, so a prompt is
360// never both submitted and pasted.
361//
362// NOT HERE: the queue, and --now. A prompt for a busy session still goes to fleet-send's
363// <sock>.<slot>.queue, counted on the card and drained by the shell hook's Stop through
364// fleet-send, which hands each one here: one queue, one order, and a mod that dies with
365// prompts waiting leaves them where the shell drain finds them. --now still pastes: the
366// API runs a plugin's prompt as its own turn, once idle, and the only way into a running
367// turn is a peer's message, which the model reads as somebody else's words.
368//
369// Fails open like the rest: a section that never claims costs the sender its window,
370// then the paste it would have made anyway.
371
372const POLL_MS = 500
373// How long a claimed prompt may take to reach its turn.start before it is written off: the
374// engine runs it "once idle", and a person's own turn can put that off.
375const START_MS = 120_000
376
377let turnRunning = false
378let pending = null // { id, text, reply, at }: submitted, not started
379let claiming = false
380let poll = null
381let spool = ''
382let deliverSid = ''
383
384const runBounded = ($, argv, ms = IO_MS) =>
385 bounded($, $.process.run(argv, { timeoutMs: ms }), ms + 500, null)
386
387async function writeSpool($, dir, name, text) {
388 const tmp = `${dir}/.${name}.tmp`
389 await bounded($, $.fs.write(tmp, text), IO_MS, null)
390 const moved = await runBounded($, ['mv', '-f', tmp, `${dir}/${name}`])
391 return moved?.exitCode === 0
392}
393
394async function receipt($, id, fields) {
395 const nowMs = await $.clock.now()
396 await writeSpool($, spool, `${id}.done`, JSON.stringify({ id, ...fields, at: nowMs }))
397 await runBounded($, ['rm', '-f', `${spool}/${id}.taken`])
398}
399
400// One at a time, and only while idle: a prompt claimed mid-turn would sit inside the
401// engine, where fleet-send can no longer take it back and nothing shows it waiting.
402async function deliverTick($) {
403 if (claiming || pending || turnRunning || !spool) return
404 claiming = true
405 try {
406 const entries = await bounded($, $.fs.list(spool), IO_MS, null)
407 if (!entries) return
408 const nowMs = await $.clock.now()
409 const old = staleReceipts(entries, nowMs)
410 if (old.length) void runBounded($, ['rm', '-f', ...old.map(n => `${spool}/${n}`)])
411 const id = waiting(entries)[0]
412 if (!id) return
413 const took = await runBounded($, ['mv', `${spool}/${id}.json`, `${spool}/${id}.taken`])
414 if (took?.exitCode !== 0) return // revoked under us: it is theirs
415 const entry = await readRecord($, `${spool}/${id}.taken`)
416 if (!entry || typeof entry.text !== 'string' || !entry.text.trim()) {
417 await receipt($, id, { error: 'unreadable entry' })
418 return
419 }
420 // `kind: "nudge"`: a wake-up fleet-send was told is one (--nudge), never a request.
421 pending = { id, text: entry.text, reply: replyOf(entry), at: nowMs, nudge: entry.kind === 'nudge' }
422 // The ledger's item for it is made HERE: a plugin's own submit passes every prompt.submit
423 // hook but its own, so LEDGER's never sees it.
424 await ledgerDelivery($, pending)
425 // Settles once the turn started or the engine queued it; turn.start is what names the
426 // turn, so this is not awaited for that. A refusal is answered here.
427 const settled = r => (r && r.drop !== undefined ? { dropped: String(r.drop) } : null)
428 const lost = async () => { if (pending?.ledgered?.length) await ledgerUndo($, pending.ledgered) }
429 Promise.resolve($.prompt.submit({ text: entry.text, asUser: true })).then(
430 async r => { const d = settled(r); if (d && pending?.id === id) { await lost(); pending = null; await receipt($, id, d) } },
431 async err => { if (pending?.id === id) { await lost(); pending = null; await receipt($, id, { error: String(err?.message || err) }) } })
432 } finally {
433 claiming = false
434 }
435}
436
437// THE REPLY ADDRESS, ARMED BY THE TURN ITSELF. hooks/fleet-event.sh relays the answer on
438// the Stop of an armed address, and for a pasted prompt arms it on the next
439// UserPromptSubmit, which is right only if that submit was this prompt. Here the turn is
440// known: the address is written and armed together at its turn.start, with the transcript
441// offset (the relay reads the answer from after it) and the turnId, which tells the hook
442// not to re-arm on a prompt somebody types into this turn.
443async function armReply($, reply, turnId) {
444 const dir = await fleetDir($)
445 const rec = await readRecord($, `${dir}/${deliverSid}.json`)
446 if (!rec || !rec.sock || !rec.slot) return
447 let lines = 0
448 if (rec.transcript) {
449 const wc = await runBounded($, ['wc', '-l', String(rec.transcript)])
450 lines = Number(String(wc?.stdout || '').trim().split(/\s+/)[0]) || 0
451 }
452 const base = `${rec.sock}.${rec.slot}.reply-to`
453 await writeSpool($, dir, base, replyMarker(reply))
454 await writeSpool($, dir, `${base}.armed`, armedMarker(lines, turnId))
455}
456
457// The turn the claimed prompt started is the first main-loop turn.start carrying its text.
458// One that starts first with other text (a person typing in the same second) is theirs,
459// and the prompt goes on waiting for its own.
460async function deliveryTurnStart($, e) {
461 turnRunning = true
462 const p = pending
463 if (isTurnOf(p, e.text)) {
464 pending = null
465 await bounded($, (async () => {
466 if (p.reply) await armReply($, p.reply, e.turnId)
467 await receipt($, p.id, { turnId: e.turnId })
468 // Recorded at its prompt.submit when that hook saw it (so its note could name it);
469 // stamped with its turn here. A nudge is never an item.
470 if (p.ledgered) await stampItems($, p.ledgered, e.turnId)
471 if (p.ledgered && p.ledgered.length) noteDue = e.turnId
472 else if (!p.nudge) await ledgerFleetItem($, p.text, e.turnId)
473 })(), IO_MS * 4, null)
474 } else if (p && (await $.clock.now()) - p.at > START_MS) {
475 pending = null
476 await bounded($, receipt($, p.id, { error: 'its turn never started' }), IO_MS * 2, null)
477 }
478}
479
480function deliveryTurnComplete(e) {
481 if (e.agentId === undefined) turnRunning = false
482}
483
484async function deliveryStart($) {
485 await bounded($, (async () => {
486 const sid = await $.session.id()
487 if (!sid || !pid) return
488 const dir = spoolOf(await fleetDir($), sid)
489 await runBounded($, ['mkdir', '-p', dir])
490 // A claim an earlier process of this conversation made and never finished goes back,
491 // so the next tick delivers it instead of leaving it stranded as `.taken`.
492 for (const t of (await bounded($, $.fs.list(dir), IO_MS, null)) || []) {
493 const id = t && t.kind === 'file' ? entryId(t.name, '.taken') : null
494 if (id) await runBounded($, ['mv', '-n', `${dir}/${id}.taken`, `${dir}/${id}.json`])
495 }
496 spool = dir
497 deliverSid = sid
498 pending = null
499 turnRunning = false
500 await writeSpool($, dir, '.ready', `${pid}\n`)
501 if (poll) poll.cancel()
502 poll = $.clock.every(POLL_MS, () => { void deliverTick($) })
503 })(), IO_MS * 6, null)
504}
505
506// The spool stays (a prompt in it is somebody's); only this process's claim on it ends.
507async function deliveryEnd($) {
508 if (poll) { poll.cancel(); poll = null }
509 const dir = spool
510 spool = ''
511 deliverSid = ''
512 if (dir) await bounded($, $.process.run(['rm', '-f', `${dir}/.ready`], { timeoutMs: 400 }), 450, null)
513}
514
515// ── wiring ──────────────────────────────────────────────────────────────────
516
517async function onSessionStart($, e, next) {
518 const started = await next(e)
519 await startState($)
520 await startCommands($)
521 await ledgerStart($)
522 startBand($)
523 await deliveryStart($)
524 return started
525}
526
527/** @type {import('claude-code').Register} */
528export const register = on => {
529 on('session.start', onSessionStart)
530 on('turn.start', onTurnStart)
531 on('turn.step', onTurnStep)
532 on('turn.complete', onTurnComplete)
533 on('tool.check', onToolCheck)
534 on('tool.call', onToolCall)
535 on('session.end', onSessionEnd)
536 on('session.measure', onMeasure)
537 on('command.run', { command: 'fleet' }, onFleet)
538 on('command.run', { command: 'inbox' }, onInbox)
539 // GUARDS and BAND, below the wiring so each is one section of its own.
540 on('tool.call', { tool: 'Bash' }, onBash).catch(bashFailedClosed)
541 on('tool.call', { tool: /^mcp__/ }, onMcp).catch(mcpFailedClosed)
542 on('ui.render', { component: 'AbovePrompt' }, onBandRender)
543 on('prompt.submit', onPromptSubmit)
544 on('command.run', { command: 'ledger' }, onLedgerCommand)
545 // Spelled out, not TOOL.*: `claude plugin validate` reads a matcher only from its literal.
546 on('tool.call', { tool: 'mcp__ghostfleet__ledger_close' }, onLedgerTool)
547 on('tool.call', { tool: 'mcp__ghostfleet__ledger_drop' }, onLedgerTool)
548 on('tool.call', { tool: 'mcp__ghostfleet__ledger_add' }, onLedgerTool)
549}
550
551// ── GUARDS ──────────────────────────────────────────────────────────────────
552//
553// The fleet's refusals, in front of the tool, from inside the session.
554//
555// Until the mod, each of these was a shell hook or a check inside a command, and each could
556// only fail OPEN: a PreToolUse hook that cannot find jq exits 0 and the call goes on, which
557// is right for a hook that must never break a session and wrong for one whose whole job is
558// to say no. Here every guard is a `tool.call` hook registered with a `.catch` that refuses:
559// a hook that throws, answers a wrong shape, or outlasts its budget DENIES the call. An
560// observer (register.js) fails open; a guard fails closed.
561//
562// JARVIS the confirm-list (docs/jarvis.md): merge/push, stop/reclaim/force,
563// worktree and project removal, answering a worker's prompt, a second
564// worker per request. Bash and the ghostfleet MCP tools.
565// MERGE a worker does not merge its own PR, nor change its own boundaries
566// ("workers can merge", hooks/fleet-guard.sh). Bash and merge_pull_request.
567// APPROVE an agent does not approve another agent's tool call ("agents can approve
568// tool calls", bin/fleet-answer). fleet-answer in Bash and fleet_answer.
569//
570// ONE RULE, ASKED FROM HERE. Jarvis's proposals live behind a lock three processes share,
571// and fleet-answer reads a pane to decide; neither is something to write twice. So those two
572// ask the code that already decides (lib/mod-gate.mjs, `fleet-answer --check`) as a process,
573// and refuse on anything that is not a clear answer. The merge guard's rule is small enough
574// to hold here (./guard-shape.js, plain functions the suite runs with node) and its facts come from git, tmux, the fleet dir and gh.
575//
576// THE SHELL VERSIONS STAY, for every session without the mod: another agent, an older
577// Claude, an organization that blocks mods. Where both run, they agree: the shell guard
578// refuses what this refuses, and Jarvis's gate leaves a single-use relay so the door behind
579// this one passes the call the owner said yes to instead of spending his yes a second time.
580//
581// THE COST, for the call that is none of these. Every Bash call in every session comes
582// through the Bash hook, so it decides from the command text first: no process, no file,
583// unless the text could be a merge, a boundary write or a fleet-answer, or the session is
584// Jarvis's master (one small file read).
585
586const GUARD_MS = 15_000
587
588// A process call that rejects rather than settling to a fallback: a guard that could not
589// learn a fact refuses, and the throw is how it gets to the `.catch` that does. The bound is
590// the call's own timeout (it rejects), not a $.clock.sleep race: time inside $.process.run
591// is not the hook's, while a sleep's is, and a hook that outruns its 10s is skipped.
592async function guardRun($, argv, init = {}) {
593 const out = await $.process.run(argv, { ...init, timeoutMs: init.timeoutMs || GUARD_MS })
594 return { code: out.exitCode, out: String(out.stdout || ''), err: String(out.stderr || '') }
595}
596
597const bin = ($, name) => `${$.plugin.root}/../../bin/${name}`
598const lib = ($, name) => `${$.plugin.root}/../../lib/${name}`
599
600// ── JARVIS ──────────────────────────────────────────────────────────────────
601
602async function jarvisDir($) {
603 return (await $.env.get('CLAUDE_FLEET_JARVIS_DIR')) || `${await $.env.get('HOME')}/.config/ghostfleet`
604}
605
606// Is this session Jarvis's master? Cheaply, from the marker and the session's own
607// identity; lib/mod-gate.mjs asks again from the live $TMUX, exactly as the MCP door does,
608// so this only decides whether to ask at all. No marker is no Jarvis, as in the shell guard.
609// `command`, for a Bash call: also whether the confirm-list could act on it at all.
610async function maybeJarvis($, command) {
611 const dir = await jarvisDir($)
612 const file = `${dir}/jarvis`
613 if (!(await $.fs.exists(file))) return false
614 // Switched off (lib/jarvis.mjs enabled) is no Jarvis: nothing to ask the gate, which would
615 // otherwise refuse — it fails closed — on behalf of a Jarvis that is not there.
616 if (await $.fs.exists(`${file}.enabled`) && /^\s*off\s*$/.test(await $.fs.read(`${file}.enabled`))) return false
617 const sock = markerSock(await $.fs.read(file))
618 if (!sock) return false
619 if (command !== undefined && !jarvisMightAct(command, sock, dir)) return false
620 const who = await identity($, await ownRecord($))
621 return who.sock === sock && (!who.slot || who.slot === 'master')
622}
623
624// 0 = go, 2 = refused; anything else is no answer, which throws.
625async function askGate($, argv, stdin) {
626 const r = await guardRun($, ['node', lib($, 'mod-gate.mjs'), ...argv], { stdin })
627 if (r.code === 0) return { ok: true, granted: /\bgranted\b/.test(r.out) }
628 if (r.code === 2) return { ok: false, text: r.err.trim() }
629 throw new Error(`lib/mod-gate.mjs ${argv[0]} exited ${r.code}: ${r.err.trim().slice(0, 300)}`)
630}
631
632// ── MERGE ───────────────────────────────────────────────────────────────────
633
634// git -C dir rev-parse …: the value, or null when dir is not in a repository at all. Any
635// other failure (git missing, a timeout, a broken repo) throws.
636async function gitIn($, dir, args) {
637 const r = await guardRun($, ['git', '-C', dir, ...args])
638 if (r.code === 0) return r.out.trim()
639 if (/not a git repository|cannot change to|No such file/i.test(r.err)) return null
640 throw new Error(`git ${args.join(' ')} in ${dir}: ${r.err.trim().slice(0, 200)}`)
641}
642
643async function isLinked($, dir) {
644 if (!dir) return false
645 const gd = await gitIn($, dir, ['rev-parse', '--path-format=absolute', '--git-dir'])
646 if (gd === null) return false
647 const gcd = await gitIn($, dir, ['rev-parse', '--path-format=absolute', '--git-common-dir'])
648 return Boolean(gd && gcd && gd !== gcd)
649}
650
651// The registered project a checkout belongs to, as hooks/fleet-guard.sh registered_project
652// finds it: ~/.config/ghostfleet/projects and projects.<profile>, physical paths both sides.
653async function registeredProject($, gitroot) {
654 const dir = `${await $.env.get('HOME')}/.config/ghostfleet`
655 if (!(await $.fs.exists(dir))) return ''
656 const files = (await $.fs.list(dir)).map(f => f.name).filter(n => n === 'projects' || /^projects\.[A-Za-z0-9_-]+$/.test(n))
657 const home = await $.env.get('HOME')
658 for (const f of files) {
659 for (const line of (await $.fs.read(`${dir}/${f}`)).split('\n')) {
660 if (/^\s*#/.test(line)) continue
661 const [name, raw] = line.split('\t')
662 if (!name || !raw) continue
663 let root = raw.replace(/^~/, home).replace(/\/$/, '')
664 const phys = await guardRun($, ['/bin/sh', '-c', 'cd "$1" 2>/dev/null && pwd -P', 'sh', root])
665 if (phys.code === 0 && phys.out.trim()) root = phys.out.trim()
666 if (gitroot === root || gitroot.startsWith(`${root}/`)) return name
667 }
668 }
669 return ''
670}
671
672// The sessions whose <sock>.<child>.parent marker names `me`, and their branches.
673async function childBranches($, dir, sock, me) {
674 const kids = []
675 for (const f of await $.fs.list(dir)) {
676 if (!f.name.startsWith(`${sock}.`) || !f.name.endsWith('.parent')) continue
677 const parent = (await $.fs.read(`${dir}/${f.name}`)).split('\n')[0].trim()
678 if (parent === me) kids.push(f.name.slice(sock.length + 1, -'.parent'.length))
679 }
680 let manifest = ''
681 try { manifest = await $.fs.read(`${dir}/${sock}.manifest.tsv`) } catch {}
682 const branches = []
683 for (const k of kids) {
684 let b = ''
685 for (const line of manifest.split('\n')) {
686 const c = line.split('\t')
687 if (c[1] === k && c[2]) { b = c[2]; break }
688 }
689 if (!b) {
690 const p = await guardRun($, ['tmux', '-L', sock, 'display-message', '-p', '-t', k, '#{pane_current_path}'])
691 if (p.code === 0 && p.out.trim()) b = (await gitIn($, p.out.trim(), ['rev-parse', '--abbrev-ref', 'HEAD'])) || ''
692 }
693 if (b) branches.push(b)
694 }
695 return branches
696}
697
698// null = not this guard's business; otherwise { deny } or { ok }.
699async function mergeGuard($, e) {
700 const bash = e.tool === 'Bash'
701 const kind = !bash ? 'merge' : mergesAPr(e.command) ? 'merge' : changesABoundary(e.command) ? 'setting' : ''
702 if (!kind) return null
703 const cwd = await $.session.cwd()
704 const root = await $.session.root()
705 const gitroot = await gitIn($, cwd, ['rev-parse', '--show-toplevel'])
706 if (gitroot === null) return null // not a repo: nothing here to protect
707 // Is there a fleet here at all? Inside one, or beside a registered project's live one.
708 const who = await identity($, await ownRecord($))
709 let sock = who.sock
710 if (!sock) {
711 const proj = await registeredProject($, gitroot)
712 if (!proj) return null
713 if ((await guardRun($, ['tmux', '-L', `cf-${proj}`, 'list-sessions'])).code !== 0) return null
714 sock = `cf-${proj}`
715 }
716 // From the directory it was started in as well as the one it is in now, so a `cd` into
717 // the main checkout is not a way round it. The main checkout is the lead's: it merges.
718 const where = (await isLinked($, cwd)) ? cwd : (await isLinked($, root)) ? root : ''
719 if (!where) return null
720 if (kind === 'setting') return { deny: SETTING_REFUSAL }
721 const branch = (await gitIn($, where, ['rev-parse', '--abbrev-ref', 'HEAD'])) || ''
722 const dir = await fleetDir($)
723 const pane = await $.env.get('TMUX_PANE')
724 let sess = ''
725 if (pane) {
726 const r = await guardRun($, ['tmux', '-L', sock, 'display-message', '-p', '-t', pane, '#{session_name}'])
727 if (r.code === 0) sess = r.out.trim()
728 }
729 sess = sess || who.slot || (await $.env.get('CLAUDE_FLEET_SLOT')) || ''
730 const markers = new Set((await $.fs.list(dir)).map(f => f.name))
731 if (boundaryOn('workers-merge', sock, sess, n => markers.has(n))) return { ok: true }
732 // A SUB-LEAD MERGES ITS CHILDREN'S PRs INTO ITS OWN BRANCH (hooks/fleet-guard.sh says
733 // why): base = this session's branch AND head = one of its children's branches. Asked of
734 // GitHub; any failure to learn them refuses.
735 if (sess && branch && !markers.has(`${sock}.${sess}.parent`) && !markers.has(`${sock}.${sess}.workers-merge-off`)) {
736 const kids = await childBranches($, dir, sock, sess)
737 if (kids.length) {
738 const { sel, repo } = bash ? prSelector(e.command) : { sel: String(e.pullNumber ?? e.pull_number ?? ''), repo: '' }
739 const r = await guardRun($, ['gh', 'pr', 'view', ...(sel ? [sel] : []), ...(repo ? ['-R', repo] : []),
740 '--json', 'baseRefName,headRefName', '-q', '.baseRefName + "\\t" + .headRefName'], { cwd: where })
741 const [base, head] = r.out.trim().split('\t')
742 if (r.code === 0 && base === branch && head && kids.includes(head)) return { ok: true }
743 }
744 }
745 return { deny: mergeRefusal(where, branch, sock, sess) }
746}
747
748// ── APPROVE ─────────────────────────────────────────────────────────────────
749
750// fleet-answer's own decision for each fleet-answer the command runs. A command whose
751// words cannot be known from here (answerCalls null) is left to fleet-answer itself, which
752// still decides when the command runs: that is today's behaviour, not a gap this opens.
753async function answerGuardBash($, e) {
754 if (!/fleet-answer/.test(e.command)) return null
755 const calls = answerCalls(e.command)
756 if (!calls || !calls.length) return null
757 for (const argv of calls) {
758 const r = await guardRun($, [bin($, 'fleet-answer'), '--check', ...argv], { cwd: await $.session.cwd() })
759 if (r.code === 0 || r.code === 1) continue
760 if (r.code === 3 || r.code === 4) return { deny: r.err.trim() }
761 throw new Error(`fleet-answer --check exited ${r.code}: ${r.err.trim().slice(0, 300)}`)
762 }
763 return null
764}
765
766// ── the hooks ───────────────────────────────────────────────────────────────
767
768async function onBash($, e, next) {
769 // Jarvis first, as its hook is the first door today: a refused proposal ends it there.
770 if (await maybeJarvis($, e.command)) {
771 const g = await askGate($, ['jarvis-bash'], e.command)
772 if (!g.ok) return { deny: g.text }
773 }
774 const merge = await mergeGuard($, e)
775 if (merge && merge.deny) return { deny: merge.deny }
776 const answer = await answerGuardBash($, e)
777 if (answer && answer.deny) return { deny: answer.deny }
778 return next(e)
779}
780
781async function onMcp($, e, next) {
782 if (isMergeTool(e.tool)) {
783 const merge = await mergeGuard($, e)
784 if (merge && merge.deny) return { deny: merge.deny }
785 return next(e)
786 }
787 const name = fleetTool(e.tool)
788 if (!JARVIS_TOOLS.has(name)) return next(e)
789 const a = callArgs(e)
790 let granted = false
791 if (await maybeJarvis($)) {
792 const g = await askGate($, ['jarvis-mcp', name], JSON.stringify(a))
793 if (!g.ok) return { deny: g.text }
794 granted = g.granted
795 }
796 // The owner's yes to a fleet_answer IS the human approval: the MCP door adds
797 // --human-approved for exactly that call, so asking fleet-answer without it would refuse
798 // what he just said yes to.
799 if (name === 'fleet_answer' && !granted) {
800 const g = await askGate($, ['answer-mcp'], JSON.stringify(a))
801 if (!g.ok) return { deny: g.text }
802 }
803 return next(e)
804}
805
806// A guard that throws refuses. Where it had already called `next`, the call it judged has
807// run, and replaying that answer is the only honest thing left (the engine runs nothing
808// twice); every guard here judges before it calls `next`. A re-entry (the call raised
809// beneath one of this hook's own `$` calls) was judged by nobody, and is refused too.
810function whyFailed(next) {
811 const err = next.error || {}
812 return err.message || (err.kind === 're-entry' ? 'it was raised beneath the guard\'s own call' : err.kind) || 'the guard failed'
813}
814function bashFailedClosed($, e, next) {
815 return next.called ? next(e) : { deny: failedClosed('this command', whyFailed(next)) }
816}
817function mcpFailedClosed($, e, next) {
818 return next.called ? next(e) : { deny: failedClosed('this call', whyFailed(next)) }
819}
820
821// ── BAND ────────────────────────────────────────────────────────────────────
822//
823// A lead's team at a glance, above its prompt, without a turn.
824//
825// A lead asks "who needs me" by running fleet-inbox, or now /fleet: a question it has to
826// think to ask, in a session whose attention is on whatever it was doing. The band says it
827// without being asked, in one line above the prompt:
828//
829// 3 workers · 1 working · 1 need you · 2 PRs green
830//
831// Only for a LEAD: the session named `master` on its fleet socket, or a worker with children
832// (a sub-lead: some <sock>.<child>.parent names it). A worker without children draws nothing,
833// and a worker becomes a sub-lead's band the tick after its first child appears.
834//
835// CHEAP BY CONSTRUCTION. Nothing here starts a turn or calls a model. Every TICK_MS the
836// session lists its fleet dir (one directory read, which is all a non-lead ever does) and, a
837// lead only, asks tmux which sessions are alive and reads the records it has not read at
838// that mtime. PRs are GitHub's to say, so a lead asks `gh` every PR_MS, bounded, and says
839// `PRs ?` when it cannot. The band redraws only when what it says changes ($.state).
840//
841// An observer, like STATE above: a tick that fails leaves the band as it was,
842// and a draw that fails is skipped and the engine draws its own (nothing, for this band).
843
844const BAND_TICK_MS = 5_000
845const BAND_PR_MS = 120_000
846const GH_MS = 15_000
847
848// The one value the band draws, declared in ../types/index.d.ts.
849const BAND = /** @type {const} */ ({ plugin: 'ghostfleet', key: 'band' })
850
851let bandTick = null
852let prTick = null
853let lead = null // { sock, me, team } while this session is a lead
854let prs // undefined: not read yet; null: could not; else the summary
855const seen = new Map() // record file -> { mtimeMs, rec }
856
857const soon = ($, work, fallback, ms = IO_MS) => bounded($, work, ms, fallback)
858
859async function aliveSessions($, sock) {
860 const out = await soon($, $.process.run(['tmux', '-L', sock, 'list-sessions', '-F', '#{session_name}'], { timeoutMs: IO_MS }), null)
861 if (!out || out.exitCode !== 0) return null
862 return String(out.stdout || '').split('\n').map(s => s.trim()).filter(Boolean)
863}
864
865async function readTeamRecords($, dir, entries) {
866 const recs = []
867 for (const f of entries) {
868 if (f.kind !== 'file' || !f.name.endsWith('.json') || f.name.startsWith('.')) continue
869 const file = `${dir}/${f.name}`
870 const had = seen.get(file)
871 if (had && had.mtimeMs === f.mtimeMs) { recs.push(had.rec); continue }
872 let rec = null
873 try { rec = JSON.parse(await $.fs.read(file)) } catch {}
874 seen.set(file, { mtimeMs: f.mtimeMs, rec })
875 recs.push(rec)
876 }
877 for (const k of seen.keys()) if (!entries.some(f => `${dir}/${f.name}` === k)) seen.delete(k)
878 return recs
879}
880
881async function childrenOf($, dir, entries, sock, me) {
882 const kids = []
883 for (const f of entries) {
884 if (!f.name.startsWith(`${sock}.`) || !f.name.endsWith('.parent')) continue
885 const parent = await soon($, $.fs.read(`${dir}/${f.name}`), '')
886 if (String(parent).split('\n')[0].trim() === me) kids.push(f.name.slice(sock.length + 1, -'.parent'.length))
887 }
888 return kids
889}
890
891async function publish($, value) {
892 const cur = await soon($, $.state.get(BAND), null)
893 if (JSON.stringify(cur && cur.value) === JSON.stringify(value)) return
894 await soon($, $.state.set(BAND, value), null)
895}
896
897async function refresh($) {
898 const who = await identity($, await ownRecord($))
899 if (!who.sock || !who.slot) { lead = null; return publish($, null) }
900 const dir = await fleetDir($)
901 const entries = await soon($, $.fs.list(dir), null)
902 if (!entries) return
903 const kids = who.slot === 'master' ? [] : await childrenOf($, dir, entries, who.sock, who.slot)
904 if (who.slot !== 'master' && !kids.length) { lead = null; return publish($, null) }
905 const alive = await aliveSessions($, who.sock)
906 if (!alive) return
907 const team = teamOf(who.slot, alive, kids)
908 if (!team) { lead = null; return publish($, null) }
909 const wasLead = lead !== null
910 lead = { sock: who.sock, me: who.slot, team, dir }
911 if (!wasLead) refreshPrs($).catch(() => {})
912 const bySlot = latestBySlot(await readTeamRecords($, dir, entries), who.sock)
913 const s = summarize(team, bySlot, await $.clock.now())
914 await publish($, { ...s, prs })
915}
916
917async function refreshPrs($) {
918 if (!lead) return
919 const { me, dir, sock } = lead
920 const cwd = await $.session.cwd()
921 const out = await soon($, $.process.run(['gh', 'pr', 'list', '--state', 'open', '--limit', '100',
922 '--json', 'number,headRefName,baseRefName,statusCheckRollup'], { cwd, timeoutMs: GH_MS }), null, GH_MS + 1000)
923 let list = null
924 try { if (out && out.exitCode === 0) list = JSON.parse(String(out.stdout || '[]')) } catch {}
925 if (!Array.isArray(list)) { prs = null; return refresh($) }
926 let branch = ''
927 if (me !== 'master') {
928 const b = await soon($, $.process.run(['git', 'rev-parse', '--abbrev-ref', 'HEAD'], { cwd, timeoutMs: IO_MS }), null)
929 branch = b && b.exitCode === 0 ? String(b.stdout).trim() : ''
930 }
931 const manifest = String(await soon($, $.fs.read(`${dir}/${sock}.manifest.tsv`), ''))
932 const fleetBranches = manifest.split('\n').map(l => l.split('\t')[2]).filter(Boolean)
933 prs = prSummary(list, { me, branch, fleetBranches })
934 return refresh($)
935}
936
937// The team's row, then the ledger's (LEDGER, below), each only when it has something to say.
938async function onBandRender($, e, next) {
939 if (e.props.hasSurvey) return next(e)
940 const { value } = await $.state.get(BAND)
941 const team = value ? bandRuns(value, value.prs, e.props.bodyColumns) : null
942 const led = (await $.state.get(LEDGER)).value
943 const book = led ? ledgerRuns(led, led.asOf, e.props.bodyColumns) : null
944 if (!team && !book) return next(e)
945 const { Box, Text } = $.ui.resolve(e)
946 const row = (runs, k) => h(Box, { key: k, flexDirection: 'row' },
947 ...runs.map((r, i) => h(Text, {
948 key: String(i), wrap: 'truncate-end',
949 ...(r.color ? { color: r.color } : {}), ...(r.dim ? { dimColor: true } : {}), ...(r.bold ? { bold: true } : {}),
950 }, r.text)))
951 return h(Box, { flexDirection: 'column' }, ...(team ? [row(team, 'team')] : []), ...(book ? [row(book, 'ledger')] : []))
952}
953
954// Started from onSessionStart (one session.start hook per module), after the record exists.
955function startBand($) {
956 if (bandTick) bandTick.cancel()
957 if (prTick) prTick.cancel()
958 refresh($).catch(() => {})
959 bandTick = $.clock.every(BAND_TICK_MS, () => { refresh($).catch(() => {}); ledgerRefresh($).catch(() => {}) })
960 prTick = $.clock.every(BAND_PR_MS, () => { refreshPrs($).catch(() => {}) })
961}
962
963// ── LEDGER ──────────────────────────────────────────────────────────────────
964//
965// Every request made of this session, held open until a final message answers it.
966//
967// A person types three messages while a turn runs; the prompt tells the model to treat each
968// as queued work and never to end a turn with one neither done nor reported not-done. That
969// instruction is dropped often enough to matter, and nothing outside the model could see it
970// happen: the second message's answer is simply never written, and the person finds out
971// when they go looking. So does "I'll merge when it's green", said once and never done.
972// From in here both are visible: `prompt.submit` sees every message the moment Enter is
973// pressed (with the turn it was typed over), `turn.step` sees the text of every step of a
974// turn, and `turn.complete` sees how it ended.
975//
976// RECORD each request an item in <fleet dir>/<session_id>.ledger (./ledger.js has the
977// shape), its open count in the status record (`ledger`), the oldest on the band
978// JUDGE at a main-loop turn.complete that ended with an answer, a small-model call per
979// ten open items (when something is open, or the answer sounds like a promise):
980// which open items the turn's text, all of it, addressed, and what its final text
981// promised. A promise kept open through five judged turns closes as stale
982// GATE items still open after that get ONE re-prompt, ever, naming them
983//
984// A NAG, NOT A GUARD. It fails OPEN everywhere: a judge that errors, times out or answers
985// a wrong shape closes nothing and re-prompts nothing (the item stays open, the failure is
986// noted in the file and the debug log). It never gates a turn the person interrupted, a
987// subagent's run, a headless (-p) session, or after a queued message has already started
988// the next turn (that turn's own end is judged instead). The loop bound is in the data,
989// not in a counter that a reload would reset: an item carries `gated` once it has been
990// re-prompted, and a gated item is never re-prompted again.
991//
992// Not retroactive: only messages submitted after the mod loaded. The judge runs after
993// turn.complete has resolved, never inside it, so the turn's end never waits on a model.
994
995const LEDGER = /** @type {const} */ ({ plugin: 'ghostfleet', key: 'ledger' })
996const JUDGE_MS = 30_000
997
998let ledgerCfg = null
999let ledgerQueue = Promise.resolve()
1000let currentTurn = ''
1001let judging = false
1002let judgeNext = null // a turn.complete that arrived while the judge was busy
1003let turnsStarted = 0 // main-loop turn.starts, so a verdict can tell it went stale
1004let answers = [] // the last few judged turns: { at, turnId, blocks }
1005let stepTexts = new Map() // turnId -> the text each of its main-loop steps wrote, in order
1006let ledgerShown = '' // what the band and the record last said
1007let ledgerAsOf = 0
1008
1009async function ledgerConfigOf($) {
1010 if (ledgerCfg) return ledgerCfg
1011 // Each name spelled out: the engine lists what a module reads from its literal names.
1012 const env = {
1013 CLAUDE_FLEET_LEDGER: await bounded($, $.env.get('CLAUDE_FLEET_LEDGER'), IO_MS, undefined),
1014 CLAUDE_FLEET_LEDGER_GATE: await bounded($, $.env.get('CLAUDE_FLEET_LEDGER_GATE'), IO_MS, undefined),
1015 CLAUDE_FLEET_LEDGER_PROMISES: await bounded($, $.env.get('CLAUDE_FLEET_LEDGER_PROMISES'), IO_MS, undefined),
1016 CLAUDE_FLEET_LEDGER_MODEL: await bounded($, $.env.get('CLAUDE_FLEET_LEDGER_MODEL'), IO_MS, undefined),
1017 }
1018 ledgerCfg = ledgerConfig(k => env[k])
1019 return ledgerCfg
1020}
1021
1022async function ledgerPath($) {
1023 const sid = await $.session.id()
1024 return sid ? ledgerFile(await fleetDir($), sid) : ''
1025}
1026
1027// A missing file is an empty ledger; one that could not be read (a stall, a permission) is
1028// null, and a change against null is skipped: written over, it would wipe the history.
1029const UNREAD = Symbol('unread')
1030async function readLedger($, file) {
1031 const text = await bounded($, (async () => ((await $.fs.exists(file)) ? $.fs.read(file) : ''))(), IO_MS, UNREAD)
1032 return text === UNREAD ? null : parseLedger(text)
1033}
1034
1035// One change at a time, read-modify-write against the FILE, never a copy held here:
1036// fleet-ledger closes items from outside, and a copy would undo it on the next write.
1037function changeLedger($, change) {
1038 const run = ledgerQueue.then(() => bounded($, applyLedger($, change), IO_MS * 3, null))
1039 ledgerQueue = run.then(() => undefined, () => undefined)
1040 return run
1041}
1042
1043async function applyLedger($, change) {
1044 const file = await ledgerPath($)
1045 if (!file) return null
1046 const cur = await readLedger($, file)
1047 if (!cur) return null
1048 const next = change(cur)
1049 if (!next) return null
1050 const dir = file.slice(0, file.lastIndexOf('/'))
1051 const tmp = `${dir}/.${file.slice(dir.length + 1)}.tmp`
1052 await $.fs.write(tmp, JSON.stringify(next))
1053 const moved = await $.process.run(['mv', '-f', tmp, file], { timeoutMs: IO_MS })
1054 if (moved.exitCode !== 0) return null
1055 await showLedger($, next)
1056 return next
1057}
1058
1059// The band's row and the record's count, rewritten only when what they say changed. The
1060// band's "oldest 4m" is drawn against `asOf`, a minute bucket, so the age moves once a
1061// minute rather than redrawing every tick.
1062async function showLedger($, ledger) {
1063 const nowMs = await $.clock.now()
1064 const s = ledgerSummary(ledger, nowMs)
1065 const asOf = Math.floor(nowMs / 60_000) * 60_000
1066 const said = JSON.stringify(s)
1067 if (said === ledgerShown && asOf === ledgerAsOf) return
1068 const recChanged = said !== ledgerShown
1069 ledgerShown = said
1070 ledgerAsOf = asOf
1071 await bounded($, $.state.set(LEDGER, (s.open || s.promises || s.judgeFailing) ? { ...s, asOf } : null), IO_MS, null)
1072 if (recChanged) {
1073 const field = {
1074 open: s.open, promises: s.promises, ...(s.oldest ? { oldest_at: Math.floor(s.oldest.at / 1000) } : {}),
1075 ...(s.judgeFailing ? { judge_failing: s.judgeFailing } : {}),
1076 }
1077 await patchRecord($, rec => ({ ...rec, ledger: field }))
1078 }
1079}
1080
1081async function ledgerStart($) {
1082 const cfg = await ledgerConfigOf($)
1083 if (!cfg.on) return
1084 await bounded($, Promise.all([
1085 $.command.register({
1086 name: 'ledger', description: "This session's open requests and promises; close <id>, clear (no turn)", immediate: true,
1087 }),
1088 // Not deferred: a tool the agent has to go looking for is a tool it forgets to call, and
1089 // the three schemas together are a few hundred characters.
1090 $.tool.register({
1091 name: 'ledger_close', isDeferred: false,
1092 description: "Close one of this session's ledger items (the open requests and promises the prompt's ledger note lists) once it is finished. The proof is required: a path, link, commit, PR number or command result that shows it is done.",
1093 inputSchema: { type: 'object', properties: { id: { type: 'string', description: 'The item id, as the ledger note gives it' }, proof: { type: 'string', description: 'What shows it is done: a path, link, commit, PR number or command result' } }, required: ['id', 'proof'] },
1094 }),
1095 $.tool.register({
1096 name: 'ledger_drop', isDeferred: false,
1097 description: 'Drop a ledger item that is not a real ask, or that the person cancelled. The reason is required.',
1098 inputSchema: { type: 'object', properties: { id: { type: 'string' }, reason: { type: 'string' } }, required: ['id', 'reason'] },
1099 }),
1100 $.tool.register({
1101 name: 'ledger_add', isDeferred: false,
1102 description: "Track an ask from the person's latest message that the ledger note did not list.",
1103 inputSchema: { type: 'object', properties: { text: { type: 'string', description: 'The ask, in the person\'s words' } }, required: ['text'] },
1104 }),
1105 ]), IO_MS, null)
1106 ledgerShown = ''
1107 ledgerAsOf = 0
1108 await ledgerRefresh($)
1109}
1110
1111// The band tick rereads the file, so a close made from outside shows within a tick.
1112async function ledgerRefresh($) {
1113 const cfg = await ledgerConfigOf($)
1114 if (!cfg.on) return
1115 const file = await ledgerPath($)
1116 const l = file ? await readLedger($, file) : null
1117 if (l) await showLedger($, l)
1118}
1119
1120// Recorded BEFORE `next`, so the note the prompt carries can name its own items by id; the
1121// turn they belong to is stamped after, once `next` has started it. A slash command is the
1122// harness's, and carries nothing.
1123async function onPromptSubmit($, e, next) {
1124 const cfg = await ledgerConfigOf($)
1125 if (!cfg.on || String(e.text || '').trimStart().startsWith('/')) return next(e)
1126 const before = currentTurn
1127 const at = await $.clock.now()
1128 const source = sourceOf(e)
1129 const ids = []
1130 let window = 0
1131 let cur = source ? await changeLedger($, l => {
1132 window = Number(l.window) || 0
1133 return addPrompt(l, { text: e.text, at, turnId: e.turnId || '', source, queued: Boolean(e.turnId) }, ids)
1134 }) : null
1135 if (!cur) {
1136 const file = await ledgerPath($)
1137 cur = file ? await readLedger($, file) : null
1138 }
1139 const note = cur ? contextNote(cur, at) : ''
1140 const r = await next(note ? { ...e, context: [...(e.context || []), note] } : e)
1141 if (!ids.length) return r
1142 if (!r || r.drop !== undefined) {
1143 // It never entered: its items go, and the window goes back to the one before.
1144 await changeLedger($, l => ({ ...dropItems(l, ids), window }))
1145 return r
1146 }
1147 // Typed over a running turn, it carries that turn's id. Submitted idle, `next` resolves
1148 // once its own turn started: a turn.start since is that turn; failing that, the turn's
1149 // start names it (ledgerTurnStart), whichever of the two lands second.
1150 const turnId = e.turnId || (currentTurn !== before ? currentTurn : '')
1151 if (turnId && !e.turnId) await stampItems($, ids, turnId)
1152 return r
1153}
1154
1155async function stampItems($, ids, turnId) {
1156 if (!ids.length || !turnId) return
1157 await changeLedger($, l => (l.items.some(i => ids.includes(i.id) && !i.turnId)
1158 ? { ...l, items: l.items.map(i => (ids.includes(i.id) && !i.turnId ? { ...i, turnId } : i)) } : null))
1159}
1160
1161// ledger_close, ledger_drop and ledger_add: the agent's own hand on its items (./ledger.js).
1162async function onLedgerTool($, e) {
1163 const cfg = await ledgerConfigOf($)
1164 if (!cfg.on) return { result: 'ledger: off (CLAUDE_FLEET_LEDGER=off)' }
1165 const a = callArgs(e)
1166 const ctx = { nowMs: await $.clock.now(), turnId: currentTurn }
1167 const act = e.tool === TOOL.close ? l => closeByAgent(l, a.id, a.proof, ctx)
1168 : e.tool === TOOL.drop ? l => dropByAgent(l, a.id, a.reason, ctx)
1169 : l => addByAgent(l, a.text, ctx)
1170 let said = 'ledger: the file could not be read just now; nothing changed'
1171 await changeLedger($, l => { const r = act(l); said = r.said; return r.ledger })
1172 return { result: said }
1173}
1174
1175async function ledgerTurnStart($, e) {
1176 const cfg = await ledgerConfigOf($)
1177 if (!cfg.on || !e.text) return
1178 await changeLedger($, l => stampTurn(l, e.text, e.turnId))
1179}
1180
1181// A prompt fleet-send handed over, as DELIVERY claims it: one item (a brief is not split into
1182// its sentences) in a window of its own, unless it is a nudge. Its turn.start stamps the item
1183// with the turn and arms the note (ledgerNoteOnTool).
1184async function ledgerDelivery($, p) {
1185 const cfg = await ledgerConfigOf($)
1186 if (!cfg.on) return
1187 const at = await $.clock.now()
1188 const ids = []
1189 if (!p.nudge) await changeLedger($, l => addPrompt(l, { text: p.text, at, source: 'fleet', whole: true }, ids))
1190 p.ledgered = ids
1191}
1192
1193async function ledgerUndo($, ids) {
1194 await changeLedger($, l => dropItems(l, ids))
1195}
1196
1197// A plugin's submit cannot carry context either (PromptSubmitArgs leaves it out), so the note
1198// a delivered prompt should have had rides on the first tool result of the turn it started,
1199// read as a PostToolUse reminder is: a worker handed a brief nearly always calls a tool before
1200// it calls ledger_close, and without the note it could not name the brief's item at all.hooks/shape.js 80 lines1// What the mod writes, as plain functions of what the engine handed it: no `$`, no I/O.
2//
3// Kept apart from register.js so the suite can hold the record's shape to its readers
4// without a Claude session (test/run.sh imports this file with node), and so the next
5// phase reads the vocabulary here rather than in the middle of the hooks.
6
7export const VERSION = '0.1.0'
8
9// The fields the mod owns in a status record. hooks/fleet-event.sh rewrites the record
10// on every event and carries exactly these forward; test/run.sh holds the two lists to
11// each other, so a field added here and forgotten there goes red instead of vanishing
12// on the next shell event.
13export const MOD_FIELDS = ['source', 'state', 'turnId', 'mod', 'usage', 'ledger']
14
15// The state words are the fleet's own (bin/fleet-grid.mjs's STATUS table), so a reader
16// needs no translation:
17// working a model turn is running
18// ready the turn ended with an answer (or an error) and the prompt is free
19// interrupted the person cut the turn short (Esc); it is waiting on them
20// need-you a dialog is up that only a person can answer
21// idle a session that has not had a turn yet
22export const STATES = ['working', 'ready', 'interrupted', 'need-you', 'idle']
23
24export const stateAfterTurn = reason => (reason === 'aborted' ? 'interrupted' : 'ready')
25
26// Whether a tool.check verdict puts a real call in front of a person: `ask`, on a call
27// the model made (it has a tool_use_id; a `$.tool.check` query has none).
28export function asksAPerson(e, verdict) {
29 return Boolean(e && e.tool_use_id && verdict && verdict.decision === 'ask')
30}
31
32// The state, merged into the record the shell hook wrote. `status` is set too, so a
33// reader that only knows `status` sees the same word; `source` and `mod` are what let a
34// reader decide whether to believe it (lib/mod-status.mjs).
35export function withState(rec, state, { nowMs, pid, turnId }) {
36 return {
37 ...rec,
38 ...(turnId ? { turnId } : {}),
39 status: state,
40 state,
41 source: 'mod',
42 ts: Math.floor(nowMs / 1000),
43 mod: { v: VERSION, pid, hb: nowMs, at: nowMs },
44 }
45}
46
47// An ISO timestamp as epoch seconds, so a shell reader compares it with `date +%s` and
48// needs no date parser.
49const epoch = iso => {
50 const ms = iso ? Date.parse(iso) : NaN
51 return Number.isFinite(ms) ? Math.floor(ms / 1000) : undefined
52}
53
54// `session.measure` (or `$.session.usage()`) as the record keeps it. Each window keeps
55// the moment it resets beside its figure: within one window usage only rises, so a
56// reading whose window has not reset is a true lower bound however old it is, and one
57// whose window has reset describes nothing. That is the whole of what bin/fleet-governor
58// needs to decide whether to believe it.
59export function usageRecord(m, nowMs) {
60 const limits = {}
61 for (const w of m.rateLimits || []) {
62 if (!w || typeof w.kind !== 'string') continue
63 limits[w.kind] = { pct: w.percentUsed, resets: epoch(w.resetsAt) }
64 }
65 return {
66 at: Math.floor(nowMs / 1000),
67 context: { tokens: m.context?.tokens, window: m.context?.window, percent: m.context?.percent },
68 limits,
69 ...(m.cost ? { cost_usd: m.cost.usd } : {}),
70 }
71}
72
73// The fleet socket named by $TMUX ("<socket path>,<server pid>,<session>"), when it is
74// a fleet's (cf-*): the server this pane is on, which cannot go stale behind a
75// long-running --resume the way an exported CLAUDE_FLEET_SOCK can.
76export function sockOfTmux(tmux) {
77 const server = String(tmux || '').split(',')[0].split('/').pop() || ''
78 return server.startsWith('cf-') ? server : ''
79}
80hooks/guard-shape.js 201 lines1// What the guards decide from, as plain functions of strings: no `$`, no I/O.
2//
3// Kept apart from guards.js for the reason shape.js is: test/run.sh imports this file with
4// node and holds it to the shell guard it stands in for (hooks/fleet-guard.sh), command for
5// command, so the two cannot drift into two answers to "is this a merge".
6
7// ── the merge guard's patterns, from hooks/fleet-guard.sh ───────────────────
8//
9// The shell's POSIX classes spelled out. Whitespace is folded first, as `tr '\n\t' ' '`
10// folds it there, so a merge split across lines is still one.
11const fold = cmd => String(cmd || '').replace(/[\n\t]/g, ' ')
12
13const MERGES = [
14 // `gh pr merge`, with --auto too: that is a merge scheduled for later
15 /(^|[^A-Za-z0-9_.-])gh\s([^|;&]*\s)?pr\s+merge(\s|$)/,
16 // the REST endpoint, which is what a model reaches for when the porcelain is refused
17 /(^|[^A-Za-z0-9_.-])gh\s([^|;&]*\s)?api\s[^|;&]*pulls\/[^\s/]+\/merge/,
18 /mergePullRequest|enablePullRequestAutoMerge/,
19]
20export const mergesAPr = cmd => MERGES.some(re => re.test(fold(cmd)))
21
22const BOUNDARY_WRITES = [
23 /(^|[^A-Za-z0-9_.-])fleet-project\s+set(\s|$)/,
24 /\.(workers-merge|agents-approve)(-off)?([^A-Za-z0-9_-]|$)/,
25]
26export const changesABoundary = cmd => BOUNDARY_WRITES.some(re => re.test(fold(cmd)))
27
28// The MCP tools the merge guard stands in front of: any server's merge_pull_request.
29export const isMergeTool = tool => /^mcp__.+__merge_pull_request$/.test(String(tool || ''))
30
31// Which PR a merge command names: a REST call carries it in the path; `gh pr merge` takes
32// it as the first bare word after `merge`, and with none it means the current branch's PR.
33// `repo` is a -R/--repo, when given.
34export function prSelector(cmd) {
35 const flat = fold(cmd)
36 let sel = (flat.match(/pulls\/([0-9]+)\/merge/) || [])[1] || ''
37 if (!sel) {
38 const w = flat.split(/\s+/).filter(Boolean)
39 for (let i = 1; i < w.length - 1 && !sel; i++) {
40 if (w[i] !== 'merge' || w[i - 1] !== 'pr') continue
41 for (let j = i + 1; j < w.length; j++) {
42 if (/^[;&|]/.test(w[j])) break
43 if (!w[j].startsWith('-')) { sel = w[j]; break }
44 }
45 break
46 }
47 }
48 const repo = (flat.match(/.*(?:-R|--repo)[ =]([^ ]+)/) || [])[1] || ''
49 return { sel, repo }
50}
51
52// "Workers can merge" / "agents can approve tool calls" (lib/boundary.sh): most specific
53// wins. `has(name)` says whether a marker of that name is in the fleet dir.
54export function boundaryOn(setting, sock, sess, has) {
55 if (!setting || !sock) return false
56 if (sess) {
57 if (has(`${sock}.${sess}.${setting}-off`)) return false
58 if (has(`${sock}.${sess}.${setting}`)) return true
59 }
60 return has(`${sock}.${setting}`)
61}
62
63const LABEL = { 'workers-merge': 'workers can merge', 'agents-approve': 'agents can approve tool calls' }
64export function boundaryHow(setting, sock, sess) {
65 const lines = [
66 `"${LABEL[setting]}" is off. A lead or a human can turn it on:`,
67 ` fleet-project set -s ${sock} ${setting} on # the whole project (or the grid's , page)`,
68 ]
69 if (sess) lines.push(` fleet-project set -s ${sock} ${setting} on --session ${sess} # this session only`)
70 return lines.join('\n')
71}
72
73// The refusals, word for word the shell guard's, so a session reads one rule whichever
74// door refused it. Each begins `ghostfleet:` as the shell's do.
75export const SETTING_REFUSAL = [
76 'ghostfleet: a worker does not change its own boundaries.',
77 ' "workers can merge" and "agents can approve tool calls" are set by the lead (from the',
78 " main checkout) or a human (the grid's , page) — never by a session in a linked worktree,",
79 ' which is the session they bound. Ask the lead if your task needs one.',
80].join('\n')
81
82export function mergeRefusal(where, branch, sock, sess) {
83 return [
84 'ghostfleet: a worker does not merge its own PR — the LEAD merges.',
85 ` This session runs in a linked worktree (${where}${branch ? `, branch ${branch}` : ''}), which makes`,
86 ' it a worker. The lead scans what you opened and merges it from the main checkout;',
87 ' a green check is its signal to look, not yours to merge.',
88 '',
89 ' What to do instead: push, make sure the PR is open against the integration branch,',
90 ' report the PR number, and end your turn.',
91 '',
92 boundaryHow('workers-merge', sock, sess).split('\n').map(l => ` ${l}`).join('\n'),
93 ].join('\n')
94}
95
96// Every refusal from a guard that could not decide. A guard that cannot tell refuses:
97// that is the whole difference from the shell version, which lets the call through.
98export const failedClosed = (what, why) =>
99 `ghostfleet: ${what} could not be checked, so it is refused (a guard fails closed): ${why}. ` +
100 'Nothing ran. If this repeats, the fleet\'s own commands (fleet-answer, fleet-jarvis, gh, git) ' +
101 'are not answering from this session; tell the lead or a human rather than working around it.'
102
103// ── fleet-answer, as a Bash command ─────────────────────────────────────────
104//
105// Each `fleet-answer` the command runs, as the argv it would get, so the mod can ask
106// `fleet-answer --check` the same question first. Words are split the way a shell splits
107// plain quoting ('…', "…", \x). Anything a shell would EXPAND ($, `, a subshell, a
108// redirect) cannot be known from here: such a command answers null and is left to
109// fleet-answer's own check, which still runs when the command does.
110export function answerCalls(cmd) {
111 const words = shellWords(String(cmd || ''))
112 if (!words) return null
113 const calls = []
114 let start = true
115 for (let i = 0; i < words.length; i++) {
116 const w = words[i]
117 if (w.op) { start = true; continue }
118 if (start && /^[A-Za-z_][A-Za-z0-9_]*=/.test(w.text)) continue
119 if (start && /(^|\/)fleet-answer$/.test(w.text)) {
120 const argv = []
121 for (i++; i < words.length && !words[i].op; i++) argv.push(words[i].text)
122 i--
123 calls.push(argv)
124 }
125 start = false
126 }
127 return calls
128}
129
130// [{text}|{op}] or null when the line holds something only a shell can expand.
131function shellWords(s) {
132 const out = []
133 let cur = null
134 const push = () => { if (cur !== null) out.push({ text: cur }); cur = null }
135 for (let i = 0; i < s.length; i++) {
136 const c = s[i]
137 if (c === "'") {
138 const j = s.indexOf("'", i + 1)
139 if (j < 0) return null
140 cur = (cur ?? '') + s.slice(i + 1, j); i = j; continue
141 }
142 if (c === '"') {
143 let t = ''
144 for (i++; i < s.length && s[i] !== '"'; i++) {
145 if (s[i] === '$' || s[i] === '`') return null
146 if (s[i] === '\\' && i + 1 < s.length && '"\\$`\n'.includes(s[i + 1])) i++
147 t += s[i]
148 }
149 if (i >= s.length) return null
150 cur = (cur ?? '') + t; continue
151 }
152 if (c === '\\') { if (i + 1 >= s.length) return null; cur = (cur ?? '') + s[++i]; continue }
153 if ('$`()<>{}'.includes(c)) return null
154 if (c === ';' || c === '|' || c === '&' || c === '\n') {
155 push()
156 while (i + 1 < s.length && (s[i + 1] === '|' || s[i + 1] === '&')) i++
157 out.push({ op: true }); continue
158 }
159 if (c === ' ' || c === '\t') { push(); continue }
160 cur = (cur ?? '') + c
161 }
162 push()
163 return out
164}
165
166// ── Jarvis ──────────────────────────────────────────────────────────────────
167//
168// The MCP tools lib/jarvis.mjs mcpConfirmSpec names, and the server they belong to. Only
169// these are asked about; every other call costs nothing.
170export const JARVIS_TOOLS = new Set([
171 'fleet_stop', 'fleet_worktree_remove', 'fleet_project_remove', 'fleet_answer',
172 'fleet_spawn', 'fleet_companion', 'fleet_send',
173])
174export function fleetTool(tool) {
175 const m = /^mcp__ghostfleet__(fleet_[a-z_]+)$/.exec(String(tool || ''))
176 return m ? m[1] : ''
177}
178
179// The tool's own arguments: the envelope keys the engine adds are not the call's.
180const ENVELOPE = new Set(['tool', 'tool_use_id', 'agentId', 'consent'])
181export const callArgs = e => Object.fromEntries(Object.entries(e || {}).filter(([k]) => !ENVELOPE.has(k)))
182
183// Could lib/jarvis.mjs bashSpec act on this command at all? A strict SUPERSET of its rules
184// (test/run.sh holds it to that), so the gate is asked only when it might say no. Every
185// rule there names gh, git, tmux, a fleet- command, Jarvis's own files or verbs, or Jarvis's
186// socket after -L/-s; anything else it answers `ok`. Without this, Jarvis's every `ls` would
187// need node and the gate, and one broken gate would refuse the whole shell.
188export function jarvisMightAct(cmd, sock, jdir) {
189 const c = String(cmd || '')
190 if (/\b(gh|git|tmux|jarvis)\b|fleet-|\.config\/ghostfleet/.test(c)) return true
191 if (jdir && c.includes(jdir)) return true
192 const esc = String(sock || '').replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
193 return Boolean(esc) && new RegExp(`-[Ls]\\s*${esc}\\b`).test(c)
194}
195
196// The marker's sock= line (lib/jarvis.mjs readMarker reads the rest).
197export function markerSock(text) {
198 const m = /^sock=(.*)$/m.exec(String(text || ''))
199 return m ? m[1].trim() : ''
200}
201hooks/band-shape.js 131 lines1// What the lead's band says, as plain functions: no `$`, no I/O. test/run.sh imports this
2// with node and renders it at every width it claims to fit.
3
4// The grid's colours (bin/fleet-grid.mjs C: 203 red, 80 cyan, 114 green), so a state reads
5// the same on the band as on the card.
6export const COLOR = { need: '#ff5f5f', working: '#5fd7d7', green: '#87d787', red: '#ff5f5f' }
7
8// A record's state for the band: the mod's while it is still being written (the same
9// heartbeat rule as lib/mod-status.mjs, without the pid probe the mod cannot make), else the
10// shell hook's `status`.
11export const MOD_STALE_MS = 150_000
12export function recordState(rec, nowMs) {
13 if (!rec) return ''
14 if (rec.source === 'mod' && rec.state && rec.mod && nowMs - (Number(rec.mod.hb) || 0) <= MOD_STALE_MS) return rec.state
15 return String(rec.status || '')
16}
17
18// Who is on this lead's team, from the sessions alive on its socket:
19// master every session but itself and the terminal tabs (`_term-…`, never a worker)
20// sub-lead its children: the sessions whose <sock>.<child>.parent names it
21// Anything else is not a lead and gets no band.
22export function teamOf(me, alive, children) {
23 if (me === 'master') return alive.filter(s => s !== 'master' && !s.startsWith('_'))
24 if (children.length) return alive.filter(s => children.includes(s))
25 return null
26}
27
28// The newest record per session name on this socket.
29export function latestBySlot(records, sock) {
30 const by = new Map()
31 for (const r of records) {
32 if (!r || r.sock !== sock || !r.slot) continue
33 const prev = by.get(r.slot)
34 if (!prev || (Number(r.ts) || 0) >= (Number(prev.ts) || 0)) by.set(r.slot, r)
35 }
36 return by
37}
38
39export function summarize(team, bySlot, nowMs) {
40 let working = 0, need = 0
41 for (const s of team) {
42 const st = recordState(bySlot.get(s), nowMs)
43 if (st === 'working') working++
44 else if (st === 'need-you') need++
45 }
46 return { workers: team.length, working, need }
47}
48
49// `gh pr list --json headRefName,baseRefName,statusCheckRollup` reduced to what the band
50// says. A PR is green when every check finished and none failed, red when any failed,
51// pending otherwise (and when it has no checks at all: nothing has said it is green).
52const FAILED = new Set(['FAILURE', 'CANCELLED', 'TIMED_OUT', 'ERROR', 'ACTION_REQUIRED', 'STARTUP_FAILURE'])
53const PASSED = new Set(['SUCCESS', 'NEUTRAL', 'SKIPPED'])
54export function checkState(rollup) {
55 const checks = Array.isArray(rollup) ? rollup : []
56 if (!checks.length) return 'pending'
57 let done = true
58 for (const c of checks) {
59 const v = String(c.conclusion || c.state || '').toUpperCase()
60 if (FAILED.has(v)) return 'red'
61 if (!PASSED.has(v)) done = false
62 }
63 return done ? 'green' : 'pending'
64}
65
66// Which PRs are this lead's: a sub-lead's are the ones INTO its branch (its children's);
67// master's are the ones FROM a branch its fleet spawned (the manifest), whatever became of
68// the session since — a finished worker's green PR is exactly what master is waiting on.
69export function prSummary(prs, { me, branch, fleetBranches }) {
70 const mine = (prs || []).filter(p => me === 'master'
71 ? fleetBranches.includes(p.headRefName)
72 : Boolean(branch) && p.baseRefName === branch)
73 const out = { green: 0, red: 0, pending: 0 }
74 for (const p of mine) out[checkState(p.statusCheckRollup)]++
75 return out
76}
77
78// The band as text runs, the longest form that fits `columns`. The order is the brief's
79// ("3 workers · 1 working · 1 need you · 2 PRs green") while there is room; as it narrows,
80// words shorten, and below that `need you` moves first, since it is the one thing a lead
81// must not miss and the right end is what a narrow pane cuts.
82// prs: undefined = not read yet (say nothing), null = could not read (`PRs ?`).
83// A run is { text, color?, dim?, bold? }; groups are joined by ` · `, or a space when tight.
84// `w` is workers wherever it is short: the band never says anything else with a w.
85export function bandRuns(s, prs, columns) {
86 if (!s) return null
87 if (s.workers === 0 && !(prs && (prs.green || prs.red || prs.pending))) return null
88 const cols = Math.max(1, Number(columns) || 80)
89 const n = (k, one, many) => `${k} ${k === 1 ? one : many}`
90 const need = t => s.need ? [{ text: t, color: COLOR.need, bold: true }] : null
91 const busy = t => s.working ? [{ text: t, color: COLOR.working }] : null
92 const team = t => [{ text: t, dim: true }]
93 const forms = [
94 [' · ', [team(n(s.workers, 'worker', 'workers')), busy(`${s.working} working`), need(`${s.need} need you`), ...prGroups(prs, 'long')]],
95 [' · ', [team(n(s.workers, 'worker', 'workers')), busy(`${s.working} working`), need(`${s.need} need you`), ...prGroups(prs, 'short')]],
96 [' · ', [need(`${s.need} need you`), busy(`${s.working} busy`), team(`${s.workers}w`), ...prGroups(prs, 'tight')]],
97 [' · ', [need(`${s.need} need`), busy(`${s.working} busy`), team(`${s.workers}w`)]],
98 [' ', [need(`${s.need} need`), team(`${s.workers}w`)]],
99 [' ', [s.need ? need(`${s.need}!`) : team(`${s.workers}w`)]],
100 ]
101 let runs = null
102 for (const [sep, groups] of forms) {
103 runs = groups.filter(Boolean).flatMap((g, i) => (i ? [{ text: sep, dim: true }, ...g] : g))
104 if (width(runs) <= cols) return runs
105 }
106 return runs
107}
108
109function prGroups(prs, form) {
110 if (prs === undefined) return []
111 if (prs === null) return [[{ text: 'PRs ?', dim: true }]]
112 const green = { text: '', color: COLOR.green }, red = { text: '', color: COLOR.red }
113 if (form === 'long') return [
114 prs.green ? [{ ...green, text: `${prs.green} ${prs.green === 1 ? 'PR' : 'PRs'} green` }] : null,
115 prs.red ? [{ ...red, text: `${prs.red} red` }] : null,
116 prs.pending ? [{ text: `${prs.pending} pending`, dim: true }] : null,
117 ]
118 const bits = [
119 prs.green ? { ...green, text: `${prs.green}✓` } : null,
120 prs.red ? { ...red, text: `${prs.red}✗` } : null,
121 form === 'short' && prs.pending ? { text: `${prs.pending}…`, dim: true } : null,
122 ].filter(Boolean)
123 if (!bits.length) return []
124 const spaced = bits.flatMap((b, i) => (i ? [{ text: ' ' }, b] : [b]))
125 return [form === 'short' ? [{ text: 'PRs ', dim: true }, ...spaced] : spaced]
126}
127
128// Cells, counting each code point as one: every glyph the band draws is narrow.
129export function width(runs) { return (runs || []).reduce((k, r) => k + [...r.text].length, 0) }
130export const plain = runs => (runs || []).map(r => r.text).join('')
131hooks/ledger.js 673 lines1// The request ledger's vocabulary, as plain functions: no `$`, no I/O. The LEDGER section
2// of register.js uses them; bin/fleet-ledger reads and writes the same file through them,
3// and test/run.sh imports this file with node.
4//
5// <fleet dir>/<session_id>.ledger (JSON, but deliberately NOT named *.json: eight readers
6// glob <fleet dir>/*.json as status records)
7// { v: 1, seq, prompt, window, items: [ { id, text, at, turnId, state, source, ... } ], judge? }
8//
9// state open | done | not-done | stale (a promise no judged turn addressed, see STALE_TURNS)
10// | dropped (the agent said it was not a real ask, or the person cancelled it)
11// source user typed at the prompt (or the phone's bridge), idle or mid-turn
12// fleet a prompt fleet-send handed to the mod (DELIVERY)
13// promise a commitment the agent made in a final message ("I'll merge when green")
14// gated true once the gate has re-prompted about it: at most once per item, ever
15// queued typed over a running turn; interrupted: its turn was stopped (Esc). Either may
16// never have reached the model, and the gate asks the transcript first
17// prompt the number of the prompt that made it (ledger.prompt counts them); `window` is
18// the first prompt of the latest one, and only items from `window` on are gated.
19// An item from before this field existed has none, and is never gated again
20// closedBy agent (ledger_close / ledger_drop, with `proof` or `reason`) | judge |
21// person (/ledger close, fleet-ledger close; `hand` in a file written before)
22
23export const LEDGER_VERSION = 1
24export const KEEP_ITEMS = 200 // the file keeps the newest 200
25export const BAND_DAYS = 7 // older than this drops off the band and the gate
26export const EXCERPT = 240 // a request's text as kept
27const DAY_MS = 86_400_000
28
29export const ledgerFile = (dir, sessionId) => `${dir}/${sessionId}.ledger`
30
31export const emptyLedger = () => ({ v: LEDGER_VERSION, seq: 0, items: [] })
32
33// A file that is missing, torn or someone else's shape reads as empty, never as a throw.
34export function parseLedger(text) {
35 try {
36 const l = JSON.parse(text)
37 if (l && typeof l === 'object' && Array.isArray(l.items)) return { ...emptyLedger(), ...l, items: l.items.filter(i => i && i.id) }
38 } catch {}
39 return emptyLedger()
40}
41
42// The switches, from the environment (a settings file's `env` block lands there too).
43// CLAUDE_FLEET_LEDGER=off the whole feature
44// CLAUDE_FLEET_LEDGER_GATE=off record and show, never re-prompt
45// CLAUDE_FLEET_LEDGER_PROMISES=show|gate|off (default show)
46export function ledgerConfig(get) {
47 const off = v => /^(off|0|false|no)$/i.test(String(v || '').trim())
48 const p = String(get('CLAUDE_FLEET_LEDGER_PROMISES') || 'show').trim().toLowerCase()
49 return {
50 on: !off(get('CLAUDE_FLEET_LEDGER')),
51 gate: !off(get('CLAUDE_FLEET_LEDGER_GATE')),
52 promises: p === 'gate' || p === 'off' ? p : 'show',
53 model: String(get('CLAUDE_FLEET_LEDGER_MODEL') || 'haiku').trim() || 'haiku',
54 }
55}
56
57const oneLine = s => String(s || '').replace(/\s+/g, ' ').trim()
58export const excerpt = (s, n = EXCERPT) => {
59 const t = oneLine(s)
60 return t.length > n ? `${t.slice(0, n - 1)}…` : t
61}
62
63// What a message asks, in the person's own words. A message is often mostly material: a
64// pasted transcript or log with a line of the person's around it. Kept as typed, the item's
65// excerpt was the paste's first lines, the person's words were cut off past the excerpt,
66// and the judge, shown a pasted draft and a pasted ledger reminder, judged THOSE: measured
67// live, "u see what it just did <a pasted turn> its like reminding literally the last
68// response" was re-prompted as an open request. So the pastes are set aside (marked, so the
69// judge knows something was pasted) and so is any ledger reminder quoted in the text: it is
70// this mod's own words, never the person's request. A message that is nothing but a quoted
71// reminder is no request at all ('').
72const PASTE = /<pasted_content\b[^>]*>[\s\S]*?(?:<\/pasted_content\b[^>]*>|$)/g
73const PASTE_TAG = /<\/?pasted_content\b[^>]*>/g
74const REMINDER = [
75 /(?:The ghostfleet plugin sent a message:\s*)?\[ghostfleet ledger\][\s\S]*?(?:it will not be asked again\.\)|$)/g,
76 /This is how Claude Code surfaces a prompt a plugin submits between turns[^\n]*/g,
77 /\[ghostfleet ledger: open items\][\s\S]*?(?:for an ask this list missed\.|$)/g,
78]
79const unquote = t => REMINDER.reduce((a, re) => a.replace(re, ' '), t)
80export function requestText(text) {
81 const pastes = []
82 const own = oneLine(unquote(String(text || '').replace(PASTE, m => { pastes.push(m.replace(PASTE_TAG, ' ')); return ' ' })))
83 if (!pastes.length) return own
84 if (own) return `${own} [+ pasted text]`
85 const pasted = oneLine(unquote(pastes.join(' ')))
86 return pasted ? `[pasted] ${pasted}` : ''
87}
88
89// A request as an item keeps its start AND its end. A paste the composer shows inline carries
90// no marker, and the person's own words come after it: measured live, a three-line draft and
91// "what do u think" was kept as the draft's opening lines, the judge read the request as
92// "write the draft", and an answered question was re-prompted. The start stays the longer
93// part (the withdrawn check matches on an item's opening words).
94const ITEM_HEAD = 140
95export function requestItem(text) {
96 const t = requestText(text)
97 return t.length > EXCERPT ? `${t.slice(0, ITEM_HEAD - 1)}… ${t.slice(-(EXCERPT - ITEM_HEAD - 1))}` : t
98}
99
100// Which submitted prompts are requests, and whose. Only the person's: composer, the phone's
101// bridge, and what the engine cannot attest (`unclassified`). This mod's own submits are
102// never read here: a fleet-send handoff is recorded by DELIVERY at the turn it started (it
103// knows the turn exactly), and the gate's re-prompt must never become an item of its own,
104// which would be a loop with extra steps. Everything else (a background task's
105// notification, a /loop firing, a peer's message) is not a request somebody is waiting on.
106// A slash command is an instruction to the harness, not work for the agent.
107export function sourceOf(e) {
108 const text = String(e && e.text || '')
109 if (!requestText(text) || text.trimStart().startsWith('/')) return null
110 const o = (e && e.origin) || { kind: 'composer' }
111 return o.kind === 'composer' || o.kind === 'bridge' || o.kind === 'unclassified' ? 'user' : null
112}
113
114// The asks in one message, each an item of its own, so each can be closed on its own proof.
115// A message of three asks kept as one item could only close when all three were done, and
116// the agent closing it had to vouch for the two it did not mention. Split only where the
117// split is plain: two or more sentences or list lines that each read as an ask (a list line,
118// a question, a sentence that opens with a verb of work or a "can you"). A message with
119// fewer is one item, exactly as it always was, and so is one with a paste in it (the paste's
120// sentences are not the person's asks) or one long enough to be a pasted brief. The cheap
121// side of the trade is deliberate: a miss is an ask the agent adds with ledger_add, while a
122// false split is an item nobody asked for, which the gate would then re-prompt.
123const ASK_VERB = /^(please\b|pls\b|can you|could you|would you|will you|can we|could we|let'?s|make sure|i need you|i want you|we need to|add|fix|build|write|rewrite|update|remove|delete|rename|run|rerun|re-run|test|check|review|merge|push|pull|open|close|create|make|send|show|tell|explain|find|look (at|into)|move|bump|change|refactor|document|deploy|publish|release|draft|summari[sz]e|list|compare|measure|verify|install|set up|clean|revert|commit|ship|wire|port|split|reply|answer|investigate|debug|profile|benchmark|translate|describe|generate|implement|try|shorten|trim|cut|edit|polish|format|lint|simplify|tighten|extend|convert|replace|swap|post|upload|share|save|schedule|plan|design|outline|research|read|search|fetch|download|start|stop|restart|retry|resend|confirm|count)\b/i
124const ASK_ANYWHERE = /\b(please|can you|could you|need you to|want you to)\b/i
125const LEAD_IN = /^(and|also|then|plus|so|oh and|and also)[, ]+/i
126const RULE = /^(do not|don'?t|never|avoid|without|no need)\b/i
127const STILL_ASKS = /^(do not|don'?t|never) forget\b/i
128const LISTED = /^\s*(\d+[.)]|[-*•])\s+/
129const SPLIT_MAX = 4000
130const ASKS_MAX = 8
131export function requestAsks(text) {
132 const raw = String(text || '')
133 const whole = requestItem(raw)
134 if (!whole) return []
135 if (PASTE.test(raw) || raw.length > SPLIT_MAX) { PASTE.lastIndex = 0; return [whole] }
136 PASTE.lastIndex = 0
137 const own = REMINDER.reduce((a, re) => a.replace(re, ' '), raw)
138 const parts = own.split(/\n+/).flatMap(line => {
139 const listed = LISTED.test(line)
140 return line.replace(LISTED, '').split(/(?<=[.?!])\s+/).map((t, k) => ({ t: oneLine(t), listed: listed && k === 0 }))
141 }).filter(x => x.t)
142 const asks = []
143 for (const { t, listed } of parts) {
144 if (t.length < 6 || t.length > 300) continue
145 const bare = t.replace(LEAD_IN, '')
146 if (RULE.test(bare) && !STILL_ASKS.test(bare)) continue
147 if (listed || t.endsWith('?') || STILL_ASKS.test(bare) || ASK_VERB.test(bare) || ASK_ANYWHERE.test(t)) asks.push(excerpt(t))
148 if (asks.length >= ASKS_MAX) break
149 }
150 return asks.length >= 2 ? asks : [whole]
151}
152
153// A message, as items: one per ask (requestAsks), all carrying the message's prompt number.
154// A message typed idle starts a new window, the one the gate may re-prompt about; one typed
155// over a running turn joins the window that turn belongs to, since that turn's end is not
156// gated (a queued message has started the next) and the next turn answers both.
157// `ids`, when given, is filled with the new items' ids.
158export function addPrompt(ledger, { text, at, turnId, source, queued, whole = false }, ids = []) {
159 const asks = whole ? [requestItem(text)].filter(Boolean) : requestAsks(text)
160 if (!asks.length) return null
161 const prompt = (Number(ledger.prompt) || 0) + 1
162 const window = queued && ledger.window ? ledger.window : prompt
163 let next = { ...ledger, prompt, window }
164 for (const ask of asks) {
165 next = addItem(next, { text: ask, at, turnId, source, queued, prompt, asIs: true })
166 ids.push(next.items[next.items.length - 1].id)
167 }
168 return next
169}
170
171// A request submitted idle names its turn at that turn's start, which carries its text: every
172// item of the newest message with those words that has no turn yet.
173export function stampTurn(ledger, text, turnId) {
174 const t = String(text || '').trim()
175 if (!t) return null
176 const asks = new Set([requestItem(t), ...requestAsks(t)])
177 const fits = ledger.items.filter(x => x.state === 'open' && !x.turnId && x.source !== 'promise' && asks.has(x.text))
178 if (!fits.length) return null
179 // An item from before prompts were numbered has none: the first match, as it always was.
180 const newest = Math.max(...fits.map(x => Number(x.prompt) || 0))
181 const pick = new Set(newest ? fits.filter(x => Number(x.prompt) === newest).map(x => x.id) : [fits[0].id])
182 return { ...ledger, items: ledger.items.map(x => (pick.has(x.id) ? { ...x, turnId } : x)) }
183}
184
185// `queued`: typed over a running turn. Such a message can still be pulled back out of the
186// queue (Up edits it) and never reach the model; see `withdrawn` below.
187// `prompt`: the message it came from (addPrompt). `asIs`: the text is already an item's.
188export function addItem(ledger, { text, at, turnId, source, queued, prompt, by, asIs }) {
189 const seq = (Number(ledger.seq) || 0) + 1
190 const words = asIs ? String(text) : source === 'promise' ? excerpt(text) : requestItem(text)
191 const item = {
192 id: String(seq), text: words, at, ...(turnId ? { turnId } : {}), state: 'open', source,
193 ...(queued ? { queued: true } : {}), ...(prompt ? { prompt } : {}), ...(by ? { addedBy: by } : {}),
194 }
195 const items = [...ledger.items, item].slice(-KEEP_ITEMS)
196 return { ...ledger, seq, items }
197}
198
199const fresh = (i, nowMs) => nowMs - (Number(i.at) || 0) <= BAND_DAYS * DAY_MS
200export const openItems = (ledger, nowMs) => ledger.items.filter(i => i.state === 'open' && fresh(i, nowMs))
201
202// What the record and the band say: open requests, open promises, the oldest open of each.
203export function ledgerSummary(ledger, nowMs) {
204 const open = openItems(ledger, nowMs)
205 const req = open.filter(i => i.source !== 'promise')
206 const prom = open.filter(i => i.source === 'promise')
207 const oldest = list => list.length ? { id: list[0].id, text: excerpt(list[0].text, 80), at: list[0].at } : null
208 // A failure is shown while something is open: with nothing open it has cost nothing, and
209 // a hand-cleared ledger would otherwise go on saying so until the next judged turn.
210 const j = req.length || prom.length ? ledger.judge : null
211 return {
212 open: req.length, promises: prom.length, oldest: oldest(req), oldestPromise: oldest(prom),
213 judgeFailing: j && j.ok === false ? excerpt(j.why, 80) : null,
214 }
215}
216
217// Is there anything for the judge? Open items, or an answer that sounds like a commitment.
218// The regex is only a cheap door in front of the model call, never the decision: it lets a
219// turn that promises nothing go by without one.
220const PROMISE_WORDS = /\b(I'll|I will|I'm going to|I am going to|next,? I|then I|once .{1,60}(I'll|I will)|when .{1,60}(I'll|I will)|later|after (that|this|CI|the))\b/i
221export const soundsLikeAPromise = answer => PROMISE_WORDS.test(String(answer || ''))
222
223// What a turn said: every text block it wrote, in order, one per model step that wrote any.
224// `turn.complete`'s `answer` is only the LAST of them, and judged alone it lost the work:
225// a turn wrote a post in its first block, ran two commands, and ended "the draft is above",
226// and the judge, shown that line with nothing above it, kept the request open and the gate
227// re-prompted for a reply the person had just read. `steps` are the texts the turn's steps
228// returned; `answer` is the turn's final text, the whole record when the steps were missed
229// (the module reloaded mid-turn) and appended when the last step did not end on it.
230export function turnBlocks(steps, answer) {
231 const blocks = (steps || []).map(s => String(s || '')).filter(s => s.trim())
232 const last = String(answer || '')
233 if (last.trim() && (!blocks.length || blocks[blocks.length - 1].trim() !== last.trim())) blocks.push(last)
234 return blocks
235}
236
237// The turn as the judge reads it: each block labelled with its place, and a long turn cut
238// from the MIDDLE. The start is where the work tends to be (the draft, the answer) and the
239// end is where the turn reports; a tail-only cut drops exactly the half the report points at.
240const HEAD_SHARE = 0.4
241export function middleCut(s, n) {
242 const a = String(s || '')
243 if (a.length <= n) return a
244 const head = Math.floor(n * HEAD_SHARE)
245 const tail = n - head
246 return `${a.slice(0, head)}\n[… ${a.length - head - tail} characters from the middle of the turn left out …]\n${a.slice(-tail)}`
247}
248export function turnText(blocks, n) {
249 const list = typeof blocks === 'string' ? [blocks] : blocks || []
250 const text = list.length > 1 ? list.map((b, k) => `[block ${k + 1} of ${list.length}]\n${b}`).join('\n\n') : String(list[0] || '')
251 return middleCut(text, n)
252}
253
254// The one judge call: which open items the turn addressed, and what it promised. Inputs are
255// truncated: item texts to EXCERPT, the turn to ANSWER_CHARS, an earlier turn to EARLIER_CHARS.
256// `turn` is the turn's blocks (turnBlocks), or one string for a turn of one block.
257export const ANSWER_CHARS = 6000
258export const EARLIER_CHARS = 1500
259export function judgePrompt(items, turn, { promises, earlier = [], openPromises = [] }) {
260 const blocks = typeof turn === 'string' ? [turn] : turn || []
261 const tail = turnText(blocks, ANSWER_CHARS)
262 const final = blocks.length > 1 ? `the LAST block (${blocks.length} of ${blocks.length})` : 'the message'
263 const before = earlier.slice(-2).map(t => turnText(t, EARLIER_CHARS))
264 const list = items.length
265 ? items.map(i => `${i.id} [${i.source === 'promise' ? 'promise the agent made' : 'request to the agent'}]: ${excerpt(i.text)}`).join('\n')
266 : '(none)'
267 return [
268 'You audit an AI coding agent. Below are items it owes, and everything it wrote in the turn it just ended.',
269 '',
270 'ITEMS:',
271 list,
272 '',
273 ...(before.length ? ['EARLIER TURNS (oldest first; an item answered here is answered):', ...before.map(t => `<<<\n${t}\n>>>`), ''] : []),
274 `THIS TURN (every text block the agent wrote, in order; its tool calls and their output ran between blocks and are left out; ${final} is how it ended):`,
275 '<<<',
276 tail,
277 '>>>',
278 '',
279 'For EACH item decide:',
280 '- "done": the turn (any block of it) or an earlier turn did it, answered it, or reports it completed. Work written in an earlier block counts: "the draft is above" in the last block refers to a draft in an earlier one.',
281 '- "done" also, with reason "reported: waiting on <what>", when the agent did everything it can do now and says plainly that the rest waits on something outside its control (CI running, a registry or deploy propagating, a review, the person\'s own action), and what happens next. Work the agent could have done itself and simply did not is not this: it is "open".',
282 '- "not-done": the agent explicitly says THIS item was not or cannot be done AND gives a reason. A refusal with no reason, or one that does not say which request it means, is "open".',
283 '- "open": anything else: not mentioned, only acknowledged, or partly done with no report of why the rest is waiting.',
284 'Judge an item by what it asked for NOW: a part it explicitly put off ("not in this reply", "later", "after X") is not owed yet.',
285 'An item may open with material the person pasted with no marker (a draft, a log, a transcript) and end with their own words: judge what THOSE words ask. A question ("what do you think?") is "done" once the agent answers it; an answer that ends by offering more or asking something back does not reopen it.',
286 'An item is the person\'s own words. "[+ pasted text]" means they also pasted material (a transcript, a log, an earlier reply) as context for those words: the material is not a request of its own, and a "[ghostfleet ledger]" reminder quoted in it is this tool talking, never a request. Judge what the person\'s own words ask; a remark about the paste ("see what it did") asks for the agent to look at it, and is done once the agent has.',
287 'An item that only approves, confirms or thanks ("go ahead", "yes", "thanks") asks for no work of its own: it is "done" once the agent acts on what it approved, or if there is nothing to act on. An item that confirms AND asks ("done, now draft the post") is judged by what it asks.',
288 ...(promises === 'off'
289 ? ['Return "promises": [] always.']
290 : [
291 `Also list "promises", from ${final} only: things the AGENT says IT WILL DO LATER in this session ("I'll merge once CI is green", "next I'll add the tests"), each as a short imperative phrase of at most 12 words. Only a firm commitment to a specific action: not work it already did, not suggestions for the user, not questions, not offers that wait on the user ("I can do X if you'd like"), not statements about how it will behave in general ("I'll keep responding normally").`,
292 'A promise is something the AGENT will do. What it asks the PERSON to do is never a promise: "you run X", "type X", "waiting on you", and every line of a list under "still waiting on you" or "for you to do". If you list one anyway, mark it "by":"person".',
293 ...(openPromises.length
294 ? ['ALREADY OPEN PROMISES (one commitment is one promise, however it is worded):',
295 ...openPromises.map(i => `${i.id}: ${excerpt(i.text, 120)}`),
296 'A promise in this turn that restates one of these (the same follow-up in other words, or narrower or wider) is that promise: give its id as "same". Only a different commitment has "same":"".']
297 : []),
298 '[] if none.',
299 ]),
300 '',
301 'Reply with ONLY this JSON, no prose, no code fence:',
302 `{"items":[{"id":"<id>","status":"done|not-done|open","reason":"<at most 12 words>"}],"promises":[${promises === 'off' ? '' : '{"text":"<at most 12 words>","by":"agent|person","same":"<open promise id, or empty>"}'}]}`,
303 ].join('\n')
304}
305
306// The judge's reply, held to the shape asked for. null when nothing in it has that shape: the
307// gate fails OPEN on null (nothing closes, nothing is re-prompted).
308//
309// A reply cut off by its token budget still carries every item object it finished. Measured
310// live: a ledger of 46 open items asked for 46 verdicts in 700 tokens, the reply stopped
311// partway through, and dropping the whole reply on its missing brace closed nothing, so the
312// backlog only grew and every later turn failed the same way. So the finished objects are
313// kept, `partial` says the reply was cut, and an item it never reached is simply not judged.
314const ITEM_OBJ = /\{[^{}]*"id"[^{}]*\}/g
315export function parseVerdict(text, ids) {
316 const s = String(text || '')
317 const a = s.indexOf('{'), b = s.lastIndexOf('}')
318 if (a < 0) return null
319 let v = null
320 if (b > a) try { v = JSON.parse(s.slice(a, b + 1)) } catch {}
321 let partial = false
322 if (!v || typeof v !== 'object' || !Array.isArray(v.items)) {
323 const list = s.indexOf('"items"', a)
324 if (list < 0) return null
325 const found = []
326 for (const m of s.slice(list).matchAll(ITEM_OBJ)) { try { found.push(JSON.parse(m[0])) } catch {} }
327 if (!found.length) return null
328 // the promises list, when the reply got as far as closing it
329 const pm = s.match(/"promises"\s*:\s*(\[[^\]]*\])/)
330 let promises = []
331 if (pm) try { promises = JSON.parse(pm[1]) } catch {}
332 v = { items: found, promises }
333 partial = true
334 }
335 const known = new Set(ids)
336 const items = []
337 for (const it of v.items) {
338 if (!it || !known.has(String(it.id))) continue
339 const status = String(it.status || '')
340 if (!['done', 'not-done', 'open'].includes(status)) continue
341 items.push({ id: String(it.id), status, reason: excerpt(it.reason, 120) })
342 }
343 if (partial && !items.length) return null
344 // Each promise as { text, same? }: a plain string (the shape before `same`) is a new one, and
345 // one the judge says is the PERSON's to do is not a promise at all.
346 const promises = (Array.isArray(v.promises) ? v.promises : [])
347 .map(p => (p && typeof p === 'object' ? p : { text: p }))
348 .filter(p => String(p.by || 'agent').toLowerCase() !== 'person')
349 .map(p => ({ text: excerpt(p.text, 120), ...(p.same ? { same: String(p.same) } : {}) }))
350 .filter(p => p.text && !addressedToPerson(p.text)).slice(0, 3)
351 return { items, promises, ...(partial ? { partial: true } : {}) }
352}
353
354// The judge asks about at most JUDGE_BATCH items per call, the newest first. One verdict is
355// one {"id","status","reason"} object per item. Measured on haiku: 46 items in one call took
356// 1,737 output tokens (38 an item) against the 700 the call allowed, so it was cut off.
357// Batches of ten took 325-343, and the first batch, the one that also reads promises, 589:
358// 84% of 700, too close. So ten a call, and 1,500 tokens each, over twice the worst measured.
359// The budget is a ceiling, not a cost: a call is billed for what it writes.
360export const JUDGE_BATCH = 10
361export const JUDGE_TOKENS = 1500
362export function judgeBatches(items, n = JUDGE_BATCH) {
363 const newest = [...items].sort((x, y) => (Number(y.at) || 0) - (Number(x.at) || 0) || Number(y.id) - Number(x.id))
364 const out = []
365 for (let k = 0; k < newest.length; k += n) out.push(newest.slice(k, k + n))
366 return out
367}
368
369// A promise is not gated by default, it is only shown, and one no turn ever addresses would
370// stay on the band for the full BAND_DAYS. So a promise the judge was shown and kept open in
371// STALE_TURNS judged turns closes as `stale`. Five: a "once CI is green" spans a turn or two
372// of other work; five turns that never mention it is a commitment the session has dropped.
373export const STALE_TURNS = 5
374
375// One commitment is one promise. Measured live: four open promises were one follow-up in
376// four phrasings, added on three consecutive turns ("read the ledgers after the next turns",
377// "watch for re-prompt closure in acme-api and acme-web", "check acme-api, acme-web, toolbox
378// ledgers after next turns", "read acme-api, acme-web, toolbox ledgers ..."): the judge was
379// never told which promises were open, kept the old one open (correctly) and extracted the
380// same commitment again in new words, and the only check was exact text. The judge now sees
381// the open promises and names the one a phrase restates (`same`); this is the backstop for
382// when it does not. Two phrasings are one promise when they share at least two content
383// words and those are at least SAME_SHARE of the shorter one's. Verbs of looking are one
384// verb, and a plural is its singular. On the four above, any two linked through a third:
385// the first and second share only the verb (1 of 4). So a promise keeps the phrasings folded
386// into it (`said`, the last SAID_KEPT) and a new one is matched against all of them; and when
387// a new phrasing matches two open promises, they were one all along and fold into the older.
388export const SAME_SHARE = 0.6
389const SAID_KEPT = 4
390const STOP = new Set('a an the to of in on at for and or then once when after before with by from it its is be this that these those any all i ill will we me my our up out as so just again'.split(' '))
391const LOOK = new Set(['read', 'check', 'watch', 'look', 'review', 'verify', 'inspect', 'monitor', 'confirm', 'see'])
392export function promiseWords(text) {
393 return new Set(String(text || '').toLowerCase().replace(/[’']/g, '').split(/[^a-z0-9.\-/]+/)
394 .map(w => w.replace(/^[.\-/]+|[.\-/]+$/g, ''))
395 .filter(w => w && !STOP.has(w))
396 .map(w => (LOOK.has(w) ? 'check' : w.length > 3 && w.endsWith('s') && !w.endsWith('ss') ? w.slice(0, -1) : w)))
397}
398export function samePromise(a, b) {
399 const x = promiseWords(a), y = promiseWords(b)
400 const shared = [...x].filter(w => y.has(w)).length
401 return shared >= 2 && shared / Math.min(x.size, y.size) >= SAME_SHARE
402}
403
404// What the agent asks the PERSON to do is not its promise. Measured live: "Run npm login &&
405// npm publish" and "Type /reload-plugins between turns" became promises, read off a closing
406// "Still waiting on you:" list. The prompt says so; this catches a phrase that names the
407// person outright.
408export const addressedToPerson = text => /\b(you|your|yourself)\b/i.test(String(text || ''))
409
410// At most PROMISE_CAP open promises: a new one past it stales the oldest. Promises are shown,
411// not gated, and five is already more than a band row or a person can keep in view.
412export const PROMISE_CAP = 5
413
414// The verdict applied: judged items close, promises join as items of their own. A promise
415// the judge says restates an open one (`same`), or that reads as one (samePromise), is that
416// promise, never a second; its newer words replace the older ones only when the judge said so.
417export function applyVerdict(ledger, verdict, { nowMs, turnId, promises }) {
418 const by = new Map(verdict.items.map(i => [i.id, i]))
419 let next = {
420 ...ledger,
421 items: ledger.items.map(i => {
422 const v = by.get(i.id)
423 if (!v || i.state !== 'open') return i
424 if (v.status === 'open') {
425 if (i.source !== 'promise') return i
426 const kept = (Number(i.keptOpen) || 0) + 1
427 return kept < STALE_TURNS ? { ...i, keptOpen: kept }
428 : { ...i, keptOpen: kept, state: 'stale', closedAt: nowMs, closedBy: 'judge', reason: `no turn addressed it in ${kept} judged turns` }
429 }
430 return { ...i, state: v.status, closedAt: nowMs, closedBy: 'judge', ...(v.reason ? { reason: v.reason } : {}) }
431 }),
432 }
433 if (promises === 'off') return next
434 const openP = () => next.items.filter(i => i.source === 'promise' && i.state === 'open')
435 const close = (ids, reason) => {
436 next = { ...next, items: next.items.map(i => (ids.includes(i.id) ? { ...i, state: 'stale', closedAt: nowMs, closedBy: 'judge', reason } : i)) }
437 }
438 for (const p of verdict.promises) {
439 const named = p.same && openP().find(i => i.id === p.same)
440 if (named) {
441 next = { ...next, items: next.items.map(i => (i === named ? { ...i, text: excerpt(p.text) } : i)) }
442 continue
443 }
444 const said = i => [i.text, ...(i.said || [])]
445 const like = openP().filter(i => said(i).some(t => t.toLowerCase() === p.text.toLowerCase() || samePromise(t, p.text)))
446 if (like.length) {
447 // The oldest keeps the commitment, and every phrasing of it; the others it bridges were
448 // the same one all along.
449 const keep = like[0]
450 const words = [...new Set([...(keep.said || []), ...like.slice(1).flatMap(said), p.text])].filter(t => t !== keep.text).slice(-SAID_KEPT)
451 next = { ...next, items: next.items.map(i => (i.id === keep.id ? { ...i, said: words } : i)) }
452 if (like.length > 1) close(like.slice(1).map(i => i.id), `same as promise ${keep.id}`)
453 continue
454 }
455 next = addItem(next, { text: p.text, at: nowMs, turnId, source: 'promise', prompt: Number(next.prompt) || 0 })
456 }
457 const over = openP().length - PROMISE_CAP
458 if (over > 0) close(openP().slice(0, over).map(i => i.id), `over the cap of ${PROMISE_CAP} open promises`)
459 return next
460}
461
462// The items the gate may re-prompt about: open, fresh, never gated before, from the latest
463// prompt's window (addPrompt); promises only when promises gate. An older item stays on the
464// band and in /ledger, closable by the agent, the judge or the person, and is never named
465// again: a session that moved on from it was answering what it was asked next, and a gate
466// that reached back made every turn's end about the backlog instead of the turn.
467export function gateTargets(ledger, nowMs, { promises }) {
468 const from = Number(ledger.window) || 0
469 return openItems(ledger, nowMs).filter(i => !i.gated && from && Number(i.prompt) >= from
470 && (i.source !== 'promise' || promises === 'gate'))
471}
472
473// The queued items the model never received. A message typed over a running turn fires
474// prompt.submit at Enter and is then queued; Up pulls it back into the composer, and nothing
475// raises an event for that. Measured: the gate then re-prompted about a message the model
476// had never seen, and the model rightly said it was never asked. So before a re-prompt, a
477// queued item has to be found among the session's user messages; one that is not, with the
478// session idle and no later turn started, was withdrawn. (Resubmitted, it is a new item.)
479// Matched on the item's opening words: the item keeps an excerpt, the transcript the whole.
480//
481// An INTERRUPTED item is asked the same. Esc before the turn wrote anything rewinds the
482// message out of the conversation and hands it back to the composer; sent again, edited or
483// not, it is a second prompt.submit and so a second item. Measured live: one message the
484// person sent once (to their mind) was two items with the same excerpt, and the gate named
485// both. The transcript held one copy, the resend; the first was a sibling of it, off the
486// conversation. So one message in the transcript accounts for one item: an item is
487// withdrawn when the items from it onward that carry its words outnumber the messages that
488// do, and the newest keep the messages. Two sends of the same words that both reached the
489// model are two messages, and stay two items.
490// `all`: every item in the ledger, which the later items are counted from.
491export function withdrawn(items, userTexts, all = items) {
492 const norm = t => oneLine(t).toLowerCase()
493 const texts = userTexts.map(t => norm(requestText(t)))
494 const asks = all.filter(i => i.source !== 'promise')
495 return items.filter(i => (i.queued || i.interrupted) && i.source !== 'promise').filter(i => {
496 const head = norm(i.text).replace(/…$/, '').split('…')[0].slice(0, 80)
497 if (!head) return false
498 const said = texts.filter(t => t.includes(head)).length
499 const from = asks.filter(x => Number(x.id) >= Number(i.id) && norm(x.text).includes(head)).length
500 return said < from
501 }).map(i => i.id)
502}
503
504// The person's items of a turn that was interrupted: the gate asks the transcript about
505// them before naming any (see `withdrawn`).
506export function markInterrupted(ledger, turnId) {
507 if (!turnId || !ledger.items.some(i => i.turnId === turnId && i.state === 'open' && i.source !== 'promise')) return null
508 return {
509 ...ledger,
510 items: ledger.items.map(i => (i.turnId === turnId && i.state === 'open' && i.source !== 'promise' ? { ...i, interrupted: true } : i)),
511 }
512}
513
514export const dropItems = (ledger, ids) => ({ ...ledger, items: ledger.items.filter(i => !ids.includes(i.id)) })
515
516export function gatePrompt(items) {
517 const lines = items.map(i => `${i.id}. ${i.source === 'promise' ? '(you said you would) ' : ''}"${excerpt(i.text, 160)}"`)
518 return [
519 `[ghostfleet ledger] ${items.length === 1 ? 'One request is' : `${items.length} requests are`} still open from this session:`,
520 ...lines,
521 `Finish each one now and close it with ${TOOL.close} and its proof, or say for each that it is not done and why (${TOOL.drop} if it was not a real ask). (Asked once per item; it will not be asked again.)`,
522 ].join('\n')
523}
524
525export const markGated = (ledger, ids, nowMs) => ({
526 ...ledger,
527 items: ledger.items.map(i => (ids.includes(i.id) ? { ...i, gated: true, gatedAt: nowMs } : i)),
528})
529
530// ── the agent's own hand: the tools ledger_close, ledger_drop, ledger_add ──────
531//
532// The judge reads the turn's text and never its work: it cannot see that a commit landed or a
533// file was written, only whether the prose said so, and it costs a model call every turn that
534// leaves anything open. The agent knows what it did. So the agent closes its own items as it
535// finishes them, each with the proof that it is done, and the judge reads only what is still
536// open after that (register.js, judgeTurn): a turn whose agent closed everything costs no call.
537export const TOOL = { close: 'mcp__ghostfleet__ledger_close', drop: 'mcp__ghostfleet__ledger_drop', add: 'mcp__ghostfleet__ledger_add' }
538export const PROOF = 240
539
540// Each answers { ledger, said }: `ledger` null when nothing changed, `said` what the agent
541// reads back. A close needs proof, a drop a reason: an empty one is refused, not defaulted,
542// since "closed, because I say so" is the judge's failure in the agent's hand.
543function closeAs(ledger, id, state, field, why, { nowMs, turnId }) {
544 const it = ledger.items.find(i => i.id === String(id ?? '').trim())
545 if (!it) return { ledger: null, said: `ledger: no item ${id}` }
546 if (it.state !== 'open') return { ledger: null, said: `ledger: item ${it.id} is already ${it.state}` }
547 const closed = { ...it, state, closedAt: nowMs, closedBy: 'agent', [field]: excerpt(why, PROOF), ...(turnId ? { closedTurn: turnId } : {}) }
548 return { ledger: { ...ledger, items: ledger.items.map(i => (i === it ? closed : i)) }, said: `ledger: ${it.id} ${state === 'done' ? 'closed' : 'dropped'}` }
549}
550export function closeByAgent(ledger, id, proof, ctx) {
551 if (!oneLine(proof)) return { ledger: null, said: `ledger: ${id} not closed: proof is required (a path, link, commit, PR number or command result)` }
552 return closeAs(ledger, id, 'done', 'proof', proof, ctx)
553}
554export function dropByAgent(ledger, id, reason, ctx) {
555 if (!oneLine(reason)) return { ledger: null, said: `ledger: ${id} not dropped: a reason is required` }
556 return closeAs(ledger, id, 'dropped', 'reason', reason, ctx)
557}
558// An ask the split missed joins the latest prompt's window, so the gate covers it too.
559export function addByAgent(ledger, text, { nowMs, turnId }) {
560 const t = excerpt(text)
561 if (!t) return { ledger: null, said: 'ledger: nothing added: the text was empty' }
562 const next = addItem(ledger, { text: t, at: nowMs, turnId, source: 'user', prompt: Number(ledger.prompt) || 0, by: 'agent', asIs: true })
563 return { ledger: next, said: `ledger: tracking ${next.seq}` }
564}
565
566// The note a prompt carries to the model, never shown the person: what is open, by id, and
567// the rule. Requests first, newest first (the message just sent is the one most likely to be
568// worked on), then promises; NOTE_ITEMS at most, each cut to NOTE_TEXT, so the note stays
569// under ~1.5k characters however long the backlog grows; the rest is a count and /ledger.
570// '' when nothing is open: a session with a clean ledger reads nothing extra.
571export const NOTE_ITEMS = 8
572export const NOTE_TEXT = 100
573export const NOTE_HEAD = '[ghostfleet ledger: open items]'
574export function contextNote(ledger, nowMs) {
575 const open = openItems(ledger, nowMs)
576 if (!open.length) return ''
577 const newest = (a, b) => Number(b.id) - Number(a.id)
578 const req = open.filter(i => i.source !== 'promise').sort(newest)
579 const prom = open.filter(i => i.source === 'promise').sort(newest)
580 const shown = [...req, ...prom].slice(0, NOTE_ITEMS)
581 const more = open.length - shown.length
582 return [
583 NOTE_HEAD,
584 ...shown.map(i => `${i.id} ${i.source === 'promise' ? '(you said you would)' : '(asked)'}: ${excerpt(i.text, NOTE_TEXT)}`),
585 ...(more > 0 ? [`+${more} more open (/ledger lists them)`] : []),
586 `When one is finished, call ${TOOL.close} with its id and the proof (a path, link, commit, PR number or command result). ${TOOL.drop} with a reason if it is not a real ask or the person cancelled it; ${TOOL.add} for an ask this list missed.`,
587 ].join('\n')
588}
589
590// By hand: `fleet-ledger close <id>` and `/ledger close <id>`. null when there is no such
591// open item.
592export function closeByHand(ledger, id, nowMs) {
593 const it = ledger.items.find(i => i.id === String(id))
594 if (!it || it.state !== 'open') return null
595 return {
596 ...ledger,
597 items: ledger.items.map(i => (i === it ? { ...i, state: 'done', closedAt: nowMs, closedBy: 'person' } : i)),
598 }
599}
600
601// `clear` closes every open item by hand; the history stays in the file.
602export const clearOpen = (ledger, nowMs) => ({
603 ...ledger,
604 items: ledger.items.map(i => (i.state === 'open' ? { ...i, state: 'done', closedAt: nowMs, closedBy: 'person' } : i)),
605})
606
607const ago = ms => {
608 const s = Math.max(0, Math.round(ms / 1000))
609 return s < 60 ? `${s}s` : s < 3600 ? `${Math.round(s / 60)}m` : s < 86400 ? `${Math.round(s / 3600)}h` : `${Math.round(s / 86400)}d`
610}
611
612// The listing both the CLI and /ledger print.
613export function listing(ledger, nowMs, { all = false } = {}) {
614 const rows = (all ? ledger.items : openItems(ledger, nowMs))
615 const j = ledger.judge
616 const judged = !j ? [] : [j.ok
617 ? `last judge: ok${j.items ? `, ${j.items} item${j.items === 1 ? '' : 's'} in ${j.calls || 1} call${(j.calls || 1) === 1 ? '' : 's'}` : ''} (${ago(nowMs - j.at)} ago)`
618 : `last judge: FAILING, failed open: ${j.why} (${ago(nowMs - j.at)} ago)`]
619 if (!rows.length) return [all ? 'ledger: empty' : 'ledger: nothing open', ...judged].join('\n')
620 const out = rows.map(i => {
621 const tag = i.source === 'promise' ? 'promise' : i.source
622 const by = i.state !== 'open' && i.closedBy ? `${i.closedBy === 'hand' ? 'person' : i.closedBy}: ` : ''
623 const said = i.proof || i.reason
624 const why = said || by ? ` (${by}${said ? excerpt(said, 80) : ''})`.replace(': )', ')') : ''
625 return `${i.id.padStart(3)} ${i.state.padEnd(8)} ${tag.padEnd(7)} ${ago(nowMs - i.at).padStart(4)} ${excerpt(i.text, 100)}${i.gated ? ' [gated]' : ''}${why}`
626 })
627 return [...out, ...judged].join('\n')
628}
629
630// The band's ledger row as text runs, the longest form that fits `columns`:
631// ledger · 2 open · oldest 4m "fix the login redirect…" · promise: merge when green
632// Narrower, the quote goes, then the promise's words, then everything but the counts. A judge
633// whose last call failed says so first, at every width: a failing judge closes nothing, and
634// a count that only grows was all the band showed of it.
635// ledger · judge failing: reply was not the JSON asked for · 24 open · …
636export const LEDGER_COLOR = { open: '#ffd75f', promise: '#87afd7', failing: '#ff5f5f' }
637export function ledgerRuns(s, nowMs, columns) {
638 if (!s || (!s.open && !s.promises && !s.judgeFailing)) return null
639 const cols = Math.max(1, Number(columns) || 80)
640 const sep = { text: ' · ', dim: true }
641 const head = { text: 'ledger', dim: true }
642 const open = s.open ? { text: `${s.open} open`, color: LEDGER_COLOR.open, bold: true } : null
643 const fail = n => (s.judgeFailing
644 ? { text: n ? `judge failing: ${excerpt(s.judgeFailing, n)}` : 'judge failing', color: LEDGER_COLOR.failing, bold: true }
645 : null)
646 const age = s.oldest ? ago(nowMs - s.oldest.at) : ''
647 const quote = n => (s.oldest ? { text: `oldest ${age} “${excerpt(s.oldest.text, n)}”`, dim: true } : null)
648 const prom = n => (s.promises
649 ? { text: n && s.oldestPromise ? `promise: ${excerpt(s.oldestPromise.text, n)}${s.promises > 1 ? ` +${s.promises - 1}` : ''}`
650 : `${s.promises} ${s.promises === 1 ? 'promise' : 'promises'}`, color: LEDGER_COLOR.promise }
651 : null)
652 const tiny = [
653 s.judgeFailing ? { text: '✗', color: LEDGER_COLOR.failing, bold: true } : null,
654 s.open ? { ...open, text: `${s.open}○` } : null,
655 s.promises ? { text: `${s.promises}◇`, color: LEDGER_COLOR.promise } : null,
656 ]
657 const forms = [
658 [head, fail(60), open, quote(48), prom(40)],
659 [head, fail(40), open, quote(24), prom(24)],
660 [head, fail(24), open, s.oldest ? { text: `oldest ${age}`, dim: true } : null, prom(0)],
661 [head, fail(0), open, prom(0)],
662 [fail(0), open, prom(0)],
663 tiny,
664 [tiny.find(Boolean)],
665 ]
666 let runs = null
667 for (const parts of forms) {
668 runs = parts.filter(Boolean).flatMap((r, i) => (i ? [sep, r] : [r]))
669 if (runs.reduce((k, r) => k + [...r.text].length, 0) <= cols) return runs
670 }
671 return runs
672}
673hooks/handoff.js 52 lines1// The handoff spool's vocabulary, as plain functions: no `$`, no I/O. The DELIVERY section
2// of register.js uses them; bin/fleet-send and lib/mod-target.mjs write and read the same
3// names, and test/run.sh imports this file with node to hold the three to each other.
4//
5// <fleet dir>/<session_id>.handoff/
6// <id>.json a prompt fleet-send left for this session: { id, text, reply? }
7// <id>.taken claimed by the mod, being submitted
8// <id>.done the receipt: { id, turnId } once its turn started, or { dropped | error }
9// <id>.revoked fleet-send took it back unclaimed and pasted it instead
10// .ready the pid of the mod process that delivers from here
11// <id> starts with the epoch milliseconds it was written at, so names sort in send order.
12
13export const spoolOf = (dir, sessionId) => `${dir}/${sessionId}.handoff`
14
15const ID = /^[0-9]+[0-9A-Za-z._-]*$/
16export const entryId = (name, ext) =>
17 typeof name === 'string' && name.endsWith(ext) && ID.test(name.slice(0, -ext.length))
18 ? name.slice(0, -ext.length) : null
19
20// The prompts waiting, oldest first.
21export const waiting = entries => entries
22 .filter(e => e && e.kind === 'file').map(e => entryId(e.name, '.json')).filter(Boolean).sort()
23
24// A receipt is kept for an hour, then cleared by the next claim.
25export const KEEP_DONE_MS = 3_600_000
26export const staleReceipts = (entries, nowMs) => entries
27 .filter(e => e && e.kind === 'file' && entryId(e.name, '.done'))
28 .filter(e => nowMs - Number(e.name.match(/^[0-9]+/)[0]) > KEEP_DONE_MS)
29 .map(e => e.name)
30
31// The reply address an entry carries, or null. fleet-send validated it; it arrives here
32// through a file, so it is validated again with fleet-send's own charset.
33const NAME = /^[A-Za-z0-9._~-]+$/
34export function replyOf(entry) {
35 const r = entry && entry.reply
36 if (!r || !NAME.test(String(r.sock || '')) || !NAME.test(String(r.sess || ''))) return null
37 if (typeof r.dir !== 'string' || !r.dir.startsWith('/') || /[\n\x1f]/.test(r.dir)) return null
38 return { sock: r.sock, sess: r.sess, dir: r.dir }
39}
40
41// hooks/fleet-event.sh's reply-to marker and its arming, as that hook reads them: the
42// address as three \x1f-separated fields, and the arming as the transcript line the turn
43// starts at, with `turn <id>` on a second line, which tells the hook this arming is
44// exact and not to be redone by a prompt typed into the same turn.
45export const replyMarker = r => `${r.sock}\x1f${r.sess}\x1f${r.dir}\n`
46export const armedMarker = (lines, turnId) => `${lines}\nturn ${turnId}\n`
47
48// Whether a turn.start is the turn a submitted prompt began: the engine hands the hook the
49// user's text as the turn proceeds with it.
50export const isTurnOf = (pending, text) =>
51 Boolean(pending && typeof text === 'string' && text.trim() === String(pending.text).trim())
52types/index.d.ts 29 lines1// The values the ghostfleet mod keeps in $.state, as `claude plugin validate` holds them.
2
3/** What the lead's band draws (hooks/band.js): its team's counts and its PRs' checks. */
4export type Band = {
5 workers: number
6 working: number
7 need: number
8 /** undefined: not read yet; null: gh could not say. */
9 prs?: { green: number; red: number; pending: number } | null
10}
11
12/** What the ledger's row draws (hooks/ledger.js ledgerSummary), against `asOf`, a minute. */
13export type LedgerItemRef = { id: string; text: string; at: number }
14export type Ledger = {
15 open: number
16 promises: number
17 oldest: LedgerItemRef | null
18 oldestPromise: LedgerItemRef | null
19 /** Why the judge's last call failed open; null while it is working. */
20 judgeFailing: string | null
21 asOf: number
22}
23
24declare module 'claude-code' {
25 interface PluginState {
26 ghostfleet: { band: Band | null; ledger: Ledger | null }
27 }
28}
29