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…

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.
<sub>Screenshots are the real UI captured from a live tmux server, staged with demo repo data.</sub>
/loop waits between iterations, green ✓ when a turn finishes, red ! + bell when a session is blocked on your answer. No polling lag — colors flip on the hook, not on the status-interval timer.FLEET_DASH_WINDOW=1): an fzf panel listing every session with state glyph, bound issue, model, and context %. It lives as an embedded pane in the plan hub, which holds the dash and nothing else; the task list replaced it by default (#1533), and its prefix+g / F9 keys left the node with the rest of the person's keys (#1714). Enter jumps. The prompt line at the bottom is the quick-scratch box: type a name and press Enter — it spawns a scratch session (own writable scratch-N worktree, no issue) named after that text, with the full text prefilled in the first input as an unsent, editable draft. Chinese and spaces are fine (the window title is capped at 24 columns — 12 CJK glyphs; the draft is not clipped). The prompt label is the fleet's default agent for a new session (claude ▸ / codex ▸); Ctrl-V flips it, persisted to the fleet's conf — that key (or the config modal) is how you pick the agent; typed text supplies the name and draft. Ctrl-N is the issue-bound path: it files a GitHub issue and spawns a worker session bound to it. (Every dash Ctrl- key is checked against your tmux prefix at launch and moved to its Alt- twin when it collides — tmux would eat it otherwise; ? shows the real key.) Ctrl-S opens the same raw scratch under its auto scratch-N name (plain claude, no issue — but in its own writable scratch-N worktree, so an experiment can push a branch and open a PR like any worker). Once a scratch has talked its way to a real requirement it can become the worker for it, in place: filing with fleet-issue-file.sh --title "…" --bind (or fleet-bind.sh <N> for an existing issue) renames its branch scratch-N → issue-N, binds the window and claims the issue — no second session re-grounding from zero (#520). Set FLEET_SCRATCH_POOL=1 to keep one pre-started and ready: ⌃s then hands you a session you can type into immediately (0.46s to the window, 0.30s to the first keystroke) instead of one that spends ~7s booting — the last second of which paints a ❯ box that silently swallows whatever you type.bin/tmux-issues.sh; its prefix+b door left the node with #1714 — #1739 decides the client's): open issues grouped by milestone (roadmap | unplanned panes). Enter on an issue creates a worktree issue-<N> off your base branch and starts claude seeded to read, claim, and implement it. Issues being worked show ▶ <window>. Manage issues without leaving tmux: the modal is list-only by default, and Space toggles a preview pane showing the highlighted issue's body, labels, milestone, assignees, and recent comments — word-wrapped to the pane so nothing splits mid-word. / turns on type-to-filter; Ctrl-X closes (triages) an issue after a y/n confirm; Ctrl-O opens it on the web. Priority shows as a p0/p1/p2 tag on each row and orders issues within a milestone; Ctrl-Y cycles a highlighted issue's priority (none→p2→p1→p0). Ctrl-N files a one-line issue fast.ccquota), use account-wide 5h/7d readings to warn before a limit, rotate early, stagger window starts, and optionally pause remote starts. See subscription and quota management.cw <branch> spawns a worktree + Claude window; an hourly janitor removes worktrees that are merged + clean + not attached to any live pane (and never anything else).conf/statusline.sh): wired as a measurement bus, not a visible line (issue #1452) — it prints nothing and stamps the context %, window size, model and effort level (plus the account's rate limits) onto the pane's tmux window, where the 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.Claude Code hooks (PreToolUse/PostToolUse/Stop/Notification)
│ instant, semantic-blind
▼
@claude_state on the tmux window ──► spinner daemon (0.12s frames, single
▲ writer, change-detected) ──► dash glyphs
│ slow, semantic + needs tally
LLM classifier (haiku, ~5min, change-gated)
collector daemon (60s) ──► cache files ──► fzf dashboard / backlog panels
git · gh PRs+issues · (read-only producers, render instantly)
ctx tokens · usage proxy
Design rules that made it work:
/loop between iterations). Both write the same @claude_state.tmux source-file per frame = one repaint); a single collector owns every cache file; producers are read-only.Deeper reference: docs/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.
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 fleet's slash commands, the base skills/ tree and the hook table also install as a Claude Code plugin, served by a marketplace in this same repo:
claude plugin marketplace add verkyyi/claude-fleet
claude plugin install fleet@claude-fleet --scope user --yes
That replaces the copy-and-merge passes above for those three, and /plugin update fleet keeps them current — so on a second machine, or a teammate's, nobody has to remember /fleet-sync-install for them. Plugin commands are namespaced (/fleet:fleet-claim); the fleet detects which install path a machine has and seeds the form that resolves, so spawns work either way. Codex does not consume Claude plugin commands directly; /fleet-sync-install generates native Codex skills from commands/*.md under each known $CODEX_HOME/skills/<name>/SKILL.md, so the same worker seed becomes $fleet-claim there.
It does not replace the playbook: bin/, conf/, the tmux layer and the daemons are machine-level and stay at ~/.claude/fleet. A plugin's install path is version-scoped and moves on every update, so nothing with a stable absolute path — a launchd unit, a tmux bind, a hook command — can point into it.
tmux ≥ 3.2 · fzf ≥ 0.45 (the dashboard binds use transform) · gh (authed — 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.)
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.
conf/tmux-attention.conf also carries an opinionated fleet baseline the UX assumes so a clean install behaves consistently: mouse on (the clickable footer + dashboard mouse), truecolor (default-terminal + a Tc terminal-overrides so the theme's hex colors render), escape-time 10 (snappy ESC in the Claude TUI), history-limit 50000, allow-rename/automatic-rename off (the fleet navigates by explicit window names), and the Tokyo-Night status / pane / message theme. Every line is documented inline and easy to override — put your own settings in ~/.tmux.conf after the source-file line (later wins) or comment the baseline out. Truly personal bits (prefix remaps, personal binds) are intentionally left in your ~/.tmux.conf.
One file, ~/.claude/fleet/fleet.conf (see fleet.conf.example; 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
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:
@repo), shown on the dash as a repo heading / short-tag badge (window names stay bare: issue-12);tokenledger (0); a no-repo row (or nothing to go on) starts it in $HOME, where the hub opens too;Folding a fleet 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).
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
hooks/qd.tsx 279 lines1// 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}
279types/index.d.ts 26 lines1// 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