claude-fleet's in-session extension: version gate + heartbeat on the window's tmux options (#1335), task-progress band above the prompt (#1339), context /…

English | 简体中文
Use your AI subscriptions to move a small team's development tasks forward in parallel.
claude-fleet organizes Claude Code and Codex CLI sessions in tmux: one window and one isolated git worktree per task, GitHub Issues as the backlog, and PRs as the delivery path. Run features, fixes, tests and documentation alongside each other; use one dashboard to follow progress and handle blockers.
The core workflow uses the CLIs' subscription sign-in, with no separate model API key required. Optional Claude subscription account pools and quota-aware routing assign new work by available 5-hour / 7-day headroom and resume tasks on another account when needed. Codex shares the task workflow; its account rotation and quota management are not wired yet. See the agent capability table.
Built from daily use on an always-on Mac mini. A local transcript audit found a historical peak of 25 Claude main sessions processing tasks in parallel, across 25 independent worktrees, on September 1, 2026. A separate interval held at least 15 concurrent task directories for 14.46 minutes. These count overlapping CLI processing turns, not simultaneous server-side token generation or a throughput multiplier. Evidence and counting method.
<sub>Screenshots are the real UI captured from a live tmux server, staged with demo repo data.</sub>
/loop waits between iterations, green ✓ when a turn finishes, red ! + bell when a session is blocked on your answer. No polling lag — colors flip on the hook, not on the status-interval timer.FLEET_DASH_WINDOW=1): an fzf panel listing every session with state glyph, bound issue, model, and context %. It lives as an embedded pane in the plan hub, which holds the dash and nothing else; the task list replaced it by default (#1533), and its prefix+g / F9 keys left the node with the rest of the person's keys (#1714). Enter jumps. The prompt line at the bottom is the quick-scratch box: type a name and press Enter — it spawns a scratch session (own writable scratch-N worktree, no issue) named after that text, with the full text prefilled in the first input as an unsent, editable draft. Chinese and spaces are fine (the window title is capped at 24 columns — 12 CJK glyphs; the draft is not clipped). The prompt label is the fleet's default agent for a new session (claude ▸ / codex ▸); Ctrl-V flips it, persisted to the fleet's conf — that key (or the config modal) is how you pick the agent; typed text supplies the name and draft. Ctrl-N is the issue-bound path: it files a GitHub issue and spawns a worker session bound to it. (Every dash Ctrl- key is checked against your tmux prefix at launch and moved to its Alt- twin when it collides — tmux would eat it otherwise; ? shows the real key.) Ctrl-S opens the same raw scratch under its auto scratch-N name (plain claude, no issue — but in its own writable scratch-N worktree, so an experiment can push a branch and open a PR like any worker). Once a scratch has talked its way to a real requirement it can become the worker for it, in place: filing with fleet-issue-file.sh --title "…" --bind (or fleet-bind.sh <N> for an existing issue) renames its branch scratch-N → issue-N, binds the window and claims the issue — no second session re-grounding from zero (#520). Set FLEET_SCRATCH_POOL=1 to keep one pre-started and ready: ⌃s then hands you a session you can type into immediately (0.46s to the window, 0.30s to the first keystroke) instead of one that spends ~7s booting — the last second of which paints a ❯ box that silently swallows whatever you type.bin/tmux-issues.sh; its prefix+b door left the node with #1714 — #1739 decides the client's): open issues grouped by milestone (roadmap | unplanned panes). Enter on an issue creates a worktree issue-<N> off your base branch and starts claude seeded to read, claim, and implement it. Issues being worked show ▶ <window>. Manage issues without leaving tmux: the modal is list-only by default, and Space toggles a preview pane showing the highlighted issue's body, labels, milestone, assignees, and recent comments — word-wrapped to the pane so nothing splits mid-word. / turns on type-to-filter; Ctrl-X closes (triages) an issue after a y/n confirm; Ctrl-O opens it on the web. Priority shows as a p0/p1/p2 tag on each row and orders issues within a milestone; Ctrl-Y cycles a highlighted issue's priority (none→p2→p1→p0). Ctrl-N files a one-line issue fast.ccquota), use account-wide 5h/7d readings to warn before a limit, rotate early, stagger window starts, and optionally pause autofill. See subscription and quota management.cw <branch> spawns a worktree + Claude window; an hourly janitor removes worktrees that are merged + clean + not attached to any live pane (and never anything else).conf/statusline.sh): wired as a measurement bus, not a visible line (issue #1452) — it prints nothing and stamps the context %, window size, model and effort level (plus the account's rate limits) onto the pane's tmux window, where the pane header shows 剩余 62% · Opus 5.5 · high on its right and the auto-handoff nudge reads the %. Opt-in at install time by pointing settings.json's statusLine at the live-install path, so it improves through land → /fleet-sync-install; jq-gated (inert without it). Never auto-wired. The fleet mod feeds the same script from inside the session (statusline.sh --from mod, issue #1459), so once every Claude window carries the mod the key can go — bin/fleet-statusline.sh off removes it (and the blank bottom row Claude Code keeps for any statusLine), refusing while a window would be left blind.Claude Code hooks (PreToolUse/PostToolUse/Stop/Notification)
│ instant, semantic-blind
▼
@claude_state on the tmux window ──► spinner daemon (0.12s frames, single
▲ writer, change-detected) ──► dash glyphs
│ slow, semantic + needs tally
LLM classifier (haiku, ~5min, change-gated)
collector daemon (60s) ──► cache files ──► fzf dashboard / backlog panels
git · gh PRs+issues · (read-only producers, render instantly)
ctx tokens · usage proxy
Design rules that made it work:
/loop between iterations). Both write the same @claude_state.tmux source-file per frame = one repaint); a single collector owns every cache file; producers are read-only.Deeper reference: docs/TERMS.md defines every term (what the collector/hub/dash actually are), docs/ARCHITECTURE.md covers the shared-vs-per-fleet split and the path to running many fleets on one machine (one tmux session per repo), and docs/STATE.md traces how each window's Claude state (working/done/needs/looping) is set, rendered, and corrected. docs/EMIT.md covers the optional, off-by-default emitter that POSTs session lifecycle facts (session → issue → PR) so an external ledger can join what a week of agent work cost to what it produced — including the exact list of what does, and does not, leave the machine.
The installer is Claude itself — docs/INSTALL.md is the playbook:
git clone https://github.com/verkyyi/claude-fleet.git
cd claude-fleet
claude "install claude-fleet on this machine"
Claude will check dependencies, copy the scripts to ~/.claude/fleet/, write your fleet.conf (backlog repo, main checkout, base branch), append one source line to ~/.tmux.conf, merge five hook entries into ~/.claude/settings.json, install the daemons (launchd on macOS, the systemd/ user units on Linux), and verify each piece — asking before it touches anything.
Prefer manual? Every step is in docs/INSTALL.md; the pieces are plain shell scripts with no hidden state.
Running it on an unattended machine (a Mac mini you reach over SSH)? Read docs/HOST.md — the host setup that keeps the machine busy only with its sessions: turning off Spotlight, the unattended-Mac checklist (never sleep, no Siri, no iCloud sync, no GUI apps on the console) and how much a container VM may take. fleet-doctor's host section checks each item; fleet-host-tune.sh plans them all (--apply asks per item).
The fleet's slash commands, the base skills/ tree and the hook table also install as a Claude Code plugin, served by a marketplace in this same repo:
claude plugin marketplace add verkyyi/claude-fleet
claude plugin install fleet@claude-fleet --scope user --yes
That replaces the copy-and-merge passes above for those three, and /plugin update fleet keeps them current — so on a second machine, or a teammate's, nobody has to remember /fleet-sync-install for them. Plugin commands are namespaced (/fleet:fleet-claim); the fleet detects which install path a machine has and seeds the form that resolves, so spawns work either way. Codex does not consume Claude plugin commands directly; /fleet-sync-install generates native Codex skills from commands/*.md under each known $CODEX_HOME/skills/<name>/SKILL.md, so the same worker seed becomes $fleet-claim there.
It does not replace the playbook: bin/, conf/, the tmux layer and the daemons are machine-level and stay at ~/.claude/fleet. A plugin's install path is version-scoped and moves on every update, so nothing with a stable absolute path — a launchd unit, a tmux bind, a hook command — can point into it.
tmux ≥ 3.2 · fzf ≥ 0.45 (the dashboard binds use transform) · gh (authed) · python3 · Claude Code (the claude CLI; also used by the two optional LLM daemons). Soft: perl Time::HiRes (sharper dash spinner).
Run bin/fleet-doctor.sh to check all of these at once. Its install lines also answer "is this machine's live install current?" — the ~/.claude/fleet half is a hand-run git pull per machine, so it goes stale in silence (see bin/fleet-install-version.sh) — and "is each login still following refs/tags/stable on its own, and if not, why?" (see bin/fleet-install-follow.sh). (No standalone jq for the core — the collector only uses gh --jq, which is built in; jq is a soft dep only for the optional conf/statusline.sh status line, which exits silently without it.)
Every key below is the client's — fleet on your own computer (conf/tmux-shell.conf, its own tmux server -L fleet-shell). A node's fleet session binds none of them (issue #1714, EPIC #1710): it carries the execution sessions only, and the client looks at it through a view session with its prefix and status line off. Attaching to a node session directly still works — tmux's stock keys and one line at the top saying to use fleet.
| Key | Action |
|---|---|
| tap / right-click | the task list takes no keys (issue #1950): a tap on a row switches to it, a second tap or a right-click opens its menu, a tap on ▸ / ▾ — or anywhere left of the row's name — folds (issue #2167), and ⌘. / prefix . folds the session in view from wherever the keyboard is; a rename, an answer or a message is asked on one line under the session (↵ ok · esc cancel) |
prefix q / prefix h | back to the machine you were on (the previous window) |
prefix z | zoom the session pane — from the task list too: it zooms the session and hands the keyboard back, never the list |
prefix [ | scroll back the session (tmux copy-mode) — from the task list too |
prefix ? | keymap cheatsheet — a popup listing every fleet shortcut, each with a one-line description; q/esc closes it |
F9 | (no prefix) zoom the session pane on the right — this computer's pane; the key never reaches the far end |
What the node used to bind and where it went (#1714): prefix a (next needs window) and the ● N badge — the task list orders by who waits on you; prefix e — the client's list is always on; prefix b / c / u / ! (backlog, config, usage, alerts popups) and the bar's ☰ / ✖ ▲ taps — retired with the node's bar (#1739 decides what the client gets); prefix g / Space / E / z / [ / ? / F9 — the client's, above.
Worker and scratch windows show a 30-column task list on the left on wide screens. It shares the hub's live statuses, pins and parent/child grouping, highlights the current worker, and keeps that worker visible even in a folded group. Rows use your task descriptions, without internal worker IDs or a second title row inside the sidebar. The current task has a ▶ marker. Click the sidebar (or press prefix E) to give it the arrow keys: the selection shows keyboard focus (the list's border carries no label — issue #2167). ↑↓ (and Home/End) switch to the highlighted task without Enter, once the highlight has rested for about a quarter second: a held key is one switch, not one per row, and a row you only passed over is never selected — nor woken, since a sleeping worker resumes only after the view has stayed on it for two seconds. The sidebar keeps the arrow keys after each switch, and a click switches the same way. Click the worker, or press Enter/Esc, to return keyboard input to the worker; its top border's WORKER label then turns blue. The words on a pane's top line never change with focus — only the colour moves. Clicking the top border itself requires tmux 3.7 or newer; on older versions, click inside the sidebar or use prefix E to focus it. FLEET_SIDEBAR_WIDTH sets the width (24–60) — the floor: the list widens to its longest row up to FLEET_SIDEBAR_WIDTH_MAX (44; set it to the width to pin the list), and a drag of the divider sets the width from then on (the session's @sidebar_width_manual; tmux set -u -t <session>: @sidebar_width_manual goes back to auto). Whichever applies is held: when a window takes a narrower client's size tmux scales every pane, and the list snaps back on the spot rather than sitting where the scale left it. Below sidebar width + 81 columns (111 by default), the list hides automatically to leave 80 columns for the worker, then returns when space permits. prefix z still zooms the worker for focused work. Only the visible worker owns a sidebar; background windows and detached fleets do not run sidebar refresh loops. The full hub list also hides worker IDs and gives that space to task descriptions.
The status bar is the client's too (conf/tmux-shell.conf → bin/tmux-status.sh): fleet on the left; the right side draws only what wants your hand, and is empty while all is well — the machine of a session on another machine, the current account once 5h or week reaches FLEET_STATUS_QUOTA_PCT (80 %), ⚠ GitHub 受限, the alert counts (✖ N alarms, ▲ N warnings, from bin/fleet-alerts.sh) and ○ 入口 Nm when the hub has gone silent. On a 54-column iPad / iPhone in portrait the whole bar stays within 30 columns in every state. A node's own status line is one static hint at the top (conf/tmux-bar.conf), which only a direct attach ever sees.
To zoom a pane fullscreen, double-click it (or its border), prefix z, or F9 — except the session with the task list on screen, where a double-click selects a word as in stock tmux (zoom it with prefix z, F9 or the divider).
There is no other-fleet cue and no fleet switching: one fleet per login holds every repo you work on (EPIC #977), so the red ● is the one needs signal and the dash lists every repo at once, grouped under a heading per repo. Several fleets on one machine means several logins, each with its own.
conf/tmux-attention.conf also carries an opinionated fleet baseline the UX assumes so a clean install behaves consistently: mouse on (the clickable footer + dashboard mouse), truecolor (default-terminal + a Tc terminal-overrides so the theme's hex colors render), escape-time 10 (snappy ESC in the Claude TUI), history-limit 50000, allow-rename/automatic-rename off (the fleet navigates by explicit window names), and the Tokyo-Night status / pane / message theme. Every line is documented inline and easy to override — put your own settings in ~/.tmux.conf after the source-file line (later wins) or comment the baseline out. Truly personal bits (prefix remaps, personal binds) are intentionally left in your ~/.tmux.conf.
One file, ~/.claude/fleet/fleet.conf (see fleet.conf.example):
FLEET_REPO="you/your-repo" # backlog + PR/CI source
FLEET_MAIN="$HOME/projects/your-repo" # worktrees are created as its siblings
FLEET_BASE_BRANCH="main"
FLEET_PROTECTED_RE="^(master|main|develop|test)$"
FLEET_CTX_WINDOW=200000 # 1000000 if you run 1M-context models
FLEET_GLOBAL_MAX_SESSIONS=0 # optional hard cap on live sessions; 0 (default) = the machine's admission decides
FLEET_AGENT="claude" # or "codex" — see the capability matrix below
A fleet ≡ a tmux session on its own tmux server, and a login runs exactly one (issues #977/#979/#980). Every repo that login works on lives in that one fleet — add them with bin/fleet-repo.sh add — so moving between repos is the dash's grouped list, never a switch between fleets. There is no fleet picker.
Several fleets on one machine means several logins. Each login has its own ~/.config/claude-fleet/, its own fleet and its own tmux socket, so a crash in one never touches another. They share one collector without clobbering each other (see docs/ARCHITECTURE.md). A repo you want isolated from the rest goes to a second login. A login that ended up with two fleets folds one into the other with bin/fleet-repo.sh fold (below).
fleet # open the client — the one way in (on this machine, or through the hub)
bin/fleet-up.sh you/webapp # first repo: clone-or-reuse ~/projects/webapp, bring the fleet up
bin/fleet-repo.sh add you/infra ~/src/infra # another repo in the same fleet (explicit checkout dir) — or ⌃z on the dash
bin/fleet-list.sh # ● live / ○ down · name · repo · checkout (+ ↳ each further repo)
bin/fleet-down.sh fleet # kill the fleet after you type its name; `fleet up --undo` brings it back
bin/fleet-down.sh fleet --yes --purge # from a script, and drop its conf/cache too; checkouts stay
On SSH login, shell/fleet-intro.sh prints a short, phone-width banner: this login's fleet and its repo count, the fleet line to get in, and any machine-local intro.d lines — and then opens the client: an interactive SSH login runs bin/fleet, the same client you run on your own computer, here reading this machine (its bar says 客户端在 <机器> 上运行) — never a direct attach to the node's own session (issue #1711). scp / rsync / ssh host cmd are never touched; ~/.hushfleet-attach keeps the banner only. See docs/INSTALL.md step 7 for the one ~/.zshrc line, source ~/.claude/fleet/shell/fleet-login.zsh.
Several repos in one fleet. From inside the fleet, ⌃z on the dash or the task sidebar's row menu (.) item g + 仓库… opens a popup that asks just owner/name (a GitHub URL is fine) and adds it — bin/dash-repo-add.sh, the same script behind both, issue #1103 — so on an iPad nothing leaves the screen: the checkout is ~/projects/<name> (reused if it already is that repo, cloned if missing, the clone's progress in the popup), the verdict stays up until you dismiss it, and the new repo's heading is on the dash's next frame — no restart. The shell form, bin/fleet-repo.sh add you/infra [<checkout>], is the same registration (clone-or-reuse, like fleet-up.sh) and the one that takes a different checkout dir; list shows what it hosts and remove drops one. The rest follows on its own, as it does for the first repo: a warning if Claude Code has not trusted the checkout, and the background daemons woken so the dash picks the repo up within a tick; fleet-doctor.sh's repos row lists every hosted repo and whether it is healthy. All hosted repos are equal — there is no main repo. Once a fleet hosts two:
@repo), shown on the dash as a repo heading / short-tag badge (window names stay bare: issue-12);tokenledger (0); a no-repo row (or nothing to go on) starts it in $HOME, where the hub opens too;**Folding a fleet
hooks/register.ts 39 lines1// claude-fleet's in-session extension (issue #1335, EPIC #1334).
2//
3// This file only ASSEMBLES: one `register<Feature>(on)` line per feature file.
4// lifecycle.ts owns session.start / session.end (the version gate, the
5// heartbeat, and every feature's start-up work); gate.ts says how a feature's
6// own hooks stand behind the gate. Rules every feature keeps (EPIC #1334):
7// tmux window options stay the one state store (tmux.ts); no network, no gh —
8// local files via $.fs, the outside world via `$.process.run` of tmux and the
9// fleet's own scripts; every hook has a `.catch` that hands the event on, so a
10// broken mod never holds up a session.
11//
12// Loaded by bin/fleet-claude.sh (`--plugin-dir`) when FLEET_MOD is on (the
13// default). FLEET_MOD=0, or a Claude Code outside SUPPORTED, and every session
14// runs exactly as it did before the mod existed.
15
16import type { Register } from 'claude-code'
17
18import { registerCompose } from './compose'
19import { registerExitGuard } from './exit-guard'
20import { registerLifecycle } from './lifecycle'
21import { registerProgress } from './progress'
22import { registerQuickDispatch } from './qd'
23import { registerQueue } from './queue'
24import { registerState } from './state'
25import { registerTools } from './tools'
26import { registerUsage } from './usage'
27
28export const register: Register = on => {
29 registerLifecycle(on)
30 registerUsage(on)
31 registerState(on)
32 registerProgress(on)
33 registerCompose(on)
34 registerTools(on)
35 registerExitGuard(on)
36 registerQuickDispatch(on)
37 registerQueue(on)
38}
39hooks/compose.ts 30 lines1// The mod's sections of the system prompt — ONE `prompt.compose` hook (the
2// engine takes one unmatched hook per event per plugin). Each feature keeps its
3// own text and state; this hook only puts them after the engine's own:
4//
5// fleet:orchestrator-role the orchestrator's role (orchestrator.ts, #2582)
6// fleet:where where the person is, always last (where.ts, #1716)
7//
8// Nothing to add (gate shut, no role, no line yet) passes the engine's through.
9
10import type { On, PromptComposeSection } from 'claude-code'
11
12import { isOpen } from './gate'
13import { ROLE_SECTION, currentRole, roleSection } from './orchestrator'
14import { WHERE_SECTION, currentWhere, whereSection } from './where'
15
16export function registerCompose(on: On): void {
17 on('prompt.compose', async ($, e, next) => {
18 const r = await next(e)
19 if (!isOpen()) return r
20 const role = currentRole()
21 const where = currentWhere()
22 const ours: PromptComposeSection[] = []
23 if (role !== undefined) ours.push(roleSection(role))
24 if (where !== undefined) ours.push(whereSection(where))
25 if (ours.length === 0) return r
26 const ids = new Set([ROLE_SECTION, WHERE_SECTION])
27 return { sections: [...r.sections.filter(s => !ids.has(s.id)), ...ours] }
28 }).catch(($, e, next) => next(e))
29}
30hooks/exit-guard.ts 105 lines1// A slip of the hand must not end the orchestrator (issue #2584, EPIC #2581 C3).
2//
3// The fleet's one orchestrating session carries the whole conversation the
4// person had with it; one stray /exit or /clear used to throw that away. In the
5// orchestrator's window (orchestrator.ts's start-up read of `@fleet_role`) the
6// first /exit or /clear does not run: its answer is one line saying what this
7// session is, that ⌃D puts it in the background, and how to confirm — the same
8// command again within CONFIRM_MS, or the `!` form (`/exit!`, `/clear!`; typed
9// with a space, `/exit !`, it is the command's argument and confirms the same).
10//
11// The engine resolves aliases before `command.run` (/quit → exit, /reset and
12// /new → clear), so the two names cover them. `/exit!` is not a command: it
13// arrives at `prompt.submit` as text, and the hook there drops it and runs the
14// real command through `$.command.run` a moment later (the engine refuses a run
15// from inside that hook) — a plugin's run (this one, the command
16// inbox's /clear or /exit) is never guarded. Only a person's hand is: the
17// composer and the Remote Control bridge. Any other window, or a shut gate,
18// passes untouched.
19
20import type { On, PromptOrigin } from 'claude-code'
21
22import { isOpen } from './gate'
23import { isOrchestrator } from './orchestrator'
24
25export const GUARDED = ['exit', 'clear'] as const
26export type Guarded = (typeof GUARDED)[number]
27
28/** How long a hinted command stays armed: the second one inside it runs. */
29export const CONFIRM_MS = 60_000
30
31/** `/exit!`'s real run waits this long, past the prompt.submit that carried it. */
32export const DEFER_MS = 50
33
34let armed: { command: Guarded; at: number } | undefined
35
36export function isGuarded(command: string): command is Guarded {
37 return (GUARDED as readonly string[]).includes(command)
38}
39
40/** The `!` form typed as a prompt: exactly `/<command>!`, an alias's too. */
41export function confirmForm(text: string): Guarded | undefined {
42 const m = /^\/(exit|quit|clear|reset|new)!$/.exec(text.trim())
43 if (m === null) return undefined
44 return m[1] === 'clear' || m[1] === 'reset' || m[1] === 'new' ? 'clear' : 'exit'
45}
46
47export function hint(command: Guarded): string {
48 const what = command === 'exit' ? '结束它' : '清空它的对话'
49 return [
50 `这是编排会话:/${command} 会${what},之后要重新交代一切。`,
51 `只想离开:⌃D 放到后台,它继续在。`,
52 `确实要${command === 'exit' ? '退出' : '清空'}:${CONFIRM_MS / 1000} 秒内再输一次 /${command},或输 /${command}!`,
53 ].join('\n')
54}
55
56/** Typed by a person, not run by a plugin, a peer or a schedule. */
57export function byHand(origin: PromptOrigin): boolean {
58 return origin.kind === 'composer' || origin.kind === 'bridge'
59}
60
61/**
62 * One guarded command typed at `now`: `run` when this window is not the
63 * orchestrator's or the same command was hinted within CONFIRM_MS (the arm is
64 * spent), else `hint` (and the arm is set).
65 */
66export function decide(command: Guarded, now: number): 'run' | 'hint' {
67 if (!isOrchestrator()) return 'run'
68 if (armed !== undefined && armed.command === command && now - armed.at <= CONFIRM_MS) {
69 armed = undefined
70 return 'run'
71 }
72 armed = { command, at: now }
73 return 'hint'
74}
75
76/** Tests: forget the arm. */
77export function resetGuard(): void {
78 armed = undefined
79}
80
81export function registerExitGuard(on: On): void {
82 on('command.run', async ($, e, next) => {
83 if (!isOpen() || !isGuarded(e.command) || !byHand(e.origin)) return next(e)
84 if (e.args.trim() === '!' && isOrchestrator()) {
85 resetGuard()
86 return next({ ...e, args: '' })
87 }
88 if (decide(e.command, await $.clock.now()) === 'run') return next(e)
89 return { text: hint(e.command) }
90 }).catch(($, e, next) => next(e))
91
92 on('prompt.submit', async ($, e, next) => {
93 const command = isOpen() && isOrchestrator() && byHand(e.origin) ? confirmForm(e.text) : undefined
94 if (command === undefined) return next(e)
95 resetGuard()
96 // The engine refuses a run from inside prompt.submit (it would wait on the
97 // turn this hook holds), so it goes from a one-shot timer just after.
98 const once = $.clock.every(DEFER_MS, () => {
99 once.cancel()
100 void $.command.run({ command, args: '' }).catch(() => undefined)
101 })
102 return { drop: `/${command}! → /${command}` }
103 }).catch(($, e, next) => next(e))
104}
105hooks/lifecycle.ts 296 lines1// The session's lifecycle: the version gate and the heartbeat (issue #1335).
2//
3// The engine takes ONE unmatched `session.start` (and `session.end`) hook per
4// plugin, and follows `$` only within the file it is spelled in — so start-up
5// work for every feature lives in THIS file's start hook, behind the gate.
6// A feature that needs to start something (a timer, a first write) adds it
7// to `onReady` below; its other hooks live in its own file and start with
8// `if (!isOpen()) return next(e)` (gate.ts).
9//
10// Gate: out of SUPPORTED (version.ts), nothing past the gate runs and the
11// window says `@mod_state off:version`; in range, `@mod_state on`.
12//
13// Heartbeat: while the session lives, its window carries `@mod_alive <epoch
14// seconds>`, rewritten every HEARTBEAT_MS. Bash reads it with `fleet_mod_alive
15// <win>` (bin/fleet-lib.sh): fresh within 45s = the mod is here, take the new
16// path; stale or missing = take today's path. The timer lives in the module,
17// not the session: a /clear ends the session (`session.end`, reason `clear`)
18// and fires no new `session.start`, but the module and its timers go on — so
19// the beat survives a handoff's /clear. A real exit unsets it at once.
20//
21// Inbox (issue #1337): the same module also polls the pane's command inbox every
22// INBOX_MS and runs what bash posted there with `$.command.run` (inbox.ts) — on a
23// module timer for the same reason: the pickup a handoff posts right after its
24// /clear must still be taken.
25//
26// Where (issue #1716): the same module reads bin/fleet-client-where.sh at the
27// start and every WHERE_POLL_MS; where.ts puts the line in the context.
28//
29// Orchestrator (issue #2582): the same start reads the window's @fleet_role and,
30// in the orchestrator's window, skills/fleet-orchestrate/role.md; orchestrator.ts
31// puts it in every request, so a /clear leaves the session its role.
32//
33// Quick dispatch (issue #2618): in the orchestrator's window the same start reads
34// the `qd_` strings and registers `/qd` (qd.tsx); any other window has no /qd.
35//
36// Queue (issue #2617): in the orchestrator's window the same start stamps
37// `@orch_queue 0` (queue.ts counts from there), and a real exit unsets it.
38//
39// Tools (issue #2057): a session the launcher gave no fleet tool service (no
40// FLEET_MCP_SERVER=1 — launched before #1828) gets the mod's three fallback tools
41// at the start, from the service's own specs (tools.ts); a served session gets
42// none — status / spawn / await are the service's there.
43
44import { atom, update } from 'claude-code'
45import type { EngineInterface, On, Timer, ToolSpec } from 'claude-code'
46
47import type { FleetModStatus } from '../types'
48import { isOpen, openGate } from './gate'
49import { INBOX_MS, inboxDir, pollInbox } from './inbox'
50import { isOrchestrator, roleArgv, rolePath, takeRole } from './orchestrator'
51import { qdCommand, stringsArgv, takeStrings } from './qd'
52import { QUEUE_OPTION } from './queue'
53import type { InboxIo } from './inbox'
54import { TMUX_TIMEOUT_MS, windowOptionsArgv } from './tmux'
55import { SPEC_TIMEOUT_MS, binDir, fallbackSpecs, specArgv } from './tools'
56import { MODEL_POLL_MS, STATUSLINE_TIMEOUT_MS, claimModelFeed, feedArgv, modelMoved, resetModelFeed } from './usage'
57import { MOD_VERSION, isSupported } from './version'
58import { WHERE_POLL_MS, WHERE_TIMEOUT_MS, takeWhere, whereArgv } from './where'
59
60export const HEARTBEAT_MS = 15_000
61
62/** Exits that end the process (a `clear` or `resume` keeps it beating). */
63const EXITS = new Set(['prompt_input_exit', 'logout', 'other'])
64
65const status = atom({ plugin: 'fleet', key: 'status' } as const, null as FleetModStatus | null)
66
67// Module state: a reload is a fresh module, and session.start fires again.
68let timer: Timer | undefined
69let inboxTimer: Timer | undefined
70let modelTimer: Timer | undefined
71let whereTimer: Timer | undefined
72let whereBusy = false
73let inboxBusy = false
74let pane: string | undefined
75let inbox: string | undefined
76
77async function setOptions($: EngineInterface, options: Record<string, string | null>): Promise<void> {
78 if (pane === undefined) return
79 const argv = windowOptionsArgv(pane, options)
80 if (argv !== null) await $.process.run(argv, { timeoutMs: TMUX_TIMEOUT_MS })
81}
82
83async function beat($: EngineInterface): Promise<void> {
84 try {
85 await setOptions($, { '@mod_alive': String(Math.floor((await $.clock.now()) / 1000)) })
86 } catch {
87 // A missed beat ages the option out; the bash side falls back on its own.
88 }
89}
90
91function inboxIo($: EngineInterface): InboxIo {
92 return {
93 list: async dir => (await $.fs.list(dir)).filter(f => f.kind === 'file').map(f => f.name),
94 read: path => $.fs.read(path),
95 write: (path, text) => $.fs.write(path, text),
96 claim: async (from, to) => {
97 const r = await $.process.run(['mv', from, to], { timeoutMs: TMUX_TIMEOUT_MS })
98 return r.exitCode === 0
99 },
100 run: async (command, args) => {
101 await $.command.run({ command, args })
102 },
103 }
104}
105
106// The model poll (usage.ts explains it; the timer and `$` live here because the
107// engine follows `$` only within one file): when the live model is not the one
108// last fed, feed it alone through conf/statusline.sh, so a `/model` with no turn
109// yet still flips @model within MODEL_POLL_MS (fleet-model-switch's verify).
110async function pollModel($: EngineInterface): Promise<void> {
111 if (pane === undefined) return
112 let fields: string[] | null = null
113 try {
114 const id = await $.session.model()
115 if (!modelMoved(id)) return
116 fields = claimModelFeed(id, undefined)
117 if (fields === null) return
118 await $.process.run(feedArgv($.plugin.root, fields), { timeoutMs: STATUSLINE_TIMEOUT_MS })
119 } catch {
120 if (fields !== null) resetModelFeed() // the next tick or turn feeds it again
121 }
122}
123
124// Where the person is (where.ts): one read at a time, a failed one keeps the line.
125async function pollWhere($: EngineInterface): Promise<void> {
126 // A session outside tmux is no fleet session: there is no one to place.
127 if (whereBusy || pane === undefined) return
128 whereBusy = true
129 try {
130 const r = await $.process.run(whereArgv($.plugin.root), { timeoutMs: WHERE_TIMEOUT_MS })
131 takeWhere(r.exitCode, r.stdout)
132 } catch {
133 // The next tick reads it again.
134 } finally {
135 whereBusy = false
136 }
137}
138
139// The orchestrator's role (orchestrator.ts): read once — a window's role does not
140// change under a running session. A read that fails adds nothing.
141async function readRole($: EngineInterface): Promise<void> {
142 if (pane === undefined) return
143 let windowRole = ''
144 try {
145 const r = await $.process.run(roleArgv(pane), { timeoutMs: TMUX_TIMEOUT_MS })
146 windowRole = r.exitCode === 0 ? r.stdout : ''
147 } catch {
148 takeRole('', undefined)
149 return
150 }
151 // A role file that cannot be read drops the section, never the window's role
152 // (exit-guard.ts and /qd ask isOrchestrator with or without it).
153 let text: string | undefined
154 if (windowRole.trim() === 'orchestrator') {
155 try {
156 text = await $.fs.read(rolePath($.plugin.root))
157 } catch {
158 text = undefined
159 }
160 }
161 takeRole(windowRole, text)
162}
163
164// Quick dispatch (qd.tsx): the orchestrator's window only — its strings, then the
165// command. A read or a register that fails costs /qd, never the session.
166async function registerQuickDispatchCommand($: EngineInterface): Promise<void> {
167 if (!isOrchestrator()) return
168 try {
169 const r = await $.process.run(stringsArgv($.plugin.root), { timeoutMs: TMUX_TIMEOUT_MS })
170 if (r.exitCode === 0) takeStrings(r.stdout)
171 } catch {
172 // Every key shows itself; the command still works.
173 }
174 try {
175 await $.command.register(qdCommand())
176 } catch {
177 // An engine without `immediate`, or a refused name: no /qd.
178 }
179}
180
181async function pollOnce($: EngineInterface): Promise<void> {
182 // One command at a time: a /compact can hold its run for a minute, and the
183 // next one waits its turn behind it rather than racing it.
184 if (inboxBusy || inbox === undefined) return
185 inboxBusy = true
186 try {
187 await pollInbox(inboxIo($), inbox)
188 } catch {
189 // A failed poll is retried on the next tick; the poster times out on its own.
190 } finally {
191 inboxBusy = false
192 }
193}
194
195// The fallback tools (tools.ts explains them; the run is here because the engine
196// follows `$` only within one file): the specs from the service itself; a spec
197// read that fails registers nothing (an old session's list already carries them),
198// and one tool that will not register costs that tool only, never the session.
199async function registerFallbackTools($: EngineInterface): Promise<void> {
200 let specs: ToolSpec[]
201 try {
202 const r = await $.process.run(specArgv(binDir($.plugin.root)), { timeoutMs: SPEC_TIMEOUT_MS })
203 if (r.exitCode !== 0) return
204 specs = fallbackSpecs(r.stdout)
205 } catch {
206 return
207 }
208 for (const spec of specs) {
209 try {
210 await $.tool.register(spec)
211 } catch {
212 // Already listed (a reload), or refused: this tool only.
213 }
214 }
215}
216
217/** Start-up work of every feature, run once the gate is open. */
218async function onReady($: EngineInterface): Promise<void> {
219 // The fallback tools (tools.ts, issue #2057) — only for a fleet session the
220 // launcher did not give the tool service: FLEET_MCP_SERVER=1 says it did (issue
221 // #1807), and then status / spawn / await are the service's and the mod
222 // registers nothing (#1812). Without it — a pane launched before #1828, whose
223 // tool list still carries the mod's three — register them from the service's
224 // own specs, so a reload of this code never leaves a listed tool unanswered.
225 if (pane !== undefined && (await $.env.get('FLEET_MCP_SERVER')) !== '1') await registerFallbackTools($)
226 await beat($)
227 timer?.cancel()
228 timer = $.clock.every(HEARTBEAT_MS, () => {
229 void beat($)
230 })
231 // The model on the bus from the first second, and a /model within ~2 s (#1459).
232 resetModelFeed()
233 await pollModel($)
234 modelTimer?.cancel()
235 modelTimer = $.clock.every(MODEL_POLL_MS, () => {
236 void pollModel($)
237 })
238 // The orchestrator's role, in the context from the first request (#2582).
239 await readRole($)
240 // /qd, the orchestrator's quick dispatch (#2618).
241 await registerQuickDispatchCommand($)
242 // What waits behind its turn starts at 0, so a counting orchestrator always says a number (#2617).
243 if (isOrchestrator()) await setOptions($, { [QUEUE_OPTION]: '0' }).catch(() => undefined)
244 // Where the person is, in the context from the first request (#1716).
245 await pollWhere($)
246 whereTimer?.cancel()
247 whereTimer = $.clock.every(WHERE_POLL_MS, () => {
248 void pollWhere($)
249 })
250 const home = (await $.env.get('HOME')) ?? ''
251 const conf = (await $.env.get('FLEET_CONF_DIR')) || `${home}/.config/claude-fleet`
252 inbox = inboxDir(conf, await $.env.get('TMUX'), pane)
253 inboxTimer?.cancel()
254 inboxTimer = inbox === undefined ? undefined : $.clock.every(INBOX_MS, () => {
255 void pollOnce($)
256 })
257}
258
259export function registerLifecycle(on: On): void {
260 on('session.start', async ($, e, next) => {
261 const { version } = await $.session.version()
262 const supported = isSupported(version)
263 const state = supported ? 'on' : 'off:version'
264 const id = await $.env.get('TMUX_PANE')
265 pane = id !== undefined && id !== '' ? id : undefined
266 await update($, status, () => ({ state, engine: version, mod: MOD_VERSION }))
267 await setOptions($, {
268 '@mod_state': state,
269 '@mod_ver': MOD_VERSION,
270 // Out of range never beats: clear one a previous load left behind.
271 ...(supported ? {} : { '@mod_alive': null }),
272 })
273 if (supported) {
274 openGate()
275 await onReady($)
276 if (e.isInteractive) $.ui.toast(`fleet 扩展已加载 · v${MOD_VERSION}`)
277 }
278 return next(e)
279 }).catch(($, e, next) => next(e))
280
281 on('session.end', async ($, e, next) => {
282 if (isOpen() && EXITS.has(e.reason)) {
283 timer?.cancel()
284 timer = undefined
285 inboxTimer?.cancel()
286 inboxTimer = undefined
287 modelTimer?.cancel()
288 modelTimer = undefined
289 whereTimer?.cancel()
290 whereTimer = undefined
291 await setOptions($, { '@mod_alive': null, ...(isOrchestrator() ? { [QUEUE_OPTION]: null } : {}) })
292 }
293 return next(e)
294 }).catch(($, e, next) => next(e))
295}
296hooks/progress.tsx 163 lines1// The task-progress band above the prompt (issue #1339, EPIC #1334 C5).
2//
3// One segment: this issue's PR + checks (`PR #N ✓/✗!/…`, issue #1527). Task,
4// EPIC, children and context % are the top-right corner's and the sidebar's,
5// so the band no longer repeats them; a window with no PR — the hub, a
6// scratch — draws nothing and the row takes no height. `!` means it needs you;
7// no sleep durations — the same reading as the sidebar (#1328).
8//
9// Data: every PROGRESS_MS, ONE tmux call (this window's options + one
10// list-windows of this fleet's own server) and `$.fs` reads of the dash's
11// local caches. No network, no gh, no writes anywhere: the band only reads.
12// A child newly at `!`, or this PR's checks turning red, toasts once; the
13// alert re-arms when it clears.
14//
15// In the orchestrator's window the same band carries what waits behind its
16// running turn (queue.ts, issue #2617): 「排队 N 条 · 在忙 X(已 M 秒)· /qd 直接派」
17// in yellow above the PR line, with a button that opens /qd (qd.tsx).
18//
19// The refresh timer starts from this file's own session.start hook, past the
20// version gate (`$` is followed only within one file, so it cannot start from
21// lifecycle.ts's onReady, and a render hook is pure — no state writes). While
22// the gate is shut nothing refreshes and the render hook passes through. A
23// /clear keeps the module, so the timer goes on; a reload starts it afresh.
24
25import { atom, read, update } from 'claude-code'
26import type { EngineInterface, On, Timer } from 'claude-code'
27
28import type { ProgressSnapshot } from '../types'
29import { isOpen } from './gate'
30import { isOrchestrator } from './orchestrator'
31import { QD_COMMAND, t } from './qd'
32import { QUEUE_IDLE, queueText } from './queue'
33import {
34 children, newAlerts, parseLedger, parseTmux, PROGRESS_MS, prFor, segments, selfKey, slug, tmuxArgv,
35} from './progress-model'
36import { TMUX_TIMEOUT_MS } from './tmux'
37
38const progress = atom({ plugin: 'fleet', key: 'progress' } as const, null as ProgressSnapshot | null)
39const alerts = atom({ plugin: 'fleet', key: 'alerts' } as const, [] as string[])
40/** queue.ts's count, drawn here: the same `fleet/queue` state (issue #2617). */
41const queue = atom({ plugin: 'fleet', key: 'queue' } as const, QUEUE_IDLE)
42
43// Module state: a reload is a fresh module, and session.start fires again.
44let timer: Timer | undefined
45
46async function readOr($: EngineInterface, path: string): Promise<string> {
47 try {
48 const text = await $.fs.read(path)
49 return typeof text === 'string' ? text : ''
50 } catch {
51 return ''
52 }
53}
54
55function join(dir: string, ...parts: string[]): string {
56 return [dir.replace(/\/+$/, ''), ...parts].join('/')
57}
58
59/** Read the caches, fold them, store the snapshot, toast what is new. */
60async function refreshProgress($: EngineInterface): Promise<void> {
61 const pane = await $.env.get('TMUX_PANE')
62 if (pane === undefined || pane === '') return
63 const run = await $.process.run(tmuxArgv(pane), { timeoutMs: TMUX_TIMEOUT_MS })
64 if (run.exitCode !== 0) return
65 const { self, windows } = parseTmux(run.stdout)
66 if (self === null) return
67
68 // No TMPDIR: this login's own fallback, never the /tmp every login shares (#2442).
69 let tmp = (await $.env.get('TMPDIR')) || ''
70 if (tmp === '') {
71 const id = await $.process.run(['id', '-u'], { timeoutMs: TMUX_TIMEOUT_MS })
72 tmp = `/tmp/claude-fleet-${id.stdout.trim()}`
73 }
74 const dash = join(tmp, '.claude-dash', 'fleets')
75 const home = (await $.env.get('HOME')) ?? ''
76 const conf = (await $.env.get('FLEET_CONF_DIR')) || join(home, '.config', 'claude-fleet')
77 const s = self.repo === '' ? '' : slug(self.repo)
78
79 // Children: the ledger is keyed `<slug>:<key>` in a multi-repo fleet, bare otherwise.
80 const key = selfKey(self)
81 let ledgerText = ''
82 if (key !== '') {
83 const dir = join(conf, 'fleets', self.session, 'children')
84 if (s !== '') ledgerText = await readOr($, join(dir, `${s}:${key}.ndjson`))
85 if (ledgerText === '') ledgerText = await readOr($, join(dir, `${key}.ndjson`))
86 }
87 const kids = children(self, windows, parseLedger(ledgerText))
88
89 const pr = self.issue !== null && s !== ''
90 ? prFor(await readOr($, join(dash, s, 'prmap')), `issue-${self.issue}`)
91 : null
92 const snap: ProgressSnapshot = {
93 issue: self.issue,
94 pr,
95 needsKids: kids.filter(k => k.glyph === '!').map(k => k.key),
96 }
97 await update($, progress, () => snap)
98 const { toasts, keep } = newAlerts(await read($, alerts), snap)
99 await update($, alerts, () => keep)
100 for (const t of toasts) $.ui.toast(t)
101}
102
103export function registerProgress(on: On): void {
104 // Matched on isInteractive: lifecycle.ts holds the plugin's one unmatched
105 // session.start, and a band means nothing to a headless (`-p`) session.
106 // `await next(e)` first, so lifecycle's gate has run whichever way the two
107 // hooks nest.
108 on('session.start', { isInteractive: true }, async ($, e, next) => {
109 const result = await next(e)
110 if (!isOpen()) return result
111 const refresh = () => refreshProgress($).catch(() => undefined)
112 timer?.cancel()
113 timer = $.clock.every(PROGRESS_MS, () => {
114 void refresh()
115 })
116 await refresh()
117 return result
118 }).catch(($, e, next) => next(e))
119
120 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
121 // Read FIRST, every time: the read is what subscribes this band to the
122 // snapshot, so a draw that passed early (gate shut, nothing read yet)
123 // would otherwise never be redrawn when the first refresh lands.
124 const snap = await read($, progress)
125 const q = await read($, queue)
126 if (!isOpen() || e.props.hasSurvey) return next(e)
127 // The orchestrator's queue (queue.ts, issue #2617) shares this one site:
128 // its yellow line on top, the PR segment under it — one tree, never two.
129 const waiting = isOrchestrator() ? queueText(q) : ''
130 const kept = snap === null ? [] : segments(snap)
131 if (kept.length === 0 && waiting === '') return next(e)
132 const { Box, Text, Button } = $.ui.resolve(e)
133 return (
134 <Box key="fleet-band" flexDirection="column">
135 {waiting !== '' && (
136 <Box key="fleet-queue" flexDirection="row">
137 <Text key="fleet-queue-text" color="yellow" wrap="truncate-end">
138 {waiting}
139 </Text>
140 <Button
141 key="fleet-queue-qd"
142 label={t('orch_queue_qd')}
143 dimColor
144 onPress={() => {
145 void $.command.run({ command: QD_COMMAND }).catch(() => undefined)
146 }}
147 />
148 </Box>
149 )}
150 {kept.length > 0 && (
151 <Box key="fleet-progress" flexDirection="row">
152 {kept.map(seg => (
153 <Text key={seg.id} color={seg.color} wrap="truncate-end">
154 {seg.text}
155 </Text>
156 ))}
157 </Box>
158 )}
159 </Box>
160 )
161 }).catch(($, e, next) => next(e))
162}
163hooks/qd.tsx 244 lines1// Quick dispatch: `/qd` in the orchestrator's window (issue #2618, EPIC #2615 C3).
2//
3// The orchestrator can be busy for a while on one step (a long read, a slow
4// script), and whatever the person types waits behind that step. `/qd` is the
5// one road to a new worker that does not pass through it: registered
6// `immediate`, so Enter runs it mid-turn; it opens a small dialog — a title, a
7// repo (the fleet's hosted ones, the last one used first) — and Enter files the
8// issue through the SAME road the orchestrator takes (`fleet-mcp.py --call
9// file_issue {title, repo, spawn: true}`, the session's FLEET_WORKER_CRED in the
10// environment), so the spawn's origin is the orchestrator's window and the
11// worker's [child-report] comes back to it as always. Nothing reaches the model
12// and nothing is written into the conversation; a failure stays in the dialog
13// with the title kept, and fleet-issue-file.sh's own hints show as they came.
14//
15// Off by default, a prefix does the same without the dialog: with
16// FLEET_ORCH_QD_PREFIX=1, a prompt typed while a turn runs that opens with
17// `派:` is dropped (never queued) and dispatched to the last repo.
18//
19// Only the orchestrator's window (orchestrator.ts's start-up read) registers the
20// command — lifecycle.ts does it, with the strings (bin/fleet-ui-lang.sh's
21// `qd_` keys, read once) — so a worker's window has no /qd at all. Codex has no
22// mod, so no dialog. Every UI string comes from that table; a key with no entry
23// shows itself, as the table's own rule says.
24
25import { atom, read, update } from 'claude-code'
26import type { CommandSpec, EngineInterface, On } from 'claude-code'
27
28import type { QdState } from '../types'
29import { isOpen } from './gate'
30import { isOrchestrator } from './orchestrator'
31import { binDir, callArgv } from './tools'
32import { byHand } from './exit-guard'
33
34export const QD_COMMAND = 'qd'
35export const QD_PANE = 'fleet-qd'
36/** fleet-mcp.py's FILE_TIMEOUT_S (180) plus headroom: its own «did not answer» wins. */
37export const FILE_TIMEOUT_MS = 190_000
38export const REPOS_TIMEOUT_MS = 40_000
39/** `$.store` key: the repo the last dispatch went to. */
40export const LAST_REPO = 'qd.lastRepo'
41export const PREFIX_RE = /^\s*派[::]\s*/
42
43const IDLE: QdState = { title: '', repo: '', repos: [], error: '', busy: false }
44const qd = atom({ plugin: 'fleet', key: 'qd' } as const, IDLE as QdState)
45
46let strings: Record<string, string> = {}
47
48/** The argv that prints the `qd_` strings and queue.ts's `orch_queue` ones (KEY NUL TEXT NUL, a \001 per printf slot). */
49export function stringsArgv(root: string): string[] {
50 return ['sh', `${binDir(root)}/fleet-ui-lang.sh`, 'dump', 'qd_', 'orch_queue']
51}
52
53/** Take the dump; one that cannot be read leaves every key showing itself. */
54export function takeStrings(dump: string): void {
55 const parts = dump.split('\0')
56 const out: Record<string, string> = {}
57 for (let i = 0; i + 1 < parts.length; i += 2) if (parts[i] !== '') out[parts[i] as string] = parts[i + 1] as string
58 strings = out
59}
60
61/** One string, its \001 slots filled in order. */
62export function t(key: string, ...args: string[]): string {
63 let i = 0
64 return (strings[key] ?? key).replace(/\u0001/g, () => args[i++] ?? '')
65}
66
67/** The command as lifecycle.ts registers it. */
68export function qdCommand(): CommandSpec {
69 return { name: QD_COMMAND, description: t('qd_desc'), immediate: true }
70}
71
72/** Tests: is this argv the strings read? */
73export function isStringsRun(argv: readonly string[]): boolean {
74 return argv[0] === 'sh' && (argv[1] ?? '').endsWith('/fleet-ui-lang.sh') && argv[2] === 'dump'
75}
76
77/** `--call`'s text: `exit N · <command>`, its stdout, then `[stderr]` and its stderr. */
78export function parseReport(text: string): { exit: number | null; stdout: string; stderr: string } {
79 const lines = text.replace(/\s+$/, '').split('\n')
80 const m = /^exit (-?\d+) · /.exec(lines[0] ?? '')
81 if (m === null) return { exit: null, stdout: '', stderr: text.trim() }
82 const rest = lines.slice(1)
83 const at = rest.indexOf('[stderr]')
84 return {
85 exit: Number(m[1]),
86 stdout: (at < 0 ? rest : rest.slice(0, at)).join('\n').trim(),
87 stderr: (at < 0 ? [] : rest.slice(at + 1)).join('\n').trim(),
88 }
89}
90
91/** The hosted repos out of `--call repos` (fleet-repo.sh list's two-space rows). */
92export function parseRepos(text: string): string[] {
93 const out: string[] = []
94 for (const line of parseReport(text).stdout.split('\n')) {
95 const m = /^ {2}(\S+\/\S+)\s/.exec(`${line} `)
96 if (m !== null && !out.includes(m[1] as string)) out.push(m[1] as string)
97 }
98 return out
99}
100
101/** The issue number from the filer's URL, if it printed one. */
102export function issueNumber(stdout: string): string | undefined {
103 return /\/issues\/(\d+)/.exec(stdout)?.[1]
104}
105
106/** fleet-issue-file.sh's `hint:` lines, as it wrote them. */
107export function hints(stderr: string): string[] {
108 return stderr.split('\n').filter(l => /\bhint:/.test(l)).map(l => l.trim())
109}
110
111type Outcome = { ok: true; text: string } | { ok: false; text: string }
112
113/** One dispatch: `fleet-mcp.py --call file_issue`, its answer folded to one line or a reason. */
114async function fileIssue($: EngineInterface, title: string, repo: string): Promise<Outcome> {
115 const args: Record<string, unknown> = { title, spawn: true }
116 if (repo !== '') args.repo = repo
117 let text: string
118 let exitCode: number
119 try {
120 const r = await $.process.run(callArgv(binDir($.plugin.root), 'file_issue', args), { timeoutMs: FILE_TIMEOUT_MS })
121 text = `${r.stdout}${r.stdout && r.stderr ? '\n' : ''}${r.stderr}`
122 exitCode = r.exitCode
123 } catch (err) {
124 return { ok: false, text: t('qd_failed_fmt', err instanceof Error ? err.message : String(err)) }
125 }
126 const rep = parseReport(text)
127 const num = issueNumber(rep.stdout)
128 if (exitCode === 0 && rep.exit === 0 && num !== undefined) {
129 return { ok: true, text: [t('qd_done_fmt', num), ...hints(rep.stderr)].join('\n') }
130 }
131 // A refusal (exit 1 + its reason), a script that failed, a filed issue whose spawn did not go.
132 const why = [num !== undefined ? rep.stdout : '', rep.stderr || (rep.exit === null ? text.trim() : rep.stdout)]
133 .filter(s => s !== '').join('\n')
134 return { ok: false, text: t('qd_failed_fmt', why || `exit ${rep.exit ?? exitCode}`) }
135}
136
137async function loadRepos($: EngineInterface): Promise<string[]> {
138 try {
139 const r = await $.process.run(callArgv(binDir($.plugin.root), 'repos', {}), { timeoutMs: REPOS_TIMEOUT_MS })
140 return r.exitCode === 0 ? parseRepos(r.stdout) : []
141 } catch {
142 return []
143 }
144}
145
146/** The repo to start on: the last one used while it is still hosted, else the first. */
147export function pickRepo(repos: readonly string[], last: unknown): string {
148 return typeof last === 'string' && repos.includes(last) ? last : (repos[0] ?? '')
149}
150
151/** The dialog's Enter: an empty title is refused in place, a second Enter while one runs is ignored. */
152async function submit($: EngineInterface, typed: string): Promise<void> {
153 const s = await read($, qd)
154 if (s.busy) return
155 const title = typed.trim()
156 if (title === '') {
157 await update($, qd, v => ({ ...v, title: typed, error: t('qd_empty') }))
158 return
159 }
160 if (s.repos.length > 0 && s.repo === '') {
161 await update($, qd, v => ({ ...v, title: typed, error: t('qd_norepo') }))
162 return
163 }
164 await update($, qd, v => ({ ...v, title: typed, busy: true, error: '' }))
165 const out = await fileIssue($, title, s.repo)
166 if (!out.ok) {
167 await update($, qd, v => ({ ...v, busy: false, error: out.text }))
168 return
169 }
170 if (s.repo !== '') await $.store.set(LAST_REPO, s.repo).catch(() => undefined)
171 await update($, qd, v => ({ ...v, title: '', busy: false, error: '' }))
172 await $.ui.close({ id: QD_PANE })
173 $.ui.toast(out.text)
174}
175
176export function registerQuickDispatch(on: On): void {
177 on('command.run', { command: QD_COMMAND }, async ($, e, next) => {
178 if (!isOpen() || !isOrchestrator()) return next(e)
179 // Open first, at once; the repo list lands a moment later.
180 await update($, qd, v => ({ ...v, error: '', busy: false }))
181 await $.ui.open({ id: QD_PANE, title: t('qd_title'), focus: true, closeOnEscape: true, holdToasts: true, rows: 6 })
182 const repos = await loadRepos($)
183 const repo = pickRepo(repos, await $.store.get(LAST_REPO).catch(() => undefined))
184 await update($, qd, v => ({
185 ...v,
186 repos,
187 repo: repos.includes(v.repo) ? v.repo : repo,
188 error: repos.length === 0 ? t('qd_norepo') : v.error,
189 }))
190 return {}
191 }).catch(($, e, next) => next(e))
192
193 on('ui.render', { component: 'Pane', requestId: QD_PANE }, async ($, e, next) => {
194 const s = await read($, qd)
195 if (!isOpen()) return next(e)
196 const ui = $.ui.resolve(e)
197 const { Box, Text } = ui
198 if (!('Input' in ui) || !('Select' in ui)) return <Text>{t('qd_hint')}</Text>
199 const { Input, Select } = ui
200 return (
201 <Box flexDirection="column">
202 <Input
203 key="qd-title"
204 label={t('qd_field_title')}
205 placeholder={t('qd_placeholder')}
206 value={s.title}
207 submitLabel={t('qd_submit')}
208 autoFocus
209 onInput={value => update($, qd, v => ({ ...v, title: value }))}
210 onSubmit={value => submit($, value)}
211 />
212 {s.repos.length > 0 && (
213 <Select
214 key="qd-repo"
215 label={t('qd_field_repo')}
216 options={s.repos.map(r => ({ value: r, label: r }))}
217 value={s.repo}
218 onSelect={value => update($, qd, v => ({ ...v, repo: value }))}
219 />
220 )}
221 {s.busy && <Text dimColor>{t('qd_sending')}</Text>}
222 {s.error !== '' && <Text color="red">{s.error}</Text>}
223 <Text dimColor>{t('qd_hint')}</Text>
224 </Box>
225 )
226 }).catch(($, e, next) => next(e))
227
228 // The prefix (off unless FLEET_ORCH_QD_PREFIX=1): `派:<title>` typed by hand
229 // over a running turn never enters the queue; idle (or not dispatched), it goes on as typed.
230 on('prompt.submit', { text: PREFIX_RE }, async ($, e, next) => {
231 if (!isOpen() || !isOrchestrator() || e.turnId === undefined || !byHand(e.origin)) return next(e)
232 if (!PREFIX_RE.test(e.text) || (await $.env.get('FLEET_ORCH_QD_PREFIX')) !== '1') return next(e)
233 const title = e.text.replace(PREFIX_RE, '').trim()
234 if (title === '') return next(e)
235 const repo = pickRepo(await loadRepos($), await $.store.get(LAST_REPO).catch(() => undefined))
236 const out = await fileIssue($, title, repo)
237 $.ui.toast(out.text)
238 // Not dispatched: the words are not thrown away — they wait in the queue as typed.
239 if (!out.ok) return next(e)
240 if (repo !== '') await $.store.set(LAST_REPO, repo).catch(() => undefined)
241 return { drop: out.text }
242 }).catch(($, e, next) => next(e))
243}
244hooks/queue.ts 143 lines1// What waits behind a busy orchestrator (issue #2617, EPIC #2615 C2).
2//
3// Whatever the person types while the orchestrator's turn runs is queued by the
4// engine, and until now all they saw was one grey line under the prompt — no
5// count, no idea when it would be read, so they said it again. This file counts
6// it: a prompt typed by hand over a running turn (`prompt.submit` carrying that
7// turn's id, not dropped by a hook beneath — /qd's `派:` prefix, the exit
8// guard) is +1; it is back to 0 as soon as the engine folds the queue in, which
9// is at the NEXT main-loop model request (`turn.step`: measured, the queued
10// input joins the running turn once the current tool call ends — not at the
11// turn's end), at a new `turn.start`, and at `turn.complete`.
12//
13// Two readers, one count:
14// - the band above the prompt: progress.tsx draws 「排队 N 条 · 在忙 X(已 M 秒)·
15// /qd 直接派」 in the same AbovePrompt tree as the PR segment (one site, one
16// tree), plus a button that opens /qd;
17// - the window option `@orch_queue` (0 at the start, so a counting orchestrator
18// always says a number), which fleet-control-read.sh carries as the inventory's
19// `orchq=` and fleet-hub-sessions.sh as `orch_<sess>`'s 7th column — the
20// client's 「新任务」 row ends in 「排队 N」.
21//
22// Only the orchestrator's window (orchestrator.ts's start-up read): every hook
23// here passes straight through anywhere else. The strings are qd.tsx's table
24// (bin/fleet-ui-lang.sh's `orch_` keys ride the same dump).
25
26import { atom, read, update } from 'claude-code'
27import type { EngineInterface, On, Timer } from 'claude-code'
28
29import type { OrchQueue } from '../types'
30import { byHand } from './exit-guard'
31import { isOpen } from './gate'
32import { isOrchestrator } from './orchestrator'
33import { t } from './qd'
34import { TMUX_TIMEOUT_MS, windowOptionsArgv } from './tmux'
35
36export const QUEUE_OPTION = '@orch_queue'
37/** How often the band's 「已 M 秒」 moves while something waits. */
38export const TICK_MS = 1000
39
40export const QUEUE_IDLE: OrchQueue = { n: 0, since: 0, what: '', now: 0 }
41// progress.tsx spells the same `fleet/queue` atom to draw it (the state scan reads one per file).
42const queue = atom({ plugin: 'fleet', key: 'queue' } as const, QUEUE_IDLE)
43
44// Module state: a reload is a fresh module.
45let ticker: Timer | undefined
46let stamped: string | undefined
47
48/** The band's line, '' when nothing waits. */
49export function queueText(q: OrchQueue): string {
50 if (q.n <= 0) return ''
51 const secs = q.since > 0 && q.now >= q.since ? String(Math.floor((q.now - q.since) / 1000)) : '0'
52 return t('orch_queue_fmt', String(q.n), q.what !== '' ? q.what : t('orch_queue_thinking'), secs)
53}
54
55/** A submission that counts: typed by hand over a running turn, not /qd itself. */
56export function counts(e: { turnId?: string; origin: Parameters<typeof byHand>[0]; text: string }): boolean {
57 return e.turnId !== undefined && byHand(e.origin) && !/^\s*\/qd(\s|$)/.test(e.text)
58}
59
60/** Stamp `@orch_queue` on this pane's window — only when it changed. */
61async function stamp($: EngineInterface, n: number): Promise<void> {
62 const value = String(n)
63 if (value === stamped) return
64 const pane = await $.env.get('TMUX_PANE')
65 if (pane === undefined || pane === '') return
66 const argv = windowOptionsArgv(pane, { [QUEUE_OPTION]: value })
67 if (argv === null) return
68 try {
69 const r = await $.process.run(argv, { timeoutMs: TMUX_TIMEOUT_MS })
70 if (r.exitCode === 0) stamped = value
71 } catch {
72 // The next change writes it again.
73 }
74}
75
76/** Back to 0: the engine took what waited (or the turn is over). */
77async function clear($: EngineInterface, patch: Partial<OrchQueue> = {}): Promise<void> {
78 ticker?.cancel()
79 ticker = undefined
80 const was = (await read($, queue)).n
81 await update($, queue, v => ({ ...v, ...patch, n: 0 }))
82 // Nothing waited: the window already says 0 (lifecycle.ts stamps it at the start).
83 if (was > 0) await stamp($, 0)
84}
85
86/** Tests: forget the module's state. */
87export function resetQueue(): void {
88 ticker?.cancel()
89 ticker = undefined
90 stamped = undefined
91}
92
93export function registerQueue(on: On): void {
94 on('prompt.submit', { origin: { kind: ['composer', 'bridge'] } }, async ($, e, next) => {
95 if (!isOpen() || !isOrchestrator() || !counts(e)) return next(e)
96 const result = await next(e)
97 if ('drop' in result && result.drop !== undefined) return result
98 const now = await $.clock.now()
99 const q = await update($, queue, v => ({ ...v, n: v.n + 1, since: v.since > 0 ? v.since : now, now }))
100 await stamp($, q.n)
101 if (ticker === undefined) {
102 ticker = $.clock.every(TICK_MS, () => {
103 void (async () => {
104 const at = await $.clock.now()
105 await update($, queue, v => ({ ...v, now: at }))
106 })().catch(() => undefined)
107 })
108 }
109 return result
110 }).catch(($, e, next) => next(e))
111
112 on('turn.start', { turnId: /./ }, async ($, e, next) => {
113 if (isOpen() && isOrchestrator()) await clear($, { since: await $.clock.now(), what: '' })
114 return next(e)
115 }).catch(($, e, next) => next(e))
116
117 // A main-loop request about to go: what waited has just been folded into it.
118 // Observe and pass, as usage.ts does: the clear runs beside the stream.
119 on('turn.step', { index: /^\d+$/ }, async function* ($, e, next) {
120 if (isOpen() && isOrchestrator() && e.agentId === undefined) {
121 await (async () => {
122 if ((await read($, queue)).n > 0) await clear($, { what: '' })
123 else await update($, queue, v => (v.what === '' ? v : { ...v, what: '' }))
124 })().catch(() => undefined)
125 }
126 return yield* next(e)
127 }).catch(async function* ($, e, next) {
128 return yield* next(e)
129 })
130
131 // What it is busy with: the main loop's tool call while it runs.
132 on('tool.call', { tool: /./ }, async ($, e, next) => {
133 if (!isOpen() || !isOrchestrator() || e.agentId !== undefined) return next(e)
134 await update($, queue, v => ({ ...v, what: e.tool }))
135 return next(e)
136 }).catch(($, e, next) => next(e))
137
138 on('turn.complete', { turnId: /./ }, async ($, e, next) => {
139 if (isOpen() && isOrchestrator() && e.agentId === undefined) await clear($, { since: 0, what: '' })
140 return next(e)
141 }).catch(($, e, next) => next(e))
142}
143hooks/state.ts 115 lines1// The session reports its own state (issue #1336, EPIC #1334 C2).
2//
3// Until now `@claude_state` came from settings hooks (PreToolUse / PostToolUse /
4// Stop / Notification) plus a screen classifier that read the pane with a model
5// call to tell `done` from "waiting on you" and "a Loop is pending". The engine
6// knows all three outright, so the mod says them as they happen:
7//
8// turn.start → working
9// turn.complete → done, or `looping` when @loop says a Loop is pending
10// tool.call AskUserQuestion → needs/ask while the question is open, working after
11// tool.call ScheduleWakeup | CronCreate | CronDelete → @loop, after the call
12//
13// Every write goes through the fleet's own writers, never a tmux call of ours:
14// bin/set-claude-state.sh (`--via mod`: the state write alone — the Stop hook keeps
15// the auto-handoff / compaction / parent-report side of a clean stop) and
16// bin/fleet_loop_mark.py's PostToolUse entry, fed the same payload the hook gets, so
17// whichever of the two writers arrives first, @loop reads the same. The settings
18// hooks are not removed: they still fire, and write the same values.
19//
20// With the mod alive (bin/fleet-lib.sh fleet_mod_alive) the screen classifier skips
21// the window (`skip:mod` in classify.log, bin/classify-sessions.sh). Subagent turns
22// and calls (`agentId`) are not this pane's state and are passed straight on.
23
24import type { EngineInterface, On } from 'claude-code'
25
26import { isOpen } from './gate'
27
28/** How long one fleet-script write may take before it is abandoned. */
29export const WRITE_TIMEOUT_MS = 10_000
30
31const LOOP_TOOLS = new Set(['ScheduleWakeup', 'CronCreate', 'CronDelete'])
32
33/** The fleet's bin/ beside this plugin (<install>/mod/fleet → <install>/bin), or
34 * undefined outside tmux, where there is no window to write. */
35async function fleetBin($: EngineInterface): Promise<string | undefined> {
36 const pane = await $.env.get('TMUX_PANE')
37 if (pane === undefined || pane === '') return undefined
38 return `${$.plugin.root}/../../bin`
39}
40
41/** `set-claude-state.sh --via mod <verb>`; a failed write leaves the hooks' value.
42 * `stdin`: the hook payload's shape, for a verb that reads one (`ask`'s question). */
43async function setState($: EngineInterface, verb: 'working' | 'done' | 'ask', stdin = ''): Promise<void> {
44 try {
45 const bin = await fleetBin($)
46 if (bin === undefined) return
47 await $.process.run(['sh', `${bin}/set-claude-state.sh`, '--via', 'mod', verb], {
48 stdin,
49 timeoutMs: WRITE_TIMEOUT_MS,
50 })
51 } catch {
52 // The settings hooks still write the same state; nothing to undo.
53 }
54}
55
56/** Hand fleet_loop_mark.py the PostToolUse payload of a Loop tool call. */
57async function markLoop($: EngineInterface, payload: Record<string, unknown>): Promise<void> {
58 try {
59 const bin = await fleetBin($)
60 if (bin === undefined) return
61 await $.process.run(['python3', `${bin}/fleet_loop_mark.py`, 'hook'], {
62 stdin: JSON.stringify(payload),
63 timeoutMs: WRITE_TIMEOUT_MS,
64 })
65 } catch {
66 // The PostToolUse hook writes the same @loop.
67 }
68}
69
70/** The tool's own arguments: the call's input minus the envelope keys. */
71function toolInput(e: Record<string, unknown>): Record<string, unknown> {
72 const { tool: _t, tool_use_id: _id, agentId: _a, consent: _c, ...rest } = e
73 return rest
74}
75
76export function registerState(on: On): void {
77 on('turn.start', async ($, e, next) => {
78 if (isOpen()) await setState($, 'working')
79 return next(e)
80 }).catch(($, e, next) => next(e))
81
82 on('turn.complete', async ($, e, next) => {
83 const result = await next(e)
84 // set-claude-state.sh's done branch turns this into `looping` when @loop (or a
85 // loop ledger) says a Loop is pending — the one place that decides it.
86 if (isOpen() && e.agentId === undefined) await setState($, 'done')
87 return result
88 }).catch(($, e, next) => next(e))
89
90 on('tool.call', { tool: 'AskUserQuestion' }, async ($, e, next) => {
91 if (!isOpen() || e.agentId !== undefined) return next(e)
92 // The question's own words ride along (issue #1951): @claude_needs_detail.
93 await setState($, 'ask', JSON.stringify({ tool_name: 'AskUserQuestion', tool_input: toolInput(e) }))
94 try {
95 return await next(e)
96 } finally {
97 // Answered, declined or interrupted: the question is no longer open.
98 await setState($, 'working')
99 }
100 }).catch(($, e, next) => next(e))
101
102 on('tool.call', async ($, e, next) => {
103 if (!isOpen() || e.agentId !== undefined || !LOOP_TOOLS.has(e.tool)) return next(e)
104 const result = await next(e)
105 if (result.deny === undefined && !result.isError) {
106 await markLoop($, {
107 tool_name: e.tool,
108 tool_input: toolInput(e as unknown as Record<string, unknown>),
109 tool_response: result.result,
110 })
111 }
112 return result
113 }).catch(($, e, next) => next(e))
114}
115hooks/tools.ts 154 lines1// The fleet's three in-session tools, kept as the FALLBACK for a session launched
2// before the fleet tool service (issue #2057; the tools: issue #1340, retired as
3// the primary road in #1812).
4//
5// mcp__fleet__fleet_status / mcp__fleet__fleet_spawn / mcp__fleet__fleet_await
6//
7// bin/fleet-claude.sh mounts bin/fleet-mcp.py as the MCP server `fleet` on every
8// new session and exports FLEET_MCP_SERVER=1 (issue #1807): there the mod registers
9// NOTHING — status / spawn / await are the service's own tools. A session launched
10// before that (`--plugin-dir` only, no `--mcp-config`) has no service, and its tool
11// list — registered once at ITS start — still carries the mod's three: a hot reload
12// swaps the code, never the list. Mod 0.4.0 dropped the handler, so every such call
13// died with «no tool.call hook answered». Hence, one implementation, two roads:
14//
15// - registration (lifecycle.ts onReady, FLEET_MCP_SERVER not 1 — the engine
16// follows `$` only within one file, so the run lives there and the argv and
17// the parse live here): the three are registered from the SERVICE's own specs
18// — `fleet-mcp.py --spec status spawn await` — so a schema lives once; a
19// re-register on a reload costs nothing;
20// - serving (the tool.call hook below): every call is forwarded to `fleet-mcp.py
21// --call <tool> <json>` — the same identity check, argument check, repo check,
22// script run and call log (road=call) as a tools/call. This file knows the
23// argv and the timeout, no more: nothing here parses an argument or names a
24// script;
25// - the retreat (the issue's B): when the FORWARD itself fails — no python3, the
26// install's bin/ gone, a usage exit, a timeout — the answer is one actionable
27// message: reopen the session (/fleet-handoff, or `claude --resume`) so it
28// mounts the service, or run the script by hand meanwhile.
29
30import type { On, ToolSpec } from 'claude-code'
31
32import { isOpen } from './gate'
33
34export const PLUGIN = 'fleet'
35// FLEET_MCP_SERVER=1 (set by bin/fleet-claude.sh, #1807) says the service is
36// mounted; lifecycle.ts reads it by its literal name, as the engine requires.
37// bin/fleet-oldcfg-replay.py reads the two below (issue #2075): FALLBACK is «the tools
38// this version registers», TOOL_RE «the names the tool.call hook answers». Rename
39// either and teach the replay the new spelling, or the release gate goes red.
40/** The service's tools the fallback carries, in registration order. */
41export const FALLBACK = ['status', 'spawn', 'await'] as const
42export type Fallback = (typeof FALLBACK)[number]
43
44/** Every tool this file serves, as the model calls it. */
45export const TOOL_RE = /^mcp__fleet__fleet_(status|spawn|await)$/
46
47// Timeouts stand a little past fleet-mcp.py's own (SPAWN_TIMEOUT_S 120, STATUS 30,
48// await = timeout + 25), so the service's «did not answer within» is what the model
49// reads, not a cut from here; `$.process.run` allows ten minutes at most.
50export const AWAIT_DEFAULT_S = 540
51const AWAIT_SLACK_S = 25
52const HEADROOM_S = 10
53export const SPEC_TIMEOUT_MS = 30_000
54export const STATUS_TIMEOUT_MS = (3 * 30 + HEADROOM_S) * 1000
55export const SPAWN_TIMEOUT_MS = (120 + HEADROOM_S) * 1000
56export const RUN_MAX_MS = 600_000
57
58/** The install's bin/, from the plugin root (<install>/mod/fleet). */
59export function binDir(root: string): string {
60 const base = root.replace(/\/+$/, '').replace(/\/\.claude-plugin$/, '')
61 return `${base}/../../bin`
62}
63
64function service(bin: string): string[] {
65 return ['python3', `${bin}/fleet-mcp.py`]
66}
67
68/** The argv that prints the three specs. */
69export function specArgv(bin: string): string[] {
70 return [...service(bin), '--spec', ...FALLBACK]
71}
72
73/** The argv that makes one call — the arguments travel as one JSON word. */
74export function callArgv(bin: string, tool: string, args: Record<string, unknown>): string[] {
75 return [...service(bin), '--call', tool, JSON.stringify(args)]
76}
77
78/** True for a run of this file's (a test's recorder filters them, like where.ts's). */
79export function isToolsRun(argv: readonly string[]): boolean {
80 return argv[0] === 'python3' && (argv[1] ?? '').endsWith('/fleet-mcp.py')
81}
82
83/** How long a forwarded call may take, by tool (await: its own timeout + slack). */
84export function timeoutFor(tool: string, args: Record<string, unknown>): number {
85 if (tool === 'await') {
86 const t = typeof args.timeout === 'number' ? args.timeout : AWAIT_DEFAULT_S
87 return Math.min(RUN_MAX_MS, (t + AWAIT_SLACK_S + HEADROOM_S) * 1000)
88 }
89 return tool === 'spawn' ? SPAWN_TIMEOUT_MS : STATUS_TIMEOUT_MS
90}
91
92const NOTE =
93 '[fallback — this session was launched before the fleet tool service; the call is forwarded to ' +
94 'bin/fleet-mcp.py. A reopened session (/fleet-handoff, or claude --resume) has the service\'s own tools.] '
95
96/** The `--spec` output as the tools to register: `fleet_` + name, the note, the schema as is. */
97export function fallbackSpecs(stdout: string): ToolSpec[] {
98 const rows = JSON.parse(stdout) as unknown
99 if (!Array.isArray(rows)) throw new Error('--spec did not print a list')
100 return rows.map(row => {
101 const r = row as { name?: unknown; description?: unknown; inputSchema?: unknown }
102 if (typeof r.name !== 'string' || r.inputSchema === undefined) throw new Error('--spec row without name/schema')
103 return {
104 name: `fleet_${r.name}`,
105 description: NOTE + (typeof r.description === 'string' ? r.description : ''),
106 inputSchema: r.inputSchema,
107 } as ToolSpec
108 })
109}
110
111/** The one message when the forward itself cannot run (the issue's B). */
112export function unreachable(full: string, tool: string, detail: string): string {
113 const script =
114 tool === 'spawn' ? 'dash-issue-session.sh <N> --repo <owner/name>'
115 : tool === 'await' ? 'fleet-await.sh <N> --repo <owner/name>'
116 : 'fleet-children.sh'
117 return [
118 `${full}: 这个会话启动于 fleet 工具服务(bin/fleet-mcp.py)之前,mod 的兜底转调失败:${detail}。`,
119 '请 /fleet-handoff,或退出后用 `claude --resume` 重开 — 新会话挂上 fleet 工具服务(mcp__fleet__status / spawn / await)。',
120 `临时可以用 Bash 调 ~/.claude/fleet/bin/${script}。`,
121 ].join('\n')
122}
123
124const RESERVED = new Set(['tool', 'tool_use_id', 'agentId', 'consent'])
125
126function argsOf(e: Record<string, unknown>): Record<string, unknown> {
127 const out: Record<string, unknown> = {}
128 for (const [k, v] of Object.entries(e)) if (!RESERVED.has(k)) out[k] = v
129 return out
130}
131
132export function registerTools(on: On): void {
133 // Unconditional past the gate: a call arrives only where a tool is listed, and
134 // that list is the session's own — so answer it, served or not.
135 on('tool.call', { tool: TOOL_RE }, async ($, e, next) => {
136 const full = String(e.tool)
137 const m = TOOL_RE.exec(full)
138 if (!isOpen() || m === null) return next(e)
139 const tool = m[1] as Fallback
140 const args = argsOf(e as unknown as Record<string, unknown>)
141 try {
142 const r = await $.process.run(callArgv(binDir($.plugin.root), tool, args), { timeoutMs: timeoutFor(tool, args) })
143 const text = r.stdout.replace(/\s+$/, '')
144 if (r.exitCode === 0) return { result: text }
145 if (r.exitCode === 1 && text !== '') return { deny: text } // the service refused or faulted, with its reason
146 const err = r.stderr.replace(/\s+$/, '')
147 return { result: unreachable(full, tool, `fleet-mcp.py --call exited ${r.exitCode}${err ? ` (${err})` : ''}`) }
148 } catch (err) {
149 // No python3, no bin/, a timeout: say what to do, never hang.
150 return { result: unreachable(full, tool, err instanceof Error ? err.message : String(err)) }
151 }
152 }).catch(($, e, next) => next(e))
153}
154hooks/usage.ts 180 lines1// Context, quota, model and effort, reported by the session itself (issue #1338,
2// EPIC #1334; model/effort + the bus feed, issue #1459).
3//
4// The engine pushes `session.measure` after every main-thread turn and when a
5// rate-limit window moves a whole point — rendered or not, watched or not. So a
6// background window nobody looks at still reports, where Claude Code's status
7// line (conf/statusline.sh) only runs when the status line redraws — and once a
8// login turns that status line OFF (`bin/fleet-statusline.sh off`, which gives the
9// pane its bottom row back) this is the ONLY reporter.
10//
11// One writer: everything here goes through conf/statusline.sh itself, run as
12//
13// bash <install>/conf/statusline.sh --from mod key=value …
14//
15// so the window options, their rounding and the @ctx_band thresholds are that
16// one file's whoever feeds it — Claude Code's JSON or this mod's argv. The
17// options it stamps for us:
18//
19// @ctx_pct @ctx_limit @ctx_band the context fill (integer %), window size and
20// the fleet's handoff band (fleet-context.sh, the
21// auto-handoff nudge, the pane header's colour)
22// @ctx_src mod this mod fed the bus at least once; the Claude
23// path never touches it, so a window keeps the
24// mark — fleet-statusline.sh counts them
25// @model @effort the model's display name and effort level (the
26// pane header; fleet-model-switch.sh verifies a
27// /model off @model, so it must flip without a
28// turn — hence the poll)
29// @rl5h @rl7d @rl_reset @rl_ts the account's 5h/7d % used and resets (issue
30// @rl_src mod #1267; a fresh `mod` stamp lets the quota watch
31// skip its ccquota fetch, bin/fleet-quotawatch.sh)
32//
33// Three sources, one target:
34// session.measure context + rate limits, when `changed` names either (here)
35// turn.step the model id and effort of each main-thread model request,
36// fed only when the pair changed — observe-and-pass, the
37// stream is never held for the write (here)
38// a 2 s poll `$.session.model()`, run by lifecycle.ts's onReady timer
39// (the engine follows `$` only within one file), so a /model
40// typed or posted lands on the bus within ~2 s and
41// fleet-model-switch's 15 s verify reads it. It feeds the
42// model alone — the script unsets @effort, and the next
43// turn.step restores the new model's.
44// The model dedup (`claimModelFeed`) is shared by the two model sources so a
45// pair is fed once. Rate limits are stamped only when BOTH windows have a
46// reading — the watch reads a half stamp as none; the script enforces that too.
47
48import type { EngineInterface, On, SessionMeasureInput, TurnStepInput } from 'claude-code'
49
50import { isOpen } from './gate'
51
52/** One bash + awk + tmux chain; well under the hook's budget. */
53export const STATUSLINE_TIMEOUT_MS = 10_000
54/** How often lifecycle.ts re-reads the live model while nothing else reports it. */
55export const MODEL_POLL_MS = 2_000
56
57/** <install>/conf/statusline.sh from the plugin root (<install>/mod/fleet). */
58export function statuslinePath(root: string): string {
59 const base = root.replace(/\/+$/, '').replace(/\/\.claude-plugin$/, '')
60 return `${base}/../../conf/statusline.sh`
61}
62
63/** The argv that feeds `fields` to the bus through the script. */
64export function feedArgv(root: string, fields: readonly string[]): string[] {
65 return ['bash', statuslinePath(root), '--from', 'mod', ...fields]
66}
67
68/**
69 * Claude Code's display name for a model id, as its status line spells it:
70 * `claude-opus-5-5` → `Opus 5.5`, `claude-haiku-4-5-20251001` → `Haiku 4.5`,
71 * `claude-sonnet-4-6[1m]` → `Sonnet 4.6 (1M context)`. An id outside that
72 * grammar (a Bedrock arn, an alias) is shown as it is — fleet-model-switch's
73 * match is a case-insensitive substring either way.
74 */
75export function displayName(id: string): string {
76 const raw = id.trim()
77 const m = /^claude-([a-z]+)-(\d+)-(\d+)(?:-\d{6,})?(\[1m\])?$/i.exec(raw)
78 if (m === null) return raw
79 const family = m[1]!.charAt(0).toUpperCase() + m[1]!.slice(1).toLowerCase()
80 return `${family} ${m[2]}.${m[3]}${m[4] ? ' (1M context)' : ''}`
81}
82
83/** ISO 8601 → epoch seconds as a string; `-` when absent or unreadable. */
84function epoch(iso: string | undefined): string {
85 const ms = iso === undefined ? NaN : Date.parse(iso)
86 return Number.isFinite(ms) ? String(Math.floor(ms / 1000)) : '-'
87}
88
89/** The key=value fields one measurement feeds the bus; empty when it carries nothing to say. */
90export function measureFields(e: Pick<SessionMeasureInput, 'context' | 'rateLimits'>): string[] {
91 const out: string[] = []
92 const { percent, window } = e.context
93 if (percent !== undefined && Number.isFinite(percent)) {
94 // Raw, to two decimals: the script rounds it (printf %.0f) as it rounds Claude's.
95 out.push(`ctx_pct=${percent.toFixed(2)}`)
96 if (Number.isFinite(window) && window > 0) out.push(`ctx_limit=${Math.floor(window)}`)
97 }
98 const five = e.rateLimits.find(r => r.kind === 'five_hour')
99 const seven = e.rateLimits.find(r => r.kind === 'seven_day')
100 if (five !== undefined && seven !== undefined && Number.isFinite(five.percentUsed) && Number.isFinite(seven.percentUsed)) {
101 out.push(`rl5h=${Math.max(0, Math.floor(five.percentUsed))}`)
102 out.push(`rl7d=${Math.max(0, Math.floor(seven.percentUsed))}`)
103 out.push(`rl_reset5=${epoch(five.resetsAt)}`)
104 out.push(`rl_reset7=${epoch(seven.resetsAt)}`)
105 }
106 return out
107}
108
109/** The key=value fields for a model (+ its effort; none ⇒ the script unsets @effort). */
110export function modelFields(id: string, effort: string | number | undefined): string[] {
111 const out = [`model=${displayName(id)}`]
112 if (effort !== undefined && effort !== '') out.push(`effort=${effort}`)
113 return out
114}
115
116// --- the model dedup, shared by turn.step (here) and the poll (lifecycle.ts) ---
117// Module state: a reload is a fresh module and session.start fires again.
118let fedModel: { id: string; effort: string } | undefined
119
120/** Forget what was fed (session.start, or a feed that failed — the next source retries). */
121export function resetModelFeed(): void {
122 fedModel = undefined
123}
124
125/** True when `id` is not the model last fed (the poll's question). */
126export function modelMoved(id: string): boolean {
127 return id !== '' && fedModel?.id !== id
128}
129
130/**
131 * Record (id, effort) as fed and return its fields — or null when that pair is
132 * what the bus already has, or `id` is empty.
133 */
134export function claimModelFeed(id: string, effort: string | number | undefined): string[] | null {
135 if (id === '') return null
136 const eff = effort === undefined ? '' : String(effort)
137 if (fedModel !== undefined && fedModel.id === id && fedModel.effort === eff) return null
138 fedModel = { id, effort: eff }
139 return modelFields(id, effort)
140}
141
142async function feed($: EngineInterface, fields: readonly string[]): Promise<void> {
143 if (fields.length === 0) return
144 const pane = await $.env.get('TMUX_PANE')
145 if (pane === undefined || pane === '') return
146 await $.process.run(feedArgv($.plugin.root, fields), { timeoutMs: STATUSLINE_TIMEOUT_MS })
147}
148
149async function feedStep($: EngineInterface, e: TurnStepInput): Promise<void> {
150 const fields = claimModelFeed(e.model, e.effort)
151 if (fields === null) return
152 try {
153 await feed($, fields)
154 } catch {
155 resetModelFeed()
156 }
157}
158
159export function registerUsage(on: On): void {
160 on('session.measure', async ($, e, next) => {
161 if (!isOpen()) return next(e)
162 if (e.changed.includes('context') || e.changed.includes('rateLimits')) {
163 try {
164 await feed($, measureFields(e))
165 } catch {
166 // A missed report leaves the last one standing; the next measurement writes.
167 }
168 }
169 return next(e)
170 }).catch(($, e, next) => next(e))
171
172 on('turn.step', async function* ($, e, next) {
173 // Observe and pass: the write runs beside the stream, never ahead of it.
174 if (isOpen() && e.agentId === undefined) void feedStep($, e)
175 return yield* next(e)
176 }).catch(async function* ($, e, next) {
177 return yield* next(e)
178 })
179}
180hooks/gate.ts 27 lines1// The one switch every fleet hook stands behind (EPIC #1334 rule 5).
2//
3// `register` runs before the engine can say its version, so a feature file
4// cannot simply skip registering. lifecycle.ts's session.start hook checks the
5// version and opens the gate when it is in range, so:
6//
7// - an ordinary hook starts with `if (!isOpen()) return next(e)` and passes
8// straight through to the engine while the gate is shut;
9// - work that needs the session up (a timer, a first write) goes in
10// lifecycle.ts's `onReady`, which runs only once the gate is open — the
11// engine takes one unmatched session.start hook per plugin.
12//
13// `$` is never handed across an import (the engine follows it only within one
14// file), so this file holds the flag and nothing else. A reload is a fresh
15// module: the gate starts shut and session.start fires again.
16
17let open = false
18
19export function isOpen(): boolean {
20 return open
21}
22
23/** lifecycle.ts only: the version check passed. */
24export function openGate(): void {
25 open = true
26}
27hooks/orchestrator.ts 69 lines1// The orchestrator's role, in every request (issue #2582, EPIC #2581 C1).
2//
3// The fleet's one orchestrating session (bin/fleet-orchestrator.sh) used to know
4// what it was from one seed turn, `/fleet-orchestrate` — a compaction kept only a
5// summary of it, a /clear nothing at all. Its role now rides the system prompt
6// two ways from ONE file, skills/fleet-orchestrate/role.md: the launcher's
7// `--append-system-prompt-file`, and this `session` section, so a session the
8// launcher did not start (a hand `claude --resume`, a handoff) still carries it.
9//
10// lifecycle.ts reads the window's `@fleet_role` and the file once at the start
11// (the run and `$` live there: the engine follows `$` only within one file); a
12// window that is not `orchestrator`, or a file that cannot be read, adds nothing;
13// compose.ts adds the section.
14// A worker window never sees this section. The same read says whether this is
15// the orchestrator's window at all (isOrchestrator), which exit-guard.ts asks.
16
17import type { PromptComposeSection } from 'claude-code'
18
19export const ROLE_SECTION = 'fleet:orchestrator-role'
20
21let role: string | undefined
22let orchestrator = false
23
24/** <install>/skills/fleet-orchestrate/role.md from the plugin root (<install>/mod/fleet). */
25export function rolePath(root: string): string {
26 const base = root.replace(/\/+$/, '').replace(/\/\.claude-plugin$/, '')
27 return `${base}/../../skills/fleet-orchestrate/role.md`
28}
29
30/** The argv that prints this pane's window's `@fleet_role`. */
31export function roleArgv(pane: string): string[] {
32 return ['tmux', 'display-message', '-p', '-t', pane, '#{@fleet_role}']
33}
34
35/**
36 * Take the start-up read: the window's role and the file's text. Only an
37 * `orchestrator` window with a non-empty file keeps a role; anything else
38 * clears it.
39 */
40export function takeRole(windowRole: string, text: string | undefined): void {
41 const body = text?.trim() ?? ''
42 orchestrator = windowRole.trim() === 'orchestrator'
43 role = orchestrator && body !== '' ? body : undefined
44}
45
46export function currentRole(): string | undefined {
47 return role
48}
49
50/** Is this the orchestrator's window — with or without its role file (exit-guard.ts, #2584)? */
51export function isOrchestrator(): boolean {
52 return orchestrator
53}
54
55/** Tests: forget the role. */
56export function resetRole(): void {
57 role = undefined
58 orchestrator = false
59}
60
61export function roleSection(text: string): PromptComposeSection {
62 return { id: ROLE_SECTION, scope: 'session', text }
63}
64
65/** Tests: is this argv the role read? */
66export function isRoleRun(argv: readonly string[]): boolean {
67 return argv[0] === 'tmux' && argv[1] === 'display-message' && argv[argv.length - 1] === '#{@fleet_role}'
68}
69