SLOPSHOPPER

fleet-orchestrate

claude-fleet orchestration: /fleet-orchestrate takes the orchestrator role (take_role) in a session that holds no issue — talk a request through, then hand it…

newpanecommandtoastpromptprocess
★ 2v0.2.4MITupdated 2026-10-12verkyyi/claude-fleet/plugins/fleet-orchestrate
A shopper browsing a rack in a slop shop
README

claude-fleet

English | 简体中文

Product blueprint: Fleet product direction and design principles (中文) guides future product decisions. The rest of this README describes the current implementation.

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 remote starts. 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 client's top line 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/dev/TERMS.md defines every term (what the collector/hub/dash actually are), docs/dev/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/dev/STATE.md traces how each window's Claude state (working/done/needs/looping) is set, rendered, and corrected. docs/dev/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/dev/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/dev/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/dev/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 — only for repos on GitHub; a repo at any other git address needs none) · 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

fleet opens the thin client. ⌘P in iTerm2, ⌃空格, F12 or ⌃] p opens the panel: choose a session, start a task, open its shell, or quit fleet. Tab on a row shows its actions. The panel and top bar run on the home machine (fleet_view.py, fleet-topbar.py --node, conf/tmux-view.conf). Your computer runs one foreground Python loop and one connection; closing it leaves the sessions running. See the client guide for key customization, phone controls and the completed sidebar migration checklist.

A direct node attach keeps tmux's stock keys. One fleet per login holds every repo you work on; the panel includes your sessions on other machines too.

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; every key with its default and help is in conf/fleet.conf.reference):

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/dev/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/dev/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 into another. bin/fleet-repo.sh fold <old-fleet> --into <fleet> --dry-run shows what would move; drop --dry-run to do it. The old fleet's repo and its per-repo settings become an overlay of <fleet> (a setting that only a fleet conf can hold is warned about, not carried). Every idle or finished session exits cleanly and resumes in <fleet> with the same conversation and folder, and a hibernating one is woken first. Busy sessions are left where they are, or moved as each one finishes with --wait. Once the old fleet is empty, its server goes down and its conf is archived under ~/.config/claude-fleet/archive/. It is never deleted.

bin/multirepo-e2e-selftest.sh proves the whole path end to end. One crash of the fleet's tmux server takes every repo in it down — and a login runs exactly one fleet (issue #979): fleet-up <owner/repo> / fleet repo add <owner/repo> with a fleet already configured ADDS the repo to it, and a second fleet is refused. A repo you want isolated needs a second login. A brand-new fleet is named fleet; an existing one keeps its name.

fleet is your one way into a fleet (issue #1711, EPIC #1710): the client, on your own computer or after an ssh login. cf (from shell/cw.zsh) is folded into it — for one version it prints cf 已并入 fleet and runs bin/fleet (cf --guide → fleet guide; cf <owner/repo> → fleet-up.sh <owner/repo> --no-attach, which adds that repo to your fleet, then the client), and then it goes. bin/fleet-attach.sh — a direct attach to the node's own session — still works for a script or a person who types it, but is no longer advertised.

Each fleet keeps its durable state in one directory per fleet — ~/.config/claude-fleet/fleets/<session>/ (its conf overlay, restore map, issue-bridge state), so ls ~/.config/claude-fleet/fleets/ is the list of running fleets (issue #181). The conf overlays the global fleet.conf, which still works as a one-fleet default. One settings file per login (issue #979): bin/fleet-settings.sh merge folds the install's fleet.conf and the fleet's conf into ~/.config/claude-fleet/fleet.settings (the conf keeps only FLEET_REPO/FLEET_MAIN/FLEET_BASE_BRANCH; the old files stay as *.pre-merge). Read order is install fleet.conf < fleet.settings < the fleet conf, so an unmerged login loads exactly as before. The config modal (bin/tmux-config.sh) writes only the two layers you can tell apart — this fleet (a global-only key goes to fleet.settings, any other to the fleet conf) and a hosted repo — and never the install's fleet.conf, which it reads as legacy (issue #1102). Every fleet gets a plan hub window holding the dash alone. The hub used to split a persistent claude in below it, but that pane rebuilt itself on every ⌂ tap, F9, fresh fleet and crash recovery — closing it never stuck — so it is gone, along with the knob that configured it. Run your own claude in whatever window you like: that is where you file, triage, spawn workers, hand work back and land whatever a worker couldn't (workers land their own PRs on green, #441). There is no resident orchestrator agent, and nothing spawns a Claude session for you. The base checkout stays edit-read-only for every pane (hooks/base-readonly-guard.py), so the hub can drive the fleet without ever committing to it. Upgrading from the old flat layout is automatic — /fleet-sync-install runs bin/fleet-migrate-layout.sh once (idempotent; readers dual-read both layouts).

Manage registered machines through MCP

The optional Fleet Hub gives authorized Agents one MCP endpoint for Fleets on several machines, including SSH-connected hosts. It supports scoped status queries, Issue-worker starts, messaging / graceful stop / resume of a worker by a durable identity that survives handoffs and account migrations, limited configuration updates and persistent operation tracking. Nodes use a fixed SSH control entry point; existing workers continue if the Hub disconnects. A launchd installer runs a persistent

Source 2 files
hooks/qd.tsx 279 lines
1// Quick dispatch: `/qd` (issue #2618; the fleet-orchestrate plugin's since #3114) —
2// RETIRED by issue #3328 (EPIC #3178 ⑤): one entry, ⌘P (its ⚡ row; ⌃空格 on a
3// phone). For ONE version a session that still has /qd keeps it, forwarded: the
4// dialog says 「/qd 已并进 ⌘P」 and its Enter runs exactly what ⌘P's ⚡ row runs on
5// words + a repo — `fleet-quick-dispatch.py send --title T --repo R` — never a
6// second road. compat-1v: 下一批删 (the module, its hooks.json line, its strings).
7//
8// Registered `immediate`, so Enter runs it mid-turn; a title, a repo (the fleet's
9// hosted ones, the last one used first). Nothing reaches the model; a failure
10// stays in the dialog with the title kept.
11//
12// Off by default, a prefix does the same without the dialog: with
13// FLEET_ORCH_QD_PREFIX=1, a prompt typed while a turn runs that opens with `派:`
14// is dropped (never queued) and sent the same way, to the last repo.
15//
16// Only a fleet window that binds NO issue registers the command (one tmux read at
17// session.start): a worker's window has no /qd. Installed only with the plugin —
18// no plugin, no /qd at all. Codex has no mod. Every UI string comes from
19// bin/fleet-ui-lang.sh's `qd_` keys; a key with no entry shows itself.
20
21import { atom, read, update } from 'claude-code'
22import type { CommandSpec, EngineInterface, PromptOrigin, Register } from 'claude-code'
23
24import type { QdState } from '../types'
25
26export const QD_COMMAND = 'qd'
27export const QD_PANE = 'fleet-qd'
28/** A send opens the session before it answers (fleet-compose.py --send → place). */
29export const SEND_TIMEOUT_MS = 190_000
30export const REPOS_TIMEOUT_MS = 40_000
31export const READ_TIMEOUT_MS = 5_000
32/** `$.store` key: the repo the last dispatch went to. */
33export const LAST_REPO = 'qd.lastRepo'
34export const PREFIX_RE = /^\s*派[::]\s*/
35
36const IDLE: QdState = { title: '', repo: '', repos: [], error: '', busy: false }
37const qd = atom({ plugin: 'fleet-orchestrate', key: 'qd' } as const, IDLE as QdState)
38
39let strings: Record<string, string> = {}
40let on_ = false   // this window registered /qd
41
42/** Where the fleet's scripts are: FLEET_MCP_BIN (a dev tree), else the live install. */
43export function binDir(fleetBin: string | undefined, home: string | undefined): string {
44  return fleetBin || `${home ?? ''}/.claude/fleet/bin`
45}
46
47/** The argv that makes one fleet tool call — the arguments travel as one JSON word. */
48export function callArgv(bin: string, tool: string, args: Record<string, unknown>): string[] {
49  return ['python3', `${bin}/fleet-mcp.py`, '--call', tool, JSON.stringify(args)]
50}
51
52/** ⌘P's ⚡ row on words + a repo (issue #3327): the one send /qd forwards to. */
53export function sendArgv(bin: string, title: string, repo: string): string[] {
54  return ['python3', `${bin}/fleet-quick-dispatch.py`, 'send', '--title', title, ...(repo !== '' ? ['--repo', repo] : [])]
55}
56
57/** The argv that prints the `qd_` strings (KEY NUL TEXT NUL, a \001 per printf slot). */
58export function stringsArgv(bin: string): string[] {
59  return ['sh', `${bin}/fleet-ui-lang.sh`, 'dump', 'qd_']
60}
61
62/** The one tmux read: this pane's window — its @issue and its @fleet_role. */
63export function windowArgv(pane: string): string[] {
64  return ['tmux', 'display-message', '-p', '-t', pane, '#{@issue}|#{@fleet_role}']
65}
66
67/** May this window have /qd? A fleet window (stamped) that binds no issue and is no panel. */
68export function mayDispatch(read: string): boolean {
69  const [issue = '', role = ''] = read.trim().split('|')
70  if (/^\d+$/.test(issue)) return false
71  return role !== '' && role !== 'home' && role !== 'panel'
72}
73
74/** Take the dump; one that cannot be read leaves every key showing itself. */
75export function takeStrings(dump: string): void {
76  const parts = dump.split('\0')
77  const out: Record<string, string> = {}
78  for (let i = 0; i + 1 < parts.length; i += 2) if (parts[i] !== '') out[parts[i] as string] = parts[i + 1] as string
79  strings = out
80}
81
82/** One string, its \001 slots filled in order. */
83export function t(key: string, ...args: string[]): string {
84  let i = 0
85  return (strings[key] ?? key).replace(/\u0001/g, () => args[i++] ?? '')
86}
87
88/** The command as session.start registers it. */
89export function qdCommand(): CommandSpec {
90  return { name: QD_COMMAND, description: t('qd_desc'), immediate: true }
91}
92
93/** Typed by a person (the composer, or the bridge that relays one). */
94export function byHand(origin: PromptOrigin): boolean {
95  return origin.kind === 'composer' || origin.kind === 'bridge'
96}
97
98/** `--call`'s text: `exit N · <command>`, its stdout, then `[stderr]` and its stderr. */
99export function parseReport(text: string): { exit: number | null; stdout: string; stderr: string } {
100  const lines = text.replace(/\s+$/, '').split('\n')
101  const m = /^exit (-?\d+) · /.exec(lines[0] ?? '')
102  if (m === null) return { exit: null, stdout: '', stderr: text.trim() }
103  const rest = lines.slice(1)
104  const at = rest.indexOf('[stderr]')
105  return {
106    exit: Number(m[1]),
107    stdout: (at < 0 ? rest : rest.slice(0, at)).join('\n').trim(),
108    stderr: (at < 0 ? [] : rest.slice(at + 1)).join('\n').trim(),
109  }
110}
111
112/** The hosted repos out of `--call repos` (fleet-repo.sh list's two-space rows). */
113export function parseRepos(text: string): string[] {
114  const out: string[] = []
115  for (const line of parseReport(text).stdout.split('\n')) {
116    const m = /^ {2}(\S+\/\S+)\s/.exec(`${line} `)
117    if (m !== null && !out.includes(m[1] as string)) out.push(m[1] as string)
118  }
119  return out
120}
121
122/** The repo to start on: the last one used while it is still hosted, else the first. */
123export function pickRepo(repos: readonly string[], last: unknown): string {
124  return typeof last === 'string' && repos.includes(last) ? last : (repos[0] ?? '')
125}
126
127async function bin($: EngineInterface): Promise<string> {
128  return binDir(await $.env.get('FLEET_MCP_BIN'), await $.env.get('HOME'))
129}
130
131type Outcome = { ok: true; text: string } | { ok: false; text: string }
132
133/** One dispatch: ⌘P's own send — its first line is what it says, exit 0 = it went. */
134async function dispatch($: EngineInterface, title: string, repo: string): Promise<Outcome> {
135  try {
136    const r = await $.process.run(sendArgv(await bin($), title, repo), { timeoutMs: SEND_TIMEOUT_MS })
137    const said = (r.stdout.trim() || r.stderr.trim()).split('\n')[0] ?? ''
138    if (r.exitCode === 0) return { ok: true, text: said || t('qd_moved') }
139    return { ok: false, text: t('qd_failed_fmt', said || `exit ${r.exitCode}`) }
140  } catch (err) {
141    return { ok: false, text: t('qd_failed_fmt', err instanceof Error ? err.message : String(err)) }
142  }
143}
144
145async function loadRepos($: EngineInterface): Promise<string[]> {
146  try {
147    const r = await $.process.run(callArgv(await bin($), 'repos', {}), { timeoutMs: REPOS_TIMEOUT_MS })
148    return r.exitCode === 0 ? parseRepos(r.stdout) : []
149  } catch {
150    return []
151  }
152}
153
154/** The dialog's Enter: an empty title is refused in place, a second Enter while one runs is ignored. */
155async function submit($: EngineInterface, typed: string): Promise<void> {
156  const s = await read($, qd)
157  if (s.busy) return
158  const title = typed.trim()
159  if (title === '') {
160    await update($, qd, v => ({ ...v, title: typed, error: t('qd_empty') }))
161    return
162  }
163  if (s.repos.length > 0 && s.repo === '') {
164    await update($, qd, v => ({ ...v, title: typed, error: t('qd_norepo') }))
165    return
166  }
167  await update($, qd, v => ({ ...v, title: typed, busy: true, error: '' }))
168  const out = await dispatch($, title, s.repo)
169  if (!out.ok) {
170    await update($, qd, v => ({ ...v, busy: false, error: out.text }))
171    return
172  }
173  if (s.repo !== '') await $.store.set(LAST_REPO, s.repo).catch(() => undefined)
174  await update($, qd, v => ({ ...v, title: '', busy: false, error: '' }))
175  await $.ui.close({ id: QD_PANE })
176  $.ui.toast(out.text)
177}
178
179/** session.start: one tmux read decides; then the strings, then the command. Any failure costs /qd only. */
180async function start($: EngineInterface): Promise<void> {
181  on_ = false
182  const pane = await $.env.get('TMUX_PANE')
183  if (!pane || !(await $.env.get('TMUX'))) return
184  try {
185    const r = await $.process.run(windowArgv(pane), { timeoutMs: READ_TIMEOUT_MS })
186    if (r.exitCode !== 0 || !mayDispatch(r.stdout)) return
187  } catch {
188    return
189  }
190  try {
191    const r = await $.process.run(stringsArgv(await bin($)), { timeoutMs: READ_TIMEOUT_MS })
192    if (r.exitCode === 0) takeStrings(r.stdout)
193  } catch {
194    // Every key shows itself; the command still works.
195  }
196  try {
197    await $.command.register(qdCommand())
198    on_ = true
199  } catch {
200    // An engine without `immediate`, or a refused name: no /qd.
201  }
202}
203
204export const register: Register = on => {
205  on('session.start', async ($, e, next) => {
206    const out = await next(e)
207    await start($)
208    return out
209  }).catch(($, e, next) => next(e))
210
211  on('command.run', { command: QD_COMMAND }, async ($, e, next) => {
212    if (!on_) return next(e)
213    // Open first, at once; the repo list lands a moment later.
214    await update($, qd, v => ({ ...v, error: '', busy: false }))
215    await $.ui.open({ id: QD_PANE, title: t('qd_title'), focus: true, closeOnEscape: true, holdToasts: true, rows: 6 })
216    const repos = await loadRepos($)
217    const repo = pickRepo(repos, await $.store.get(LAST_REPO).catch(() => undefined))
218    await update($, qd, v => ({
219      ...v,
220      repos,
221      repo: repos.includes(v.repo) ? v.repo : repo,
222      error: repos.length === 0 ? t('qd_norepo') : v.error,
223    }))
224    return {}
225  }).catch(($, e, next) => next(e))
226
227  on('ui.render', { component: 'Pane', requestId: QD_PANE }, async ($, e, next) => {
228    const s = await read($, qd)
229    if (!on_) return next(e)
230    const ui = $.ui.resolve(e)
231    const { Box, Text } = ui
232    if (!('Input' in ui) || !('Select' in ui)) return <Text>{t('qd_moved')}</Text>
233    const { Input, Select } = ui
234    return (
235      <Box flexDirection="column">
236        <Input
237          key="qd-title"
238          label={t('qd_field_title')}
239          placeholder={t('qd_placeholder')}
240          value={s.title}
241          submitLabel={t('qd_submit')}
242          autoFocus
243          onInput={value => update($, qd, v => ({ ...v, title: value }))}
244          onSubmit={value => submit($, value)}
245        />
246        {s.repos.length > 0 && (
247          <Select
248            key="qd-repo"
249            label={t('qd_field_repo')}
250            options={s.repos.map(r => ({ value: r, label: r }))}
251            value={s.repo}
252            onSelect={value => update($, qd, v => ({ ...v, repo: value }))}
253          />
254        )}
255        <Text color="yellow">{t('qd_moved')}</Text>
256        {s.busy && <Text dimColor>{t('qd_sending')}</Text>}
257        {s.error !== '' && <Text color="red">{s.error}</Text>}
258        <Text dimColor>{t('qd_hint')}</Text>
259      </Box>
260    )
261  }).catch(($, e, next) => next(e))
262
263  // The prefix (off unless FLEET_ORCH_QD_PREFIX=1): `派:<title>` typed by hand
264  // over a running turn never enters the queue; idle (or not dispatched), it goes on as typed.
265  on('prompt.submit', { text: PREFIX_RE }, async ($, e, next) => {
266    if (!on_ || e.turnId === undefined || !byHand(e.origin)) return next(e)
267    if (!PREFIX_RE.test(e.text) || (await $.env.get('FLEET_ORCH_QD_PREFIX')) !== '1') return next(e)
268    const title = e.text.replace(PREFIX_RE, '').trim()
269    if (title === '') return next(e)
270    const repo = pickRepo(await loadRepos($), await $.store.get(LAST_REPO).catch(() => undefined))
271    const out = await dispatch($, title, repo)
272    $.ui.toast(out.text)
273    // Not dispatched: the words are not thrown away — they wait in the queue as typed.
274    if (!out.ok) return next(e)
275    if (repo !== '') await $.store.set(LAST_REPO, repo).catch(() => undefined)
276    return { drop: out.text }
277  }).catch(($, e, next) => next(e))
278}
279
types/index.d.ts 26 lines
1// The fleet-orchestrate plugin's contract (issue #3114, EPIC #3111 C3): the values
2// its mod module (hooks/qd.tsx) keeps in $.state.
3
4/** The /qd dialog (issue #2618; retired into ⌘P by #3328, kept one version — compat-1v): what it holds while open. */
5export type QdState = {
6  /** The title as typed — kept after a failed dispatch. */
7  title: string
8  /** The repo picked; '' while the list has not landed. */
9  repo: string
10  /** The fleet's hosted repos (`fleet-mcp.py --call repos`). */
11  repos: string[]
12  /** Why the last Enter did not dispatch ('' = nothing to say). */
13  error: string
14  /** A dispatch is running: a second Enter is ignored. */
15  busy: boolean
16}
17
18declare module 'claude-code' {
19  interface PluginState {
20    'fleet-orchestrate': {
21      /** The /qd dialog. */
22      qd: QdState
23    }
24  }
25}
26