Tells a running Fleet which sessions you have open, and what each one holds: its slot, branch, pull request, Jobs and subagents.

<img src="packages/brand/covers/armada-social-preview.png" alt="Armada — delegate without watching. You handle the decisions without babysitting." width="100%">
<a href="#status"><img src="https://img.shields.io/badge/status-pre--alpha-EE8450" alt="Status: pre-alpha"></a> <a href="LICENSE"><img src="https://img.shields.io/badge/license-Apache--2.0-4A9EDB" alt="License: Apache 2.0"></a> <img src="https://img.shields.io/badge/rust-1.90-4FB8D9" alt="Rust 1.90"> <img src="https://img.shields.io/badge/platform-macOS-8C97A6" alt="Platform: macOS">
Armada is pre-alpha, and part of it runs. There is one binary, armada:
armada serve [<path>] | The daemon. Binds a loopback port, publishes a runtime file, serves the API and turns Jobs until it is signalled |
armada check <name> | Runs one Check the repository's armada.yml declares, and first any Command its requires names, exactly as the gate does |
armada run <name> | Runs one Command it declares. Checks gate advancement; Commands do not |
armada covers | Reads changed paths on stdin and prints each Check they make run, by the same when: reading a Job's gate uses. Needs nothing running |
armada clean [--all] [--force] | Destructive. Gives this repository's worktrees, branches and Jobs back, keeping any branch whose work is not merged, and naming each worktree slot a Job still holds. --force deletes unmerged branches too, and gives back a completed or kept Job's slot where it is clean and landed. docs/practices/running-locally.md, Clearing up |
armada worktree lease <branch> | Leases a warm worktree from the repository's pool, waiting while every slot is held; release gives it back once clean and landed, --status lists who holds each. docs/practices/running-locally.md, Leasing a worktree |
What holds today, and how each is known:
| Claim | Evidence |
|---|---|
| A Job runs end to end — worktree, agent, Checks, commit, rebase, push, pull request | Several have. One document here was written that way |
| A Judge can refuse a step whose Checks all passed | It reads the step's work against the step's criteria |
| Evidence that guts a Check is caught — a deleted test, an added skip, a resolved-through config | The common patterns, mechanically, with no model call |
| A step editing outside what it declared is caught | The same way |
| An agent that loops without converging is stopped | Told to report first, then stopped |
| The machinery is proved | The acceptance test, hermetic — no process, no repository, no network |
| The merge is proved | A real agent fixed a real defect here, and the commit is in the history |
Any refusal stops the Job at the step it happened on, so you redirect that step or restart it rather than starting over.
Running it locally is how to start. Watch the milestones for what is next.
You can run several coding agents at once today. What you cannot do is stop watching them.
Armada is a macOS application that dispatches coding agents against git repositories and verifies their work before advancing them. An agent does not decide it is finished: it submits evidence through a tool Armada gave it, Armada runs the repository's own checks against the result, and only then does the work move on. Saying "done" in prose does nothing.
It exists because delegating work to something that reports on itself means reading everything anyway, which costs more than doing the work yourself.
Three rules it is built around:
This is not a sandbox. An agent can still run a shell, because a tool allowlist is a permission list and not a toolset. What bounds it is the worktree and the empty environment; real confinement means containers, and that is a different system. docs/scope.md carries the reasoning.
Two processes that ship as a pair and version together.
| Fleet | Rust daemon | Owns Jobs, spawns and supervises agents, runs checks, writes the record. Runs detached — quitting the app does not kill work in flight |
| Bridge | Electron app | The only way in. Holds one connection to Fleet and never talks to an agent directly |
They meet at exactly one seam: a versioned protocol over HTTP and a WebSocket. Every other boundary in the system is a function call or a file.
armada/
├── crates/ the Rust workspace — Fleet, the domain, the adapters
├── apps/desktop/ Bridge
├── packages/ shared: design tokens, icons, and what surfaces both read
├── xtask/ the build gate
└── docs/ contracts, practices, spikes, and what v1 taught
ARCHITECTURE.md is the map — the process topology, the crate graph, and the rules that hold everywhere, with diagrams.
You need macOS and four toolchains that do not come with it.
| Tool | Version | Where it comes from |
|---|---|---|
| Command Line Tools | the one matching your macOS | xcode-select --install, and Software Update after a macOS upgrade |
| Rust | 1.90 or later | rustup |
| Node | 24 — the version in .nvmrc | nvm, then nvm use |
| pnpm | the version in package.json | corepack enable, bundled with Node |
Command Line Tools behind your macOS fail at the link, not the compile. clang picks the newest SDK on the machine and hands it to whichever linker is installed, so a linker older than that SDK cannot read it and every Rust build ends in ld: tapi error: malformed file naming libSystem.tbd. It reads as a broken checkout and is a toolchain a version behind. softwareupdate --list names the one to install.
pnpm comes from corepack, which ships with Node, at the version package.json pins. You do not install it yourself. A Node that does not match .nvmrc refuses the install rather than warning about it: engineStrict is on.
cargo install writes to ~/.cargo/bin, and something has to put that on your PATH. rustup does, through the ~/.cargo/env it writes and your shell sources. A Rust from Homebrew writes no such file, so pnpm dev installs armada, calls it on the next line, and reports command not found.
git clone https://github.com/NickMele/armada.git
cd armada
nvm use # Node 24, per .nvmrc
corepack enable # puts pnpm on your PATH
pnpm install
cargo xtask verify-foundations # read what each line names, not the exit code
One more, once per machine: armada run browsers, before the component tests — see Tests.
A gate that reports red is not a broken checkout. A rule whose subject does not exist yet fails and names it, so the gate goes red whenever a milestone lands its registry rows ahead of the code satisfying them. The tests pass either way.
Armada checks itself with a task rather than a CI config, so the same command runs on a laptop and in CI. It has no dependencies and needs nothing built.
| Command | Checks |
|---|---|
cargo xtask verify-foundations | Every foundation rule. Red is a legitimate state |
cargo xtask verify-tokens | Design token outputs match the CSS they are generated from |
cargo xtask verify-docs | Open questions are collected, and every citation resolves |
cargo xtask verify-roadmap | Capabilities and their issues agree. Needs gh and a network, so not part of the gate |
Some of what the gate refuses:
armada check test # not `cargo test`
armada check acceptance # the milestone's own claim
pnpm bridge-test # the TypeScript half
pnpm bridge-test needs a browser. It runs the pure modules under packages/screens in node, then mounts every story in packages/components in headless Chromium — a story that throws is a failure, and a story with a play function has its assertions run against what it drew.
pnpm install does not fetch that browser. armada run browsers does, once per machine. Without it the second suite fails naming an executable that is not there, which is a missing browser and not a failing component.
Watch one while you work on it, from packages/components or packages/screens: pnpm exec vitest.
pnpm dev
That is the whole loop: it stops the running Fleet, reinstalls armada from the working tree, starts Fleet, waits for it to publish, and starts Bridge in the foreground. Ctrl-C stops both, which is a convenience of the script and not how Armada behaves in earnest.
Or by hand, which is only the same two halves in the right order:
cargo install --path crates/armada --debug # armada, on your PATH
armada serve . # runs until Ctrl-C
pnpm --filter @armada/desktop dev # in another terminal
Fleet first, always. Fleet binds a port and publishes a runtime file, and Bridge reads that file to find where to connect.
docs/practices/running-locally.md is the rest — what a healthy start prints, what Fleet refuses before it binds a port, how the two halves handle a protocol mismatch, what a finished Job leaves behind, and what armada clean will and will not delete.
Work is tracked as GitHub issues, grouped two ways.
Not yet — see CONTRIBUTING.md. Issues and questions are welcome; there is nothing to review.
Security: SECURITY.md. Armada brokers credentials and runs commands a repository declares, so the interesting constraints are listed there.
This repository is the second attempt. There was a v1 that worked well enough to be used and badly enough to be replaced, and it was deleted rather than refactored.
What it taught is written down in docs/v1-learnings/ — including the things that went wrong, which is most of it. Its history is on the v1-archive branch and the v1-final tag; a bare file path in a document means git show v1-final:<path>.
hooks/register.ts 705 lines1// Tells a running Fleet which sessions this machine has open and what each holds.
2// `docs/concepts/session.md`.
3//
4// **A session is reported as it happens and never read back**, with one
5// exception: a message a person sent it from Bridge, which Fleet holds until
6// this mod asks (`submitHeld`). One thing is told to the model: its own session id, so
7// `show_window` can name it. Two hooks change what a session does, an `open` of a page and an
8// `AskUserQuestion` Bridge answers first; every other answers with what `next` returned and lets its report go unawaited, so
9// Fleet being down costs the session nothing (`fleet.ts`). **No message text and no prompt leaves, apart from the first
10// line of the first prompt as a title, and a question put to Bridge.** A message is reported as who it went to
11// or came from, and how many.
12//
13// The helpers are top-level because the engine follows `$` only into a function
14// declared at the top of a file, and refuses the module otherwise.
15
16import type { Engine, Register } from 'claude-code'
17
18import {
19 answersIn,
20 artifactOf,
21 customTitleIn,
22 ghAct,
23 isDispatch,
24 jobIdsIn,
25 mayMoveBranch,
26 needAct,
27 micros,
28 pagesOpenedIn,
29 pullRequestsIn,
30 senderOf,
31 titleOf,
32 effortOf,
33 modeOf,
34 modelName,
35 MOD_VERSION,
36 transcriptPath,
37} from './facts'
38import type { Asked, Door, Fact, Report } from './fleet'
39
40const HARNESS = 'claude_code'
41const MEASURE_EVERY_MS = 10_000
42const RUNTIME_FILE = 'Library/Application Support/Armada/fleet.json'
43const WAIT_MS = 1500
44const ASK_EVERY_MS = 2000
45const SILENT_MS = 30_000
46const DOCS_ACTS = ['create', 'batch', 'update']
47const DISPATCHES = ['propose_job', 'propose_from_request', 'approve_dispatch', 'redispatch_job']
48
49type Dollar = Door & Pick<Engine, 'command' | 'process' | 'prompt' | 'session'>
50
51type Known = {
52 cwd: string
53 title?: string
54 branch?: string
55 prs: Map<string, Record<string, string>>
56 needs: Map<string, Record<string, string>>
57 messages: Map<string, number>
58 artifacts: Map<string, Record<string, string>>
59 tuned?: Tuning
60}
61
62type Tuning = Extract<Fact, { kind: 'tuned' }>
63
64const known = new Map<string, Known>()
65let measuredAt = Number.NEGATIVE_INFINITY
66
67function told(id: string, fact: Fact): Report {
68 return { harness: HARNESS, session_id: id, fact }
69}
70
71function everything(): Report[] {
72 return [...known].flatMap(([id, one]) => [
73 told(id, { kind: 'started', cwd: one.cwd, title: one.title, origin: 'terminal', mod_version: MOD_VERSION }),
74 ...(one.tuned === undefined ? [] : [told(id, one.tuned)]),
75 ...(one.branch === undefined
76 ? []
77 : [told(id, { kind: 'attached', attachment: { kind: 'branch', target: one.branch } })]),
78 ...[...one.needs].map(([path, detail]) =>
79 told(id, { kind: 'attached', attachment: { kind: 'need', target: path, detail } }),
80 ),
81 ...[...one.prs].map(([number, detail]) =>
82 told(id, { kind: 'attached', attachment: { kind: 'pr', target: number, detail } }),
83 ),
84 ...[...one.artifacts].map(([target, detail]) =>
85 told(id, { kind: 'attached', attachment: { kind: 'artifact', target, detail } }),
86 ),
87 ])
88}
89
90// What was dropped while Fleet was out of reach is not replayed, so what the
91// sessions hold is told again from `everything` when it answers.
92let queue: Promise<void> = Promise.resolve()
93let silentUntil = 0
94let wasOut = false
95
96
97async function portOf($: Door): Promise<number | undefined> {
98 const home = await $.env.get('HOME')
99 if (!home) return undefined
100 const file = JSON.parse(await $.fs.read(`${home}/${RUNTIME_FILE}`)) as { port?: unknown }
101 const port = file.port
102 if (typeof port !== 'number' || !Number.isInteger(port) || port < 1 || port > 65535) {
103 return undefined
104 }
105 return port
106}
107
108async function post($: Door, report: Report): Promise<boolean> {
109 const port = await portOf($)
110 if (port === undefined) return false
111 const sent = $.http.fetch(`http://127.0.0.1:${port}/sessions/report`, {
112 method: 'POST',
113 headers: { 'content-type': 'application/json' },
114 body: JSON.stringify(report),
115 })
116 const answered = await Promise.race([sent, $.clock.sleep(WAIT_MS).then(() => undefined)])
117 // A refusal is Fleet answering; only silence is Fleet being out of reach.
118 return answered !== undefined
119}
120
121async function deliver($: Door, report: Report): Promise<void> {
122 const now = await $.clock.now()
123 if (now < silentUntil) return
124 try {
125 const reports = wasOut ? [...everything(), report] : [report]
126 for (const one of reports) {
127 if (!(await post($, one))) throw new Error('out of reach')
128 }
129 wasOut = false
130 } catch {
131 silentUntil = now + SILENT_MS
132 wasOut = true
133 }
134}
135
136// What a person sent this session from Bridge. Each is submitted as the
137// person's own prompt, which starts a turn when the session is idle and waits
138// for it when it is not (spike 27). One at a time, so they keep their order.
139let submitting: Promise<void> = Promise.resolve()
140
141async function submitHeld($: Dollar): Promise<void> {
142 if ((await $.clock.now()) < silentUntil) return
143 const port = await portOf($)
144 if (port === undefined) return
145 const id = await $.session.id()
146 const asked = await $.http.fetch(`http://127.0.0.1:${port}/sessions/held`, {
147 method: 'POST',
148 headers: { 'content-type': 'application/json' },
149 body: JSON.stringify({ session_id: id }),
150 })
151 if (!asked.ok) return
152 const held = JSON.parse(asked.text) as { messages?: unknown; commands?: unknown }
153 // A command is run as typed: the engine refuses a slash command submitted as text.
154 const commands = Array.isArray(held.commands) ? held.commands : []
155 for (const one of commands as { command?: unknown; args?: unknown }[]) {
156 if (typeof one.command !== 'string' || typeof one.args !== 'string') continue
157 const { command, args } = one
158 submitting = submitting
159 .then(() => $.command.run({ command, args }))
160 .then(() => tuned($, id, {}))
161 .catch(() => undefined)
162 }
163 if (!Array.isArray(held.messages)) return
164 for (const text of held.messages) {
165 if (typeof text !== 'string') continue
166 submitting = submitting
167 .then(() => $.prompt.submit({ text, asUser: true }))
168 .then(() => undefined)
169 .catch(() => undefined)
170 }
171}
172
173/** Queue a report, in order. Never throws and never waits. */
174function send($: Door, report: Report): void {
175 queue = queue.then(() => deliver($, report)).catch(() => undefined)
176}
177
178/** Wait, within a short bound, for what is queued. */
179async function flush($: Door): Promise<void> {
180 await Promise.race([queue, $.clock.sleep(WAIT_MS * 2)])
181}
182
183// A fact that was still being worked out when its session ended is dropped, so
184// `ended` is the last thing Fleet hears of a session.
185function say($: Door, id: string, fact: Fact): void {
186 if (fact.kind !== 'started' && fact.kind !== 'ended' && !known.has(id)) return
187 send($, told(id, fact))
188}
189
190function settle($: Door, id: string, kind: string, target: string, state: 'spent' | 'given_back') {
191 say($, id, { kind: 'settled', attachment: { kind, target }, state })
192}
193
194async function branchOf($: Dollar, cwd: string): Promise<string | undefined> {
195 const ran = await $.process.run(['git', 'rev-parse', '--abbrev-ref', 'HEAD'], {
196 cwd,
197 timeoutMs: 3000,
198 })
199 const name = ran.stdout.trim()
200 return ran.exitCode === 0 && name !== '' && name !== 'HEAD' ? name : undefined
201}
202
203async function pullRequestOn($: Dollar, id: string, one: Known): Promise<void> {
204 const ran = await $.process.run(['gh', 'pr', 'view', '--json', 'number,url,state,title,headRefName,isDraft'], {
205 cwd: one.cwd,
206 timeoutMs: 10_000,
207 })
208 if (ran.exitCode !== 0) return
209 const view = JSON.parse(ran.stdout) as {
210 number?: number
211 url?: string
212 state?: string
213 title?: string
214 headRefName?: string
215 isDraft?: boolean
216 }
217 if (typeof view.number !== 'number') return
218 const number = String(view.number)
219 // The ledger names a pull request by its title, so a row told without one draws as a bare number.
220 const detail: Record<string, string> = {
221 url: view.url ?? '',
222 address: view.url ?? '',
223 state: view.isDraft === true && view.state === 'OPEN' ? 'draft' : (view.state ?? '').toLowerCase(),
224 ...(view.title === undefined ? {} : { title: view.title }),
225 ...(view.headRefName === undefined ? {} : { branch: view.headRefName }),
226 }
227 one.prs.set(number, detail)
228 say($, id, { kind: 'attached', attachment: { kind: 'pr', target: number, detail } })
229 if (view.state === 'MERGED') settle($, id, 'pr', number, 'spent')
230 if (view.state === 'CLOSED') settle($, id, 'pr', number, 'given_back')
231}
232
233/** Where the branch is now, and the pull request on it. */
234async function look($: Dollar, id: string): Promise<void> {
235 try {
236 const one = known.get(id)
237 if (one === undefined) return
238 const branch = await branchOf($, one.cwd)
239 if (branch === one.branch) return
240 if (one.branch !== undefined && branch === undefined) {
241 settle($, id, 'branch', one.branch, 'given_back')
242 }
243 one.branch = branch
244 if (branch === undefined) return
245 say($, id, { kind: 'attached', attachment: { kind: 'branch', target: branch } })
246 await pullRequestOn($, id, one).catch(() => undefined)
247 } catch {
248 // A directory with no repository, or no `git`: nothing to report.
249 }
250}
251
252const SHOWN_INSTEAD =
253 "Shown in Bridge's window instead. Use the armada show_window tool to show the owner a page; never `open` it."
254
255async function showPages($: Dollar, urls: string[]): Promise<void> {
256 try {
257 const port = await portOf($)
258 if (port === undefined) return
259 const session_id = await $.session.id()
260 for (const url of urls) {
261 await Promise.race([
262 $.http.fetch(`http://127.0.0.1:${port}/sessions/window`, {
263 method: 'POST',
264 headers: { 'content-type': 'application/json' },
265 body: JSON.stringify({ url, session_id }),
266 }),
267 $.clock.sleep(WAIT_MS),
268 ])
269 }
270 } catch {
271 // Fleet out of reach: the line is still not run.
272 }
273}
274
275/** How long Fleet holds one poll, and how long the mod waits on a request before it asks again. */
276const POLL_FETCH_MS = 35_000
277const POLL_RETRY_MS = 3000
278
279async function postAsk($: Door, port: number, body: unknown, bound: number): Promise<Asked | undefined> {
280 const sent = await Promise.race([
281 $.http.fetch(`http://127.0.0.1:${port}/sessions/ask/terminal`, {
282 method: 'POST',
283 headers: { 'content-type': 'application/json' },
284 body: JSON.stringify(body),
285 }),
286 $.clock.sleep(bound).then(() => undefined),
287 ])
288 return sent?.ok === true ? (JSON.parse(sent.text) as Asked) : undefined
289}
290
291/**
292 * The terminal's question, put to Bridge. **Posted once, then polled for**: Fleet keeps the question
293 * and any answer from Bridge, so a request that times out, a dropped connection or a Fleet that
294 * restarts costs one poll and never the question. Resolves with the answer, or nothing where Fleet
295 * says the card is gone. A Fleet that is out of reach is waited out quietly; the terminal's own
296 * prompt is all there is meanwhile. `over` ends the loop once the terminal's prompt has.
297 */
298async function putToBridge($: Door, id: string, questions: unknown, over: { done: boolean }): Promise<Asked | undefined> {
299 try {
300 let call: string | undefined
301 while (call === undefined && !over.done) {
302 const port = await portOf($).catch(() => undefined)
303 const put = port === undefined ? undefined : await postAsk($, port, { kind: 'asks', session_id: id, input: { questions } }, POLL_FETCH_MS).catch(() => undefined)
304 if (put?.outcome === 'asked') call = put.call
305 else if (put !== undefined) return undefined
306 else await $.clock.sleep(POLL_RETRY_MS)
307 }
308 while (call !== undefined && !over.done) {
309 const port = await portOf($).catch(() => undefined)
310 const polled = port === undefined ? undefined : await postAsk($, port, { kind: 'wait', session_id: id, call }, POLL_FETCH_MS).catch(() => undefined)
311 if (polled === undefined) await $.clock.sleep(POLL_RETRY_MS)
312 else if (polled.outcome !== 'waiting') return polled
313 }
314 return undefined
315 } catch {
316 return undefined
317 }
318}
319
320/** The terminal's own prompt ended first, so Bridge's card closes. */
321async function settledInTerminal($: Door, id: string, answered: boolean): Promise<void> {
322 try {
323 const port = await portOf($)
324 if (port === undefined) return
325 await Promise.race([
326 $.http.fetch(`http://127.0.0.1:${port}/sessions/ask/terminal`, {
327 method: 'POST',
328 headers: { 'content-type': 'application/json' },
329 body: JSON.stringify({ kind: 'settled', session_id: id, answered }),
330 }),
331 $.clock.sleep(WAIT_MS),
332 ])
333 } catch {
334 // Fleet out of reach: its card closes when its hold runs out.
335 }
336}
337
338function idNote(id: string): string {
339 return `Your Armada session id is ${id}. Pass it as session_id to the armada show_window tool. To show the owner a web page (a walk, a mock, a dev server), call show_window. Never run \`open\`. Keep the armada waiting_for tool current: whenever you need the owner for something (a decision, a page to look at, a pull request to approve, a command only he can run), put it in the list with one short line each and call waiting_for with the whole list, passing session_id. Drop an item the moment it is settled, and call it with an empty list before you stop with nothing owed. Do not wait to be asked what is outstanding.`
340}
341
342async function begin($: Dollar, id: string, cwd?: string): Promise<void> {
343 // A Drone, a Judge call or a scout loads this mod too, since it reads the operator's user
344 // settings. Fleet marks those launches, and they are not sessions to list.
345 if (await $.env.get('ARMADA_DRONE')) return
346 const where = cwd ?? (await $.session.cwd())
347 known.set(id, { cwd: where, prs: new Map(), needs: new Map(), messages: new Map(), artifacts: new Map() })
348 say($, id, { kind: 'started', cwd: where, origin: 'terminal', mod_version: MOD_VERSION })
349 void look($, id)
350 void tuned($, id, {}, true).catch(() => undefined)
351}
352
353/**
354 * What the terminal runs on, told when it changed: the model, and the effort and permission mode a
355 * hook input carries. The commands it lists go once, with the first. **The mode is only read**: the
356 * mods API cannot switch a live session's (spike 27).
357 */
358async function tuned(
359 $: Dollar,
360 id: string,
361 seen: { mode?: string; effort?: unknown },
362 withCommands = false,
363): Promise<void> {
364 const one = known.get(id)
365 if (one === undefined) return
366 const mode = modeOf(seen.mode)
367 const effort = effortOf(seen.effort)
368 const next: Tuning = {
369 kind: 'tuned',
370 model: modelName(await $.session.model()),
371 ...(effort === undefined ? {} : { effort }),
372 ...(mode === undefined ? {} : { mode }),
373 }
374 if (withCommands) {
375 next.commands = (await $.command.list()).map(c => ({ name: c.name, says: c.description }))
376 next.mod_version = MOD_VERSION
377 }
378 const before = one.tuned
379 const same =
380 before !== undefined &&
381 before.model === next.model &&
382 before.effort === (next.effort ?? before.effort) &&
383 before.mode === (next.mode ?? before.mode)
384 if (same && !withCommands) return
385 one.tuned = { ...before, ...next, commands: undefined, mod_version: undefined }
386 say($, id, next)
387}
388
389async function seen($: Dollar, mode: string | undefined, effort: unknown): Promise<void> {
390 try {
391 const [id] = await current($)
392 await tuned($, id, { mode, effort })
393 } catch {
394 // What the terminal runs on is a nicety: nothing here is worth a session's turn.
395 }
396}
397
398/** The session's id, starting it first where it began without a `session.start`. */
399async function current($: Dollar): Promise<[string, Known]> {
400 const id = await $.session.id()
401 if (!known.has(id)) await begin($, id)
402 const one = known.get(id)
403 if (one === undefined) throw new Error('unreported session')
404 return [id, one]
405}
406
407async function afterBash($: Dollar, command: string, text: string): Promise<void> {
408 const [id, one] = await current($)
409 const need = needAct(command)
410 if (need?.act === 'declare') {
411 const detail = { what: need.what }
412 one.needs.set(need.path, detail)
413 say($, id, { kind: 'attached', attachment: { kind: 'need', target: need.path, detail } })
414 }
415 // Without the words it was declared in, a took would overwrite them with nothing.
416 const held = need?.act === 'took' ? one.needs.get(need.path) : undefined
417 if (need?.act === 'took' && held !== undefined) {
418 const detail = { what: held.what, took: need.value }
419 one.needs.set(need.path, detail)
420 say($, id, { kind: 'attached', attachment: { kind: 'need', target: need.path, detail } })
421 }
422 if (need?.act === 'release') {
423 one.needs.delete(need.path)
424 settle($, id, 'need', need.path, 'given_back')
425 }
426 const act = ghAct(command)
427 if (act?.act === 'create') {
428 for (const pr of pullRequestsIn(text)) {
429 const detail = { url: pr.url }
430 one.prs.set(pr.number, detail)
431 say($, id, { kind: 'attached', attachment: { kind: 'pr', target: pr.number, detail } })
432 }
433 }
434 if (act?.act === 'merge' || act?.act === 'close') {
435 const target = act.number ?? (one.prs.size === 1 ? [...one.prs.keys()][0] : undefined)
436 if (target !== undefined) {
437 settle($, id, 'pr', target, act.act === 'merge' ? 'spent' : 'given_back')
438 }
439 }
440 if (mayMoveBranch(command) || act?.act === 'create') {
441 const cwd = await $.session.cwd()
442 if (cwd !== one.cwd) {
443 one.cwd = cwd
444 say($, id, { kind: 'moved', cwd })
445 }
446 await look($, id)
447 }
448}
449
450async function dispatched($: Dollar, via: string, text: string): Promise<void> {
451 const [id] = await current($)
452 for (const job of jobIdsIn(text)) {
453 say($, id, { kind: 'attached', attachment: { kind: 'job', target: job, detail: { via } } })
454 }
455}
456
457async function spawned(
458 $: Dollar,
459 target: string,
460 type: string,
461 description: string,
462): Promise<void> {
463 const [id] = await current($)
464 const detail = { type, description: description.slice(0, 80) }
465 say($, id, { kind: 'attached', attachment: { kind: 'subagent', target, detail } })
466}
467
468/**
469 * A page published, a document written or a Claude Docs document made: an artifact on the ledger.
470 * Only what a person would open. A code edit is not told.
471 */
472async function made(
473 $: Dollar,
474 tool: string,
475 input: Record<string, unknown>,
476 text: string,
477 created: boolean,
478): Promise<void> {
479 const artifact = artifactOf(tool, input, text, created)
480 if (artifact === undefined) return
481 const [id, one] = await current($)
482 const detail: Record<string, string> = { form: artifact.form }
483 if (artifact.title !== undefined) detail.title = artifact.title
484 if (artifact.form === 'image' && one.artifacts.has(artifact.target)) return
485 one.artifacts.set(artifact.target, detail)
486 say($, id, { kind: 'attached', attachment: { kind: 'artifact', target: artifact.target, detail } })
487}
488
489async function messaged($: Dollar, direction: 'sent' | 'received', who: string): Promise<void> {
490 const [id, one] = await current($)
491 const target = `${direction === 'sent' ? 'to' : 'from'}:${who}`
492 const count = (one.messages.get(target) ?? 0) + 1
493 one.messages.set(target, count)
494 const detail = { direction, count: String(count) }
495 say($, id, { kind: 'attached', attachment: { kind: 'message', target, detail } })
496}
497
498async function measured($: Dollar, tokens?: number, window?: number, usd?: number): Promise<void> {
499 const at = await $.clock.now()
500 if (at - measuredAt < MEASURE_EVERY_MS) return
501 measuredAt = at
502 const [id] = await current($)
503 const usage = { context_tokens: tokens, context_window: window, cost_micros: micros(usd) }
504 say($, id, { kind: 'measured', usage })
505}
506
507async function titled($: Dollar, prompt: string): Promise<void> {
508 const [id, one] = await current($)
509 const title = one.title === undefined ? titleOf(prompt) : undefined
510 if (title === undefined) return
511 one.title = title
512 say($, id, { kind: 'titled', title })
513}
514
515/**
516 * A `/rename` in the terminal. **The name is what was typed**; a bare `/rename` has Claude Code
517 * make one up, which is read from the transcript it writes the entry to. It replaces any title
518 * and is told as `named`, so the first prompt's line never takes it back.
519 */
520async function renamed($: Dollar, typed: string): Promise<void> {
521 const [id, one] = await current($)
522 let title: string | undefined = typed.replace(/\s+/g, ' ').trim() || undefined
523 if (title === undefined) {
524 try {
525 const home = await $.env.get('HOME')
526 const cwd = await $.session.cwd()
527 title = home ? customTitleIn(await $.fs.read(transcriptPath(home, cwd, id))) : undefined
528 } catch {
529 // A transcript too large to read, or not there: the name is not known.
530 }
531 }
532 if (title === undefined) return
533 one.title = title
534 say($, id, { kind: 'titled', title, named: true })
535}
536
537async function ended($: Dollar, id: string, reason: string): Promise<void> {
538 if (!known.has(id)) await begin($, id)
539 say($, id, { kind: 'ended', reason })
540 known.delete(id)
541 await flush($)
542}
543
544export const register: Register = on => {
545 on('session.start', async ($, e, next) => {
546 const started = await next(e)
547 if (await $.env.get('ARMADA_DRONE')) return started
548 $.clock.every(ASK_EVERY_MS, () => submitHeld($).catch(() => undefined))
549 void $.session
550 .id()
551 .then(id => begin($, id, e.cwd))
552 .catch(() => undefined)
553 return started
554 })
555
556 // `show_window` places a terminal session by the id it names, which the model cannot learn otherwise.
557 on('prompt.context', async ($, e, next) => {
558 const out = await next(e)
559 if (await $.env.get('ARMADA_DRONE')) return out
560 const id = await $.session.id()
561 return { ...out, blocks: [...out.blocks, { name: 'armadaSession', text: idNote(id) }] }
562 })
563
564 // Again at every start (startup, resume, clear, compact), so the id is never out of view.
565 on('classic.SessionStart', async ($, e, next) => {
566 const out = await next(e)
567 if (await $.env.get('ARMADA_DRONE')) return out
568 const id = await $.session.id()
569 return { ...out, additionalContext: [...(out.additionalContext ?? []), idNote(id)] }
570 })
571
572 on('prompt.submit', async ($, e, next) => {
573 const out = await next(e)
574 void titled($, e.text).catch(() => undefined)
575 return out
576 })
577
578 on('command.run', { command: 'rename' }, async ($, e, next) => {
579 const out = await next(e)
580 void renamed($, e.args).catch(() => undefined)
581 return out
582 })
583
584 // A page is shown in Bridge's window and lands on the ledger; `open` would go to the owner's browser.
585 on('tool.call', { tool: 'Bash' }, async ($, e, next) => {
586 const pages = pagesOpenedIn(e.command)
587 if (pages.length > 0 && !(await $.env.get('ARMADA_DRONE'))) {
588 await showPages($, pages)
589 return { deny: SHOWN_INSTEAD }
590 }
591 const ran = await next(e)
592 if (ran.deny === undefined && ran.isError !== true) {
593 void afterBash($, e.command, ran.text ?? '').catch(() => undefined)
594 }
595 return ran
596 })
597
598 // A question asked in the terminal is also put to Bridge, and the first answer wins. The terminal's
599 // prompt opens beneath (`next`) while Fleet holds the question; returning before `next` does aborts
600 // the prompt, so an answer from Bridge is the tool's own result and the terminal never keeps it.
601 on('tool.call', { tool: 'AskUserQuestion' }, async ($, e, next) => {
602 if (await $.env.get('ARMADA_DRONE')) return next(e)
603 const id = await $.session.id()
604 const over = { done: false }
605 const terminal = next(e).then(ran => ({ ran }))
606 const bridge = putToBridge($, id, e.questions, over).then(asked =>
607 asked?.outcome === 'answered' || asked?.outcome === 'refused'
608 ? { asked }
609 : new Promise<never>(() => undefined),
610 )
611 try {
612 const first = await Promise.race([terminal, bridge])
613 if ('asked' in first) {
614 if (first.asked.outcome === 'refused') return { deny: first.asked.message }
615 return { result: { questions: e.questions, answers: answersIn(first.asked.updated_input) } }
616 }
617 void settledInTerminal($, id, first.ran.deny === undefined && first.ran.isError !== true)
618 return first.ran
619 } catch (error) {
620 void settledInTerminal($, id, false)
621 throw error
622 } finally {
623 over.done = true
624 }
625 })
626
627 for (const name of DISPATCHES) {
628 on('tool.call', { tool: `mcp__armada-fleet__${name}` }, async ($, e, next) => {
629 const ran = await next(e)
630 if (isDispatch(e.tool) && ran.deny === undefined && ran.isError !== true) {
631 void dispatched($, name, ran.text ?? '').catch(() => undefined)
632 }
633 return ran
634 })
635 }
636
637 for (const tool of ['Artifact', 'Write', 'Read', ...DOCS_ACTS.map(act => `mcp__claude_ai_Claude_Docs__${act}`)]) {
638 on('tool.call', { tool }, async ($, e, next) => {
639 const ran = await next(e)
640 if (ran.deny === undefined && ran.isError !== true) {
641 const created = tool === 'Write' && (ran.result as { type?: string } | undefined)?.type === 'create'
642 void made($, tool, e as Record<string, unknown>, ran.text ?? '', created).catch(() => undefined)
643 }
644 return ran
645 })
646 }
647
648 on('agent.spawn', async ($, e, next) => {
649 const out = await next(e)
650 if (out.deny === undefined) {
651 const target = out.agentId ?? e.tool_use_id
652 void spawned($, target, e.subagentType, e.description).catch(() => undefined)
653 }
654 return out
655 })
656
657 on('session.send', async ($, e, next) => {
658 const out = await next(e)
659 if (out.isDelivered) void messaged($, 'sent', e.to).catch(() => undefined)
660 return out
661 })
662
663 on('session.receive', async ($, e, next) => {
664 const out = await next(e)
665 const from = senderOf(e.origin as { kind: string; teammate?: string })
666 void messaged($, 'received', from).catch(() => undefined)
667 return out
668 })
669
670 on('session.measure', async ($, e, next) => {
671 const out = await next(e)
672 void measured($, e.context.tokens, e.context.window, e.cost?.usd).catch(() => undefined)
673 return out
674 })
675
676 // The terminal's permission mode and effort are on the settings-hook inputs and nowhere else.
677 on('classic.UserPromptSubmit', async ($, e, next) => {
678 const out = await next(e)
679 void seen($, e.permission_mode, e.effort)
680 return out
681 })
682
683 on('classic.Stop', async ($, e, next) => {
684 const out = await next(e)
685 void seen($, e.permission_mode, e.effort)
686 return out
687 })
688
689 on('turn.complete', async ($, e, next) => {
690 const out = await next(e)
691 if (e.agentId === undefined) {
692 void current($)
693 .then(([id]) => say($, id, { kind: 'turn_completed' }))
694 .catch(() => undefined)
695 }
696 return out
697 })
698
699 on('session.end', async ($, e, next) => {
700 const out = await next(e)
701 await ended($, e.sessionId, e.reason).catch(() => undefined)
702 return out
703 })
704}
705hooks/facts.ts 263 lines1// What a session's own words and tool results say about what it holds. Pure, so
2// each reading is a test and not a session.
3
4const TITLE_MOST = 80
5
6/**
7 * The version this mod reports to Fleet, so Bridge can mark a session whose mod is older than the
8 * repository's. **Bump it with `version` in `.claude-plugin/plugin.json`**: the mod cannot read that
9 * file while it runs, and a Fleet test (`terminal_session.rs`) holds the two equal.
10 */
11export const MOD_VERSION = '0.3.6'
12
13/** The `answers` map a question's answered input carries, keyed by question text. */
14export function answersIn(input: unknown): Record<string, string> {
15 const answers = (input as { answers?: unknown } | null)?.answers
16 if (typeof answers !== 'object' || answers === null) return {}
17 return Object.fromEntries(Object.entries(answers).filter(([, said]) => typeof said === 'string')) as Record<string, string>
18}
19
20/**
21 * The web pages a shell line opens in the owner's browser: `open`, `xdg-open` or
22 * `python -m webbrowser` with an `http(s)` address. Fleet reads hosted sessions' lines the same way
23 * (`pages_opened`, `crates/fleet/src/helm/deciding.rs`).
24 */
25export function pagesOpenedIn(command: string): string[] {
26 const pages: string[] = []
27 for (const segment of command.replace(/&&|\|\|/g, ';').split(/[;|\n]/)) {
28 const words = segment
29 .split(/\s+/)
30 .filter(word => word !== '')
31 .map(word => word.replace(/^['"]+|['"]+$/g, ''))
32 const first = words[0] ?? ''
33 const opens =
34 first === 'open' ||
35 first === 'xdg-open' ||
36 (first.startsWith('python') && words.some((word, i) => word === '-m' && words[i + 1] === 'webbrowser'))
37 if (opens) pages.push(...words.filter(word => /^https?:\/\//.test(word)))
38 }
39 return pages
40}
41
42// A harness wraps what it adds to a prompt in hyphenated tags. A command's and a reminder's
43// contents are the harness's words, so the whole block goes; any other wrapper, such as
44// `<agent-message from="…">`, only loses its tag and the words inside are the person's.
45const MACHINE_BLOCK = /<((?:local-)?command-[\w-]+|system-reminder)(?:\s[^>]*)?>[\s\S]*?(?:<\/\1>|$)/g
46const WRAPPER_TAG = /<\/?[a-z]+(?:-[\w]+)+(?:\s[^>]*)?>/gi
47
48/** What is left of a prompt once the harness's own markup is taken off. */
49export function withoutMarkup(prompt: string): string {
50 return prompt.replace(MACHINE_BLOCK, '').replace(WRAPPER_TAG, '')
51}
52
53/** The first line of real text in a prompt as a title, or nothing for a command or a blank. */
54export function titleOf(prompt: string): string | undefined {
55 const line = withoutMarkup(prompt)
56 .split('\n')
57 .map(one => one.trim())
58 .find(one => one !== '')
59 if (line === undefined || line.startsWith('/')) return undefined
60 const flat = line.replace(/\s+/g, ' ')
61 return flat.length > TITLE_MOST ? `${flat.slice(0, TITLE_MOST - 1)}…` : flat
62}
63
64/** The name a `/rename` gave, from a transcript: its last `custom-title` entry. */
65export function customTitleIn(transcript: string): string | undefined {
66 let found: string | undefined
67 for (const line of transcript.split('\n')) {
68 if (!line.includes('customTitle')) continue
69 try {
70 const entry = JSON.parse(line) as { type?: unknown; customTitle?: unknown }
71 if (entry.type === 'custom-title' && typeof entry.customTitle === 'string') {
72 found = entry.customTitle.trim() || found
73 }
74 } catch {
75 // A line cut short at the end of a file being written.
76 }
77 }
78 return found === undefined ? undefined : found.replace(/\s+/g, ' ')
79}
80
81/** Where Claude Code keeps a session's transcript, under the directory it ran in. */
82export function transcriptPath(home: string, cwd: string, id: string): string {
83 return `${home}/.claude/projects/${cwd.replace(/[^a-zA-Z0-9]/g, '-')}/${id}.jsonl`
84}
85
86/** Whether a Bash command may have moved the branch or the directory. */
87export function mayMoveBranch(command: string): boolean {
88 return /\bgit\b[^|;&]*\b(checkout|switch|branch|commit|merge|rebase|pull|worktree|reset|cherry-pick|clone)\b/.test(
89 command,
90 )
91}
92
93export type PullRequest = { number: string; url: string }
94
95/** Pull requests a result names by their address. */
96export function pullRequestsIn(text: string): PullRequest[] {
97 const found = new Map<string, PullRequest>()
98 for (const hit of text.matchAll(/https:\/\/github\.com\/[\w.-]+\/[\w.-]+\/pull\/(\d+)/g)) {
99 found.set(hit[1], { number: hit[1], url: hit[0] })
100 }
101 return [...found.values()]
102}
103
104export type GhAct = { act: 'create' | 'merge' | 'close'; number?: string }
105
106/** A `gh pr` command that makes, lands or closes a pull request. */
107export function ghAct(command: string): GhAct | undefined {
108 const hit = /\bgh\s+pr\s+(create|merge|close)\b([^|;&]*)/.exec(command)
109 if (hit === null) return undefined
110 // `--auto` asks the forge to merge later, or puts it in the merge queue: nothing has merged
111 // yet, and Fleet's pull watch settles it when it does (#2027 read as merged, 8 Oct 2026).
112 if (hit[1] === 'merge' && /(?:^|\s)--auto(?:\s|$)/.test(hit[2])) return undefined
113 const number = /(?:^|\s)#?(\d+)(?:\s|$)/.exec(hit[2])?.[1]
114 return { act: hit[1] as GhAct['act'], number }
115}
116
117export type NeedAct =
118 | { act: 'declare'; path: string; what: string }
119 | { act: 'took'; path: string; value: string }
120 | { act: 'release'; path: string }
121
122/** The words of a shell command, quotes taken off. */
123function wordsOf(text: string): string[] {
124 return [...text.matchAll(/"((?:[^"\\]|\\.)*)"|'([^']*)'|(\S+)/g)].map(
125 hit => hit[1]?.replace(/\\(.)/g, '$1') ?? hit[2] ?? hit[3],
126 )
127}
128
129/** The `armada need` a command runs, in the three forms that declare, take or give back. */
130export function needAct(command: string): NeedAct | undefined {
131 const hit = /(?:^|[;&|(])\s*armada\s+need\s+([^|;&\n]*)/.exec(command)
132 if (hit === null) return undefined
133 const rest = wordsOf(hit[1])
134 const path = (one: string) => one.replace(/^(\.\/)+/, '')
135 if (rest[0] === '--release' && rest.length === 2) return { act: 'release', path: path(rest[1]) }
136 if (rest[0] === '--took' && rest.length === 3) {
137 return { act: 'took', path: path(rest[1]), value: rest[2] }
138 }
139 if (rest.length === 2 && !rest[0].startsWith('-')) {
140 return { act: 'declare', path: path(rest[0]), what: rest[1] }
141 }
142 return undefined
143}
144
145const DISPATCH = /^mcp__armada-fleet__(propose_job|propose_from_request|approve_dispatch|redispatch_job)$/
146
147export function isDispatch(tool: string): boolean {
148 return DISPATCH.test(tool)
149}
150
151/** Job ids in a dispatch's answer, which are ULIDs. */
152export function jobIdsIn(text: string): string[] {
153 return [...new Set(text.match(/\b[0-9A-HJKMNP-TV-Z]{26}\b/g) ?? [])].slice(0, 10)
154}
155
156/** Whom a delivery came from, as far as the delivery says: never a session id. */
157export function senderOf(origin: { kind: string; teammate?: string }): string {
158 return origin.teammate ?? origin.kind
159}
160
161export function micros(usd: number | undefined): number | undefined {
162 return usd === undefined ? undefined : Math.round(usd * 1_000_000)
163}
164
165export type Artifact = {
166 /** A published page, a file written outside the code, a picture looked at, or a Claude Docs document. */
167 form: 'page' | 'file' | 'image' | 'doc'
168 /** The address a page or doc opens at, or the file's path. */
169 target: string
170 title?: string
171}
172
173// Written, and not code: documents, pictures and prose, never configuration. A page's own source (`.html`) is left
174// out because the page appears once it is published, as a page.
175const DOCUMENT = /\.(md|mdx|txt|pdf|png|jpe?g|gif|webp|svg|csv|docx?|xlsx?|pptx?)$/i
176// Where a session keeps what it only needs on the way: its own settings and scratch space.
177const SCRATCH = /(^|\/)(node_modules|\.git|\.claude|\.armada|target|dist|build)\/|^\/(private\/)?(tmp|var\/folders)\//
178
179/** Whether a file a session wrote is something a person would want to open, and not code. */
180export function isDocument(path: string): boolean {
181 return DOCUMENT.test(path) && !SCRATCH.test(path)
182}
183
184// A picture a session looked at. Scratch is kept: a screenshot is usually read from `/tmp`.
185const PICTURE = /\.(png|jpe?g|gif|webp)$/i
186
187const CLAUDE_ADDRESS = /https:\/\/claude\.ai\/[^\s)"'<>\]]*artifact[^\s)"'<>\]]*/
188const DOCS = /^mcp__claude_ai_Claude_Docs__(create|batch|update)$/
189
190const nameOf = (path: string) => path.slice(path.lastIndexOf('/') + 1).replace(/\.[^.]+$/, '')
191
192/**
193 * What a tool call made, or looked at, that a person would open. **A code edit is never one**: only a page
194 * published, a new document written, and a Claude Docs document created or edited.
195 * `created` is whether a Write made a file that was not there.
196 */
197export function artifactOf(
198 tool: string,
199 input: Record<string, unknown>,
200 text: string,
201 created: boolean,
202): Artifact | undefined {
203 if (tool === 'Artifact') {
204 if (input.asset === true || (input.action !== undefined && input.action !== 'publish')) return undefined
205 const target = typeof input.url === 'string' ? input.url : CLAUDE_ADDRESS.exec(text)?.[0]
206 if (target === undefined) return undefined
207 const title =
208 typeof input.title === 'string' ? input.title : typeof input.file_path === 'string' ? nameOf(input.file_path) : undefined
209 return { form: 'page', target, title }
210 }
211 if (tool === 'Write') {
212 const path = input.file_path
213 if (!created || typeof path !== 'string' || !isDocument(path)) return undefined
214 return { form: 'file', target: path, title: path.slice(path.lastIndexOf('/') + 1) }
215 }
216 if (tool === 'Read') {
217 const path = input.file_path
218 if (typeof path !== 'string' || !PICTURE.test(path)) return undefined
219 return { form: 'image', target: path, title: path.slice(path.lastIndexOf('/') + 1) }
220 }
221 if (DOCS.test(tool)) {
222 const container = input.container as { id?: unknown; create?: { name?: unknown } } | undefined
223 const id = typeof container?.id === 'string' ? container.id : undefined
224 const target = CLAUDE_ADDRESS.exec(text)?.[0] ?? (id === undefined ? undefined : `https://claude.ai/artifact/${id}`)
225 if (target === undefined) return undefined
226 const name = container?.create?.name
227 return { form: 'doc', target, title: typeof name === 'string' ? name : undefined }
228 }
229 return undefined
230}
231
232const MODELS = ['haiku', 'sonnet', 'opus']
233
234/** A model by the short name the person picks it by, and by its own id where it has no short one. */
235export function modelName(id: string): string {
236 return MODELS.find(one => id.includes(one)) ?? id
237}
238
239export type Mode = 'ask' | 'auto' | 'accept_edits' | 'plan'
240
241/** The terminal's permission mode in Armada's words, or nothing for one Armada has no word for. */
242export function modeOf(mode: string | undefined): Mode | undefined {
243 switch (mode) {
244 case 'default':
245 return 'ask'
246 case 'auto':
247 return 'auto'
248 case 'acceptEdits':
249 return 'accept_edits'
250 case 'plan':
251 return 'plan'
252 default:
253 return undefined
254 }
255}
256
257/** An effort level, whether the hook input carries the word or an object around it. */
258export function effortOf(effort: unknown): string | undefined {
259 if (typeof effort === 'string') return effort
260 const level = (effort as { level?: unknown } | null | undefined)?.level
261 return typeof level === 'string' ? level : undefined
262}
263hooks/fleet.ts 53 lines1// What Fleet's intake takes: `crates/ipc/src/sessions.rs`, `SessionReport`. Only
2// shapes here; the code that sends them is in `register.ts`, since the engine
3// follows `$` into one file only.
4
5import type { Engine } from 'claude-code'
6
7export type Door = Pick<Engine, 'clock' | 'env' | 'fs' | 'http'>
8
9export type Report = {
10 harness: string
11 session_id: string
12 fact: Fact
13}
14
15/** What Fleet answers a terminal question with (`TerminalAsked`, `crates/ipc/src/hosted_sessions.rs`). */
16export type Asked =
17 | { outcome: 'asked'; call: string }
18 | { outcome: 'waiting' }
19 | { outcome: 'answered'; updated_input: unknown }
20 | { outcome: 'refused'; message: string }
21 | { outcome: 'gone' }
22
23export type Usage = {
24 context_tokens?: number
25 context_window?: number
26 cost_micros?: number
27}
28
29export type Fact =
30 | { kind: 'started'; cwd: string; title?: string; origin: 'terminal'; mod_version?: string }
31 | { kind: 'titled'; title: string; named?: boolean }
32 | { kind: 'moved'; cwd: string }
33 | {
34 kind: 'attached'
35 attachment: { kind: string; target: string; detail?: Record<string, string> }
36 }
37 | {
38 kind: 'settled'
39 attachment: { kind: string; target: string }
40 state: 'spent' | 'given_back'
41 }
42 | { kind: 'measured'; usage: Usage }
43 | {
44 kind: 'tuned'
45 model?: string
46 effort?: string
47 mode?: 'ask' | 'auto' | 'accept_edits' | 'plan'
48 commands?: { name: string; says: string }[]
49 mod_version?: string
50 }
51 | { kind: 'turn_completed' }
52 | { kind: 'ended'; reason: string }
53