SLOPSHOPPER

armada

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

newguardpromptprocessnetworktimer
★ 1v0.3.6Apache-2.0updated 2026-10-09Mele-Labs/armada/plugins/armada
A shopper browsing a rack in a slop shop
README

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


Status

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 coversReads 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:

ClaimEvidence
A Job runs end to end — worktree, agent, Checks, commit, rebase, push, pull requestSeveral have. One document here was written that way
A Judge can refuse a step whose Checks all passedIt 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 configThe common patterns, mechanically, with no model call
A step editing outside what it declared is caughtThe same way
An agent that loops without converging is stoppedTold to report first, then stopped
The machinery is provedThe acceptance test, hermetic — no process, no repository, no network
The merge is provedA 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.

What it is

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:

  • An agent cannot mark its own work complete. Evidence goes through a tool, a mechanical check decides, and a Judge can refuse a step whose checks passed.
  • An agent cannot reach the network. Every Job works in its own git worktree on its own branch, in an environment built from nothing that carries no credential. Armada pushes on its behalf once the checks pass, and opens a pull request for a person to merge.
  • An agent inherits no MCP server. Without that rule a spawned agent comes up holding every server the operator has connected — measured at seven servers, ninety-five tools, personal accounts — which is the defect that made the first attempt unusable.

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.

How it is put together

Two processes that ship as a pair and version together.

FleetRust daemonOwns Jobs, spawns and supervises agents, runs checks, writes the record. Runs detached — quitting the app does not kill work in flight
BridgeElectron appThe 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.

Building it

You need macOS and four toolchains that do not come with it.

ToolVersionWhere it comes from
Command Line Toolsthe one matching your macOSxcode-select --install, and Software Update after a macOS upgrade
Rust1.90 or laterrustup
Node24 — the version in .nvmrcnvm, then nvm use
pnpmthe version in package.jsoncorepack 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.

The gate

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.

CommandChecks
cargo xtask verify-foundationsEvery foundation rule. Red is a legitimate state
cargo xtask verify-tokensDesign token outputs match the CSS they are generated from
cargo xtask verify-docsOpen questions are collected, and every citation resolves
cargo xtask verify-roadmapCapabilities and their issues agree. Needs gh and a network, so not part of the gate

Some of what the gate refuses:

  • Untyped JSON outside the two crates allowed to parse it
  • A vendor's name outside the adapter layer, or a design value that is not a token
  • A file over 900 lines
  • A component stylesheet nothing imports — Storybook draws it, the app draws it unstyled
  • A domain registry row that disagrees with the machine it describes
  • A tool a Drone is offered and not allowed to call
  • A generated file that has drifted from the registry it came from
  • Anything that names a person or a machine

Tests

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.

Running it locally

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.

Roadmap

Work is tracked as GitHub issues, grouped two ways.

  • Milestones — each one carries a claim that is either true or not. M1 — Dogfood is "Armada does a small real task in the Armada repo, and I merge the branch it wrote."
  • Capabilities — what the system can do, written from the outside. A capability names the steps that make it real, so its progress is computed rather than reported.

Contributing

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.

Prior art, and a deleted first attempt

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

License

Apache 2.0.

Source 3 files
hooks/register.ts 705 lines
1// 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}
705
hooks/facts.ts 263 lines
1// 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}
263
hooks/fleet.ts 53 lines
1// 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