SLOPSHOPPER

fleet

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

newpanebandguardcommandtoast
★ 2v0.4.5MITupdated 2026-10-09verkyyi/claude-fleet/mod/fleet
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · fleet
│ ┃ fleet-qd ✕ › fix the failing auth test and add an audit╭──────────────────────╮ │ ┃ qd_field_title: qd_placeholder ⏎ qd_submit │ fleet │ │ ┃ qd_hint ⏺ Read(src/auth.ts) │ fleet 扩展已加载 · v0.4.5 │ │ ⎿ Read 6 lines ╰──────────────────────╯ │ ⏺ Update(src/auth.ts) │ ⎿ Added 2 lines, removed 1 line │ ⏺ Bash(bun test) │ ⎿ 3 pass, 1 fail │ │ ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · fleet-qd
qd_field_title: qd_placeholder ⏎ qd_submit qd_hint
README

claude-fleet

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.

dashboard status bar

<sub>Screenshots are the real UI captured from a live tmux server, staged with demo repo data.</sub>

What you get

  • Subscription-driven parallel work for small teams. Give independent tasks their own sessions and worktrees, follow them from a shared dashboard, and bring the results together through PRs. Worktrees separate uncommitted changes; related tasks still need an agreed merge order and may have conflicts.
  • Attention signals in the window list. Claude Code hooks stamp each window's state the instant it changes: a cyan braille spinner pulses while a session works, indigo while a /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.
  • Urgency-sorted windows. Windows re-slot themselves so position 1 is always the session that needs you most (needs > done > working > looping > idle). Your view never jumps — the sorter restores focus after every move. The client's task list puts the session that needs you first.
  • A mission-control dashboard (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.

backlog

  • GitHub backlog panel (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.
  • Background collectors keep it all instant: a 45-second daemon caches each worktree's branch, the repo's PR/CI map, open issues, per-session context tokens, and a local 5h/7d token-usage proxy. The dashboard only ever reads caches — zero inline git/gh/LLM calls.
  • Subscription-aware scheduling. Pool Claude subscription accounts, choose where new sessions start, and move existing sessions with their transcripts when an account needs a break. With TokenLedger (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.
  • Worktree lifecycle: 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).
  • Optional Claude Code status line (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.

Architecture

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:

  • Hooks are fast but blind; the LLM is smart but slow. Hooks give the instant working/done/needs signal; a change-gated haiku classifier later corrects what hooks can't know (e.g. "done" that's actually a /loop between iterations). Both write the same @claude_state.
  • One writer per surface. A single spinner daemon owns all window styling (one tmux source-file per frame = one repaint); a single collector owns every cache file; producers are read-only.
  • Loud/quiet hierarchy. Only "needs you" is loud (red, bold, bell). Everything else is quiet fg-color text — 7 spinning windows shouldn't shout.
  • Change-gate every LLM call. Summaries/classifications only fire when a pane's content checksum changed; a parked session costs zero tokens.
  • Every session is bound to a GitHub issue. New work enters through the backlog (typed tasks auto-file an issue), so nothing runs untracked.

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.

Install

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 Claude-Code side ships as a plugin

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.

Dependencies

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

Keybindings (prefix defaults to your tmux prefix)

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.

KeyAction
tap / right-clickthe 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 hback to the machine you were on (the previous window)
prefix zzoom 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.

tmux baseline

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.

Configuration

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

One fleet per login

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:

  • every session carries its repo (@repo), shown on the dash as a repo heading / short-tag badge (window names stay bare: issue-12);
  • the dash and the backlog list every repo at once, grouped under a heading per repo — there is no per-repo view to switch to;
  • a new session starts in the repo of the row you have highlighted — a session or a repo heading like tokenledger (0); a no-repo row (or nothing to go on) starts it in $HOME, where the hub opens too;
  • cleanup, PR status, restore and every issue lookup are keyed on (repo, number), so repo A's #12 never touches repo B's #12.

**Folding a fleet

Source 18 files
hooks/register.ts 39 lines
1// 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}
39
hooks/compose.ts 30 lines
1// 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}
30
hooks/exit-guard.ts 105 lines
1// 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}
105
hooks/lifecycle.ts 296 lines
1// 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}
296
hooks/progress.tsx 163 lines
1// 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}
163
hooks/qd.tsx 244 lines
1// 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}
244
hooks/queue.ts 143 lines
1// 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}
143
hooks/state.ts 115 lines
1// 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}
115
hooks/tools.ts 154 lines
1// 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}
154
hooks/usage.ts 180 lines
1// 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}
180
hooks/gate.ts 27 lines
1// 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}
27
hooks/orchestrator.ts 69 lines
1// 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