claude-fleet steward: /fleet-steward takes the steward role in a session that holds no issue and runs its own patrol loop — answers due questions by their…

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/register.ts 22 lines1// The fleet-steward plugin's mod module (issue #3115, EPIC #3111 C4): the three
2// right-hand panels — the decision sheet, the patrol, the batches — moved out of
3// the core mod (mod/fleet). This file only ASSEMBLES; panels.ts owns the start,
4// the window's holder tick and the books, common.ts the one switch (isHolder).
5// Installed only with the plugin: no plugin, no /sheet and no pane anywhere.
6// Codex has no mod: its steward reads the text `[decision]` sheet.
7
8import type { Register } from 'claude-code'
9
10import { registerBatches } from './batches'
11import { registerPanels } from './panels'
12import { registerPatrol } from './patrol'
13import { registerSheet } from './sheet'
14
15export const register: Register = on => {
16 // before the patrol / batches: the sheet's band answers AbovePrompt only with a hint
17 registerSheet(on)
18 registerPatrol(on)
19 registerPanels(on)
20 registerBatches(on)
21}
22hooks/batches.tsx 175 lines1// The batches pane (issue #2833, EPIC #2831 C2): where every batch stands, who is
2// parked, what is left for the person — without asking anyone. Only looks and
3// jumps; the sidebar stays the navigator (共同约定 1). The fleet-steward plugin's
4// since #3115 (EPIC #3111 C4), drawn in the steward holder's window only.
5//
6// Two parts, one Pane (`fleet-batches`):
7// 在跑的批次 a row per epic-running.d mark — `landed/members` bar, 在做 (live)
8// · 待合 (inflight), 停着 (park.json by origin), 「心跳 HH:MM(N 分钟前)」,
9// yellow 「心跳停了」 past its ttl; a driver the book names with no mark
10// but the ledger has going: 「没写心跳 · 进度读不到」; a batch closed
11// today: one grey line. 「到驱动」 on each row with a driver key.
12// 待你动手 the steward's open `todo.items` (kind mark · what · due), 「看」
13// opens its source on the person's own computer (fleet-open.sh).
14//
15// The data is panels.ts's view (`fleet-steward/panels`, batches-model.ts folds
16// it), so a redraw runs nothing; a process starts only on a press — 「到驱动」
17// runs bin/fleet-panel-jump.sh <key> (fleet_win_for_key; NOTFOUND / AMBIGUOUS
18// toast, never a guess), 「看」 bin/fleet-open.sh <url>.
19//
20// Never opened unasked: `/sheet b` opens it. No holder here ⇒ nothing opens,
21// every hook passes through.
22
23import { atom, read } from 'claude-code'
24import type { EngineInterface, On } from 'claude-code'
25
26import type { PanelsView } from '../types'
27import { bar, beatClock, EMPTY_BOARD, todoIcon } from './batches-model'
28import { BATCHES_PANE, fleetBin, isHolder, t } from './common'
29import { BATCHES_ARG, panelsOn, SHEET_COMMAND } from './panels'
30
31export { BATCHES_PANE }
32export const JUMP_TIMEOUT_MS = 10_000
33
34const panels = atom({ plugin: 'fleet-steward', key: 'panels' } as const, null as PanelsView | null)
35
36// Module state: a reload is a fresh module.
37let asked = false
38
39/** Tests: forget the asked open. */
40export function resetBatches(): void {
41 asked = false
42}
43
44/** Does this window carry the batches pane? The steward holder's, with its panels running. */
45export function batchesWanted(): boolean {
46 return isHolder() && panelsOn()
47}
48
49export function jumpArgv(bin: string, key: string, pane: string): string[] {
50 return ['bash', `${bin}/fleet-panel-jump.sh`, key, ...(pane !== '' ? ['--pane', pane] : [])]
51}
52
53export function openArgv(bin: string, url: string): string[] {
54 return ['bash', `${bin}/fleet-open.sh`, url]
55}
56
57/** What a jump's exit code tells the person; '' when it switched. */
58export function jumpToast(code: number): string {
59 if (code === 0) return ''
60 if (code === 1) return t('panel_batches_jump_notfound')
61 if (code === 2) return t('panel_batches_jump_ambiguous')
62 return t('panel_batches_jump_failed')
63}
64
65async function jump($: EngineInterface, key: string): Promise<void> {
66 const pane = (await $.env.get('TMUX_PANE')) ?? ''
67 const r = await $.process.run(jumpArgv(fleetBin(), key, pane), { timeoutMs: JUMP_TIMEOUT_MS })
68 const msg = jumpToast(r.exitCode)
69 if (msg !== '') $.ui.toast(msg)
70}
71
72async function openBatches($: EngineInterface): Promise<boolean> {
73 const r = await $.ui.open({ id: BATCHES_PANE, title: t('panel_batches_title') })
74 return r.isPlaced
75}
76
77/** A batch row's text, without the button. */
78export function batchLine(b: PanelsView['board']['batches'][number], at: number): { text: string; stale: boolean } {
79 const name = b.title !== '' ? `#${b.epic} ${b.title}` : `#${b.epic}`
80 if (b.noMark) {
81 const parked = b.parked > 0 ? ` · ${t('panel_batches_parked_fmt', String(b.parked))}` : ''
82 return { text: `${name} ${t('panel_batches_nomark')}${parked}`, stale: false }
83 }
84 const parts: string[] = []
85 if (b.members > 0) parts.push(`${bar(b.landed, b.members)} ${t('panel_batches_landed_fmt', String(b.landed), String(b.members))}`)
86 if (b.live !== null && b.inflight !== null) parts.push(t('panel_batches_live_fmt', String(b.live), String(b.inflight)))
87 if (b.parked > 0) parts.push(t('panel_batches_parked_fmt', String(b.parked)))
88 const { hhmm, mins } = beatClock(b.epoch, at)
89 parts.push(t(b.fresh ? 'panel_batches_beat_fmt' : 'panel_batches_stale_fmt', hhmm, String(mins)))
90 return { text: `${name} ${parts.join(' · ')}`, stale: !b.fresh }
91}
92
93export function registerBatches(on: On): void {
94 on('command.run', { command: SHEET_COMMAND }, async ($, e, next) => {
95 if (!batchesWanted() || !BATCHES_ARG.test(e.args)) return next(e)
96 asked = true
97 const placed = await openBatches($)
98 return { text: t(placed ? 'panel_batches_opened' : 'panel_batches_unplaced') }
99 }).catch(($, e, next) => next(e))
100
101 on('ui.render', { component: 'Pane', requestId: BATCHES_PANE }, async ($, e, next) => {
102 // Read first: the read subscribes the pane to both states.
103 const view = await read($, panels)
104 if (!batchesWanted()) return next(e)
105 const { Box, Text, Button } = $.ui.resolve(e)
106 if (e.props.placement === 'inline' && !asked) {
107 return (
108 <Box key="fleet-batches" flexDirection="column">
109 <Text key="fleet-batches-hint" dimColor wrap="truncate-end">{t('panel_batches_inline')}</Text>
110 </Box>
111 )
112 }
113 const board = view?.board ?? EMPTY_BOARD
114 const at = view?.at ?? 0
115 return (
116 <Box key="fleet-batches" flexDirection="column">
117 <Text key="fleet-batches-h1" bold>{t('panel_batches_running')}</Text>
118 {board.batches.length === 0 && board.done.length === 0 && (
119 <Text key="fleet-batches-none" dimColor>{t('panel_batches_none')}</Text>
120 )}
121 {board.batches.map(b => {
122 const line = batchLine(b, at)
123 return (
124 <Box key={`fleet-batch-${b.ref}`} flexDirection="row">
125 <Text key={`fleet-batch-${b.ref}-text`} color={line.stale ? 'yellow' : undefined} wrap="truncate-end">
126 {line.text}
127 </Text>
128 {b.driver !== '' && (
129 <Button
130 key={`fleet-batch-${b.ref}-jump`}
131 label={t('panel_batches_jump')}
132 dimColor
133 onPress={() => {
134 void jump($, b.driver).catch(() => undefined)
135 }}
136 />
137 )}
138 </Box>
139 )
140 })}
141 {board.done.map(d => (
142 <Box key={`fleet-done-${d.ref}`} flexDirection="row">
143 <Text key={`fleet-done-${d.ref}-text`} dimColor wrap="truncate-end">
144 {`${d.title !== '' ? `#${d.epic} ${d.title}` : `#${d.epic}`} ${t('panel_batches_done')}`}
145 </Text>
146 </Box>
147 ))}
148 <Text key="fleet-batches-h2" bold>{t('panel_batches_todo')}</Text>
149 {board.todo.length === 0 && (
150 <Box key="fleet-todo-none" flexDirection="row">
151 <Text key="fleet-todo-none-text" dimColor>{t('panel_batches_todo_none')}</Text>
152 </Box>
153 )}
154 {board.todo.map(i => (
155 <Box key={`fleet-todo-${i.id}`} flexDirection="row">
156 <Text key={`fleet-todo-${i.id}-text`} wrap="truncate-end">
157 {`${todoIcon(i.kind)} ${i.what}${i.due !== '' ? ` · ${t('panel_batches_due_fmt', i.due)}` : ''}`}
158 </Text>
159 {i.url !== '' && (
160 <Button
161 key={`fleet-todo-${i.id}-open`}
162 label={t('panel_batches_open')}
163 dimColor
164 onPress={() => {
165 void $.process.run(openArgv(fleetBin(), i.url), { timeoutMs: JUMP_TIMEOUT_MS }).catch(() => undefined)
166 }}
167 />
168 )}
169 </Box>
170 ))}
171 </Box>
172 )
173 }).catch(($, e, next) => next(e))
174}
175hooks/panels.ts 531 lines1// The panels follow the books (issue #2835, EPIC #2831 C4): the base the three
2// right-hand panels (the decision sheet, the batches, the steward's patrol) all
3// stand on. The fleet-steward plugin's since issue #3115 (EPIC #3111 C4): no
4// plugin, no panels at all.
5//
6// Only in ONE window: the one holding the steward role (take_role steward,
7// common.ts's isHolder). Every fleet window that binds no issue starts this
8// module's one-second tick, which stats global/roles.held (one stat, no
9// process) and reads it when it moved: the window that holds the role starts the
10// panels, a window that dropped it (or had it stolen) stops them and closes its
11// panes. A worker's window (it binds an issue) and a window outside tmux start
12// nothing. FLEET_MOD_PANELS=0 keeps them off everywhere.
13//
14// No polling of commands: the same one-second tick calls panelsTick, which
15// stats FOUR paths through `$.fs` — global/steward.stamp (the
16// steward writes `<seq> <epoch_ms>` after every State.save()), the
17// orchestrator's children ledger, global/epic-running.d/ and global/park.json —
18// and re-reads the books only when one of them moved (mtime/size). A full read
19// every PANELS_FULL_MS covers a writer that forgets the stamp (BREAK-IT
20// `panel-stale`). Nothing here runs a process.
21//
22// `$` is never handed across an import (the engine follows it only within one
23// file), so this file's session.start binds a PanelsIo from its own `$` calls,
24// and its `publish` stores the view in the `fleet-steward/panels` state — every
25// reader redraws on its own.
26//
27// Each read is timed (performance.now()) into $FLEET_CONF_DIR/logs/panel.ndjson
28// (`refresh` rows, the last 200), and the first time a new sheet is drawn its
29// 「写出→看见」 seconds (`seen`, from the sheet's own `at`); `/sheet --stats`
30// prints p50 / p95 and the last five. The decision sheet's answers (sheet.tsx,
31// issue #2832) are `answer` rows handed over through noteAnswer and written on
32// the next tick; `/sheet --stats` prints their median presses. Rows of another
33// kind in that file are kept as they are.
34
35import { atom, read, update } from 'claude-code'
36import type { CommandSpec, EngineInterface, On, Timer } from 'claude-code'
37
38import {
39 BATCHES_PANE, PATROL_PANE, SHEET_OPTION, SHEET_PANE, TMUX_TIMEOUT_MS, binDir, holdsSteward, isHolder, parseWindow,
40 setBin, setHeld, stringsArgv, t, takeStrings, windowArgv, windowOptionsArgv,
41} from './common'
42import type { Self } from './common'
43import { foldPanels, percentile } from './panels-model'
44import type { PanelsView } from '../types'
45import type { PanelsText } from './panels-model'
46
47/** The full read that covers a missed stamp (ms). */
48export const PANELS_FULL_MS = 10_000
49/** The tick: roles.held, then the four books (ms). */
50export const TICK_MS = 1_000
51export const SHEET_COMMAND = 'sheet'
52/** `/sheet b` (batches, 批次): the batches pane's own (batches.tsx, issue #2833) — passed on here. */
53export const BATCHES_ARG = /^\s*(b|batches|批次)\s*$/
54/** `/sheet p` (patrol, 巡查): the patrol pane's own (patrol.tsx) — passed on here. */
55export const PATROL_ARG = /^\s*(p|patrol|巡查)\s*$/
56export const REFRESH_KEEP = 200
57export const SEEN_KEEP = 50
58export const ANSWER_KEEP = 100
59/** The file's own cap, every kind counted. */
60export const LOG_KEEP = 500
61
62/** The `fleet-steward/panels` state: written here, read by every pane. */
63const panels = atom({ plugin: 'fleet-steward', key: 'panels' } as const, null as PanelsView | null)
64
65/** The `$` calls one tick needs, bound by this file's session.start. */
66export type PanelsIo = {
67 /** `mtimeMs:size`, or '-' when missing. */
68 stat: (path: string) => Promise<string>
69 /** A directory's file names ([] when missing). */
70 list: (dir: string) => Promise<string[]>
71 /** Rejects when missing. */
72 read: (path: string) => Promise<string>
73 write: (path: string, text: string) => Promise<void>
74 /** Epoch ms. */
75 now: () => Promise<number>
76 publish: (view: PanelsView) => Promise<void>
77}
78
79export type PanelPaths = {
80 /** Who holds which role (bin/fleet-role-take.sh). */
81 held: string
82 stamp: string
83 state: string
84 delta: string
85 marks: string
86 park: string
87 ledger: string
88 log: string
89}
90
91export function panelPaths(confDir: string, session: string): PanelPaths {
92 const c = confDir.replace(/\/+$/, '')
93 return {
94 held: `${c}/global/roles.held`,
95 stamp: `${c}/global/steward.stamp`,
96 state: `${c}/global/steward.state.json`,
97 delta: `${c}/global/steward.delta.json`,
98 marks: `${c}/global/epic-running.d`,
99 park: `${c}/global/park.json`,
100 ledger: `${c}/fleets/${session}/children/orchestrator.ndjson`,
101 log: `${c}/logs/panel.ndjson`,
102 }
103}
104
105/** The four paths a tick stats — never more (共同约定 5). */
106export function watched(p: PanelPaths): string[] {
107 return [p.stamp, p.ledger, p.marks, p.park]
108}
109
110/** May this window ever hold the panels? The switch, a fleet session to read, no bound issue. */
111export function panelsWanted(self: Self, flag: string | undefined, session: string | undefined): boolean {
112 if (flag === '0') return false
113 if (session === undefined || session === '') return false
114 return !/^\d+$/.test(self.issue)
115}
116
117/** `$TMUX`'s socket label — the fleet session's name (#159). */
118export function sessionOf(tmux: string | undefined): string | undefined {
119 const sock = tmux?.split(',')[0]?.split('/').pop() ?? ''
120 return sock === '' ? undefined : sock
121}
122
123/** `immediate`: it runs while a turn does (issue #2836). */
124export function sheetCommand(immediate = true): CommandSpec {
125 const spec: CommandSpec = { name: SHEET_COMMAND, description: t('panel_cmd_desc') }
126 return immediate ? { ...spec, immediate: true } : spec
127}
128
129// Module state: a reload is a fresh module, and session.start starts it again.
130let paths: PanelPaths | undefined
131let session = ''
132let print = ''
133let busy = false
134let lastSheet = ''
135let reads = 0
136let logLoaded = false
137let refreshRows: string[] = []
138let seenRows: string[] = []
139let answerRows: string[] = []
140let lastFlush = 0
141let dirty = false
142
143/** The holder tick: this window's panels run, reading these books (of this fleet session). */
144export function startPanels(p: PanelPaths, sess = ''): void {
145 paths = p
146 session = sess
147 print = ''
148}
149
150/** The fleet session the books are this window's (sheet.tsx's answers name it). */
151export function currentSession(): string {
152 return session
153}
154
155/** sheet.tsx: one answer sent — written to the log on the next tick. */
156export function noteAnswer(row: { kind: 'answer'; ts: number; gid: string; how: string; presses: number; ms: number }): void {
157 answerRows.push(JSON.stringify(row))
158 dirty = true
159}
160
161export function stopPanels(): void {
162 paths = undefined
163}
164
165export function panelsOn(): boolean {
166 return paths !== undefined
167}
168
169export function currentPaths(): PanelPaths | undefined {
170 return paths
171}
172
173/** Tests: forget everything. */
174export function resetPanels(): void {
175 paths = undefined
176 session = ''
177 print = ''
178 busy = false
179 lastSheet = ''
180 reads = 0
181 logLoaded = false
182 refreshRows = []
183 seenRows = []
184 answerRows = []
185 lastFlush = 0
186 dirty = false
187}
188
189async function readOr(io: PanelsIo, path: string): Promise<string> {
190 try {
191 return await io.read(path)
192 } catch {
193 return ''
194 }
195}
196
197async function readAll(io: PanelsIo, p: PanelPaths): Promise<PanelsText> {
198 const names = (await io.list(p.marks).catch(() => [] as string[])).filter(n => !n.startsWith('.')).sort()
199 const [state, delta, park, ledger, ...marks] = await Promise.all([
200 readOr(io, p.state), readOr(io, p.delta), readOr(io, p.park), readOr(io, p.ledger),
201 ...names.map(n => readOr(io, `${p.marks}/${n}`)),
202 ])
203 return {
204 state: state ?? '', delta: delta ?? '', park: park ?? '', ledger: ledger ?? '',
205 marks: names.map((name, i) => ({ name, text: marks[i] ?? '' })),
206 }
207}
208
209function kindOf(line: string): string {
210 try {
211 const v = JSON.parse(line) as { kind?: unknown }
212 return typeof v.kind === 'string' ? v.kind : ''
213 } catch {
214 return ''
215 }
216}
217
218/**
219 * Write the log: the other writers' rows as they are, then ours (the last
220 * SEEN_KEEP `seen`, the last REFRESH_KEEP `refresh`), the file capped at LOG_KEEP.
221 * The first flush takes the file's own rows of ours back, so a reload keeps them.
222 */
223async function flush(io: PanelsIo, p: PanelPaths): Promise<void> {
224 const text = await readOr(io, p.log)
225 const others: string[] = []
226 const oldRefresh: string[] = []
227 const oldSeen: string[] = []
228 const oldAnswers: string[] = []
229 for (const line of text.split('\n')) {
230 if (line.trim() === '') continue
231 const k = kindOf(line)
232 if (k === 'refresh') oldRefresh.push(line)
233 else if (k === 'seen') oldSeen.push(line)
234 else if (k === 'answer') oldAnswers.push(line)
235 else others.push(line)
236 }
237 if (!logLoaded) {
238 refreshRows = [...oldRefresh, ...refreshRows]
239 seenRows = [...oldSeen, ...seenRows]
240 answerRows = [...oldAnswers, ...answerRows]
241 logLoaded = true
242 }
243 refreshRows = refreshRows.slice(-REFRESH_KEEP)
244 seenRows = seenRows.slice(-SEEN_KEEP)
245 answerRows = answerRows.slice(-ANSWER_KEEP)
246 const ours = seenRows.length + refreshRows.length + answerRows.length
247 const all = [...others.slice(-(LOG_KEEP - ours)), ...answerRows, ...seenRows, ...refreshRows]
248 await io.write(p.log, `${all.join('\n')}\n`)
249}
250
251/**
252 * One tick. Off (no panels here) does nothing at all; otherwise stat the four
253 * paths and, when one moved — or `full` — read every book, publish the view and
254 * log the time it took. Returns what it did.
255 */
256export async function panelsTick(io: PanelsIo, full = false): Promise<'off' | 'busy' | 'same' | 'read'> {
257 const p = paths
258 if (p === undefined) return 'off'
259 if (busy) return 'busy'
260 busy = true
261 try {
262 const t0 = performance.now()
263 const now = watched(p)
264 const fp = (await Promise.all(now.map(path => io.stat(path).catch(() => '-')))).join('|')
265 if (!full && fp === print) {
266 if (dirty) {
267 dirty = false
268 await flush(io, p).catch(() => undefined)
269 }
270 return 'same'
271 }
272 print = fp
273 const src = await readAll(io, p)
274 const at = await io.now()
275 const view = foldPanels(src, at, 0)
276 const ms = Math.round((performance.now() - t0) * 100) / 100
277 view.ms = ms
278 await io.publish(view)
279 reads++
280 refreshRows.push(JSON.stringify({ kind: 'refresh', ts: at, ms, why: full ? 'full' : 'stamp' }))
281 // 「写出→看见」: a sheet this module has not drawn before, seen now. The first
282 // read after a start finds an old sheet — that one is no measurement.
283 const sheetKey = view.sheet === null ? '' : `${view.sheet.id}@${view.sheet.at}`
284 let seen = false
285 if (sheetKey !== lastSheet) {
286 const wrote = view.sheet === null ? NaN : Date.parse(view.sheet.at)
287 if (reads > 1 && view.sheet !== null && view.sheet.rows.length > 0 && Number.isFinite(wrote)) {
288 seenRows.push(JSON.stringify({
289 kind: 'seen', ts: at, sheet: view.sheet.id, at: wrote, s: Math.max(0, Math.round((at - wrote) / 100) / 10),
290 }))
291 seen = true
292 }
293 lastSheet = sheetKey
294 }
295 if (seen || dirty || at - lastFlush >= PANELS_FULL_MS) {
296 lastFlush = at
297 dirty = false
298 await flush(io, p).catch(() => undefined)
299 }
300 return 'read'
301 } finally {
302 busy = false
303 }
304}
305
306/** `/sheet --stats`: p50 / p95 of the refreshes, the last five 「写出→看见」. */
307export function statsText(log: string): string {
308 const ms: number[] = []
309 const seen: number[] = []
310 const presses: number[] = []
311 for (const line of log.split('\n')) {
312 if (line.trim() === '') continue
313 try {
314 const v = JSON.parse(line) as { kind?: unknown; ms?: unknown; s?: unknown; presses?: unknown }
315 if (v.kind === 'refresh' && typeof v.ms === 'number') ms.push(v.ms)
316 if (v.kind === 'seen' && typeof v.s === 'number') seen.push(v.s)
317 if (v.kind === 'answer' && typeof v.presses === 'number') presses.push(v.presses)
318 } catch {
319 // A torn line is skipped.
320 }
321 }
322 const last = ms.slice(-REFRESH_KEEP)
323 const lines = [
324 last.length === 0 ? t('panel_stats_none')
325 : t('panel_stats_fmt', String(last.length), String(percentile(last, 50)), String(percentile(last, 95))),
326 seen.length === 0 ? t('panel_seen_none')
327 : t('panel_seen_fmt', String(Math.min(5, seen.length)), seen.slice(-5).join(' · ')),
328 presses.length === 0 ? t('panel_answer_none')
329 : t('panel_answer_stats_fmt', String(presses.length), String(percentile(presses, 50))),
330 ]
331 return lines.join('\n')
332}
333
334/** `/sheet --summary`: one line of what the books say. */
335export function summaryText(v: PanelsView | null): string {
336 if (v === null) return t('panel_empty')
337 const open = v.sheet?.rows.filter(r => r.state === 'open').length ?? 0
338 const live = v.batches.filter(b => b.fresh).length
339 return t('panel_summary_fmt', String(open), String(live), String(v.patrol.parked.length), v.patrol.at || '-')
340}
341
342// ---- the window: who it is, whether it holds the role, the timers --------------
343
344let pane: string | undefined
345let self: Self = { wid: '', fid: '', issue: '' }
346let watch: PanelPaths | undefined
347let heldPrint = ''
348let tickTimer: Timer | undefined
349let fullTimer: Timer | undefined
350let ticking = false
351
352/** Tests: forget the window. */
353export function resetWindow(): void {
354 pane = undefined
355 self = { wid: '', fid: '', issue: '' }
356 watch = undefined
357 heldPrint = ''
358 tickTimer?.cancel()
359 fullTimer?.cancel()
360 tickTimer = undefined
361 fullTimer = undefined
362 ticking = false
363}
364
365function panelsIo($: EngineInterface): PanelsIo {
366 return {
367 stat: async path => {
368 try {
369 const st = await $.fs.stat(path)
370 return `${st.mtimeMs}:${st.size}`
371 } catch {
372 return '-'
373 }
374 },
375 list: async dir => (await $.fs.list(dir)).filter(f => f.kind === 'file').map(f => f.name),
376 read: path => $.fs.read(path),
377 write: (path, text) => $.fs.write(path, text),
378 now: () => $.clock.now(),
379 publish: async view => {
380 await update($, panels, () => view)
381 },
382 }
383}
384
385async function setSheetOption($: EngineInterface, value: string | null): Promise<void> {
386 if (pane === undefined) return
387 const argv = windowOptionsArgv(pane, { [SHEET_OPTION]: value })
388 if (argv !== null) await $.process.run(argv, { timeoutMs: TMUX_TIMEOUT_MS }).catch(() => undefined)
389}
390
391/** roles.held moved: read it; this window took the role ⇒ start, lost it ⇒ stop and close its panes. */
392export async function holderTick($: EngineInterface): Promise<boolean> {
393 const p = watch
394 if (p === undefined) return false
395 let print = '-'
396 try {
397 const st = await $.fs.stat(p.held)
398 print = `${st.mtimeMs}:${st.size}`
399 } catch {
400 print = '-'
401 }
402 if (print === heldPrint) return isHolder()
403 heldPrint = print
404 let text = ''
405 try {
406 text = print === '-' ? '' : await $.fs.read(p.held)
407 } catch {
408 text = ''
409 }
410 const on = holdsSteward(text, self)
411 if (on === isHolder()) return on
412 setHeld(on)
413 if (on) {
414 startPanels(p, session || sessionFromPaths(p))
415 await tickPanels($, true)
416 } else {
417 stopPanels()
418 await update($, panels, () => null)
419 for (const id of [SHEET_PANE, PATROL_PANE, BATCHES_PANE]) await $.ui.close({ id }).catch(() => undefined)
420 await setSheetOption($, null)
421 }
422 return on
423}
424
425function sessionFromPaths(p: PanelPaths): string {
426 return /\/fleets\/([^/]+)\/children\//.exec(p.ledger)?.[1] ?? ''
427}
428
429async function tickPanels($: EngineInterface, full = false): Promise<void> {
430 if (!panelsOn()) return
431 try {
432 await panelsTick(panelsIo($), full)
433 } catch {
434 // The next tick reads again; a panel never holds up the session.
435 }
436}
437
438async function tick($: EngineInterface): Promise<void> {
439 if (ticking) return
440 ticking = true
441 try {
442 if (await holderTick($)) await tickPanels($)
443 } catch {
444 // The next tick tries again.
445 } finally {
446 ticking = false
447 }
448}
449
450export function registerPanels(on: On): void {
451 // The one unmatched session.start of this plugin: one tmux read decides whether
452 // this window may ever hold the role (a fleet pane binding no issue), then the
453 // strings, /sheet, and the tick. Any failure costs the panels only.
454 on('session.start', async ($, e, next) => {
455 const result = await next(e)
456 try {
457 const id = await $.env.get('TMUX_PANE')
458 if (id === undefined || id === '') return result
459 const sess = sessionOf(await $.env.get('TMUX'))
460 const home = (await $.env.get('HOME')) ?? ''
461 const conf = (await $.env.get('FLEET_CONF_DIR')) || `${home}/.config/claude-fleet`
462 const dir = binDir(await $.env.get('FLEET_MCP_BIN'), home)
463 setBin(dir)
464 const r = await $.process.run(windowArgv(id), { timeoutMs: TMUX_TIMEOUT_MS })
465 const me = parseWindow(r.exitCode === 0 ? r.stdout : '')
466 if (r.exitCode !== 0 || !panelsWanted(me, await $.env.get('FLEET_MOD_PANELS'), sess)) return result
467 pane = id
468 self = me
469 watch = panelPaths(conf, sess as string)
470 session = sess as string
471 heldPrint = ''
472 try {
473 const s = await $.process.run(stringsArgv(dir), { timeoutMs: TMUX_TIMEOUT_MS })
474 if (s.exitCode === 0) takeStrings(s.stdout)
475 } catch {
476 // Every key shows itself.
477 }
478 try {
479 await $.command.register(sheetCommand())
480 } catch {
481 // An engine without `immediate`: /sheet between turns; a refused name: none.
482 await $.command.register(sheetCommand(false)).catch(() => undefined)
483 }
484 await tick($)
485 tickTimer?.cancel()
486 tickTimer = $.clock.every(TICK_MS, () => {
487 void tick($)
488 })
489 fullTimer?.cancel()
490 fullTimer = $.clock.every(PANELS_FULL_MS, () => {
491 if (isHolder()) void tickPanels($, true)
492 })
493 } catch {
494 // No panels in this window; the session goes on.
495 }
496 return result
497 }).catch(($, e, next) => next(e))
498
499 on('session.end', async ($, e, next) => {
500 if (e.reason !== 'reload') {
501 tickTimer?.cancel()
502 fullTimer?.cancel()
503 tickTimer = undefined
504 fullTimer = undefined
505 if (isHolder()) await setSheetOption($, null)
506 stopPanels()
507 }
508 return next(e)
509 }).catch(($, e, next) => next(e))
510
511 on('command.run', { command: SHEET_COMMAND }, async ($, e, next) => {
512 if (watch === undefined) return next(e)
513 // Nobody here holds the steward role: say so — nothing opens, nothing answers.
514 if (!isHolder()) return { text: t('panel_not_holder') }
515 if (paths === undefined || BATCHES_ARG.test(e.args) || PATROL_ARG.test(e.args)) return next(e)
516 if (/(^|\s)--stats(\s|$)/.test(e.args)) {
517 let log = ''
518 try {
519 log = await $.fs.read(paths.log)
520 } catch {
521 log = ''
522 }
523 return { text: statsText(log) }
524 }
525 // A bare /sheet opens / closes the decision sheet (sheet.tsx); `--summary` is
526 // the one line of the books.
527 if (!/(^|\s)--summary(\s|$)/.test(e.args)) return next(e)
528 return { text: summaryText(await read($, panels)) }
529 }).catch(($, e, next) => next(e))
530}
531hooks/patrol.tsx 182 lines1// The steward's patrol panel (issue #2834, EPIC #2831 C3): what the steward's
2// last beat looked at, who is parked waiting for what, what it answered itself
3// and what went by default, the health findings it follows, when the next beat
4// is — read off the books in five seconds, without typing a word.
5//
6// The steward holder's window only (common.ts isHolder, and the panels on —
7// panels.ts; the fleet-steward plugin's since #3115): every other window draws
8// nothing and opens nothing. The view is panels.ts's `fleet-steward/panels`
9// state, read on the books' change stamp; drawing it runs no process. Two
10// buttons, only when pressed: 「到编排会话」 (bin/fleet-panel-jump.sh orchestrator
11// — the one resolver, never a guess) and 「看决定单」 (`/sheet`: the decision
12// sheet is in this same window now).
13//
14// Never opened unasked (the decision sheet is what opens by itself in this
15// window, sheet.tsx): `/sheet p` opens it.
16
17import { atom, read } from 'claude-code'
18import type { EngineInterface, On } from 'claude-code'
19
20import { PATROL_PANE, TMUX_TIMEOUT_MS, fleetBin, isHolder, t } from './common'
21import { PATROL_ARG, SHEET_COMMAND, panelsOn } from './panels'
22import type { Patrol, PanelsView } from './panels-model'
23
24export { PATROL_PANE }
25/** The steward's write budget a beat when FLEET_STEWARD_WRITES says nothing (bin/fleet_steward.py). */
26export const WRITES_DEFAULT = 20
27
28const panels = atom({ plugin: 'fleet-steward', key: 'panels' } as const, null as PanelsView | null)
29
30/** Tests: nothing is kept between tests now (no unasked open). */
31export function resetPatrol(): void {}
32
33/** This window shows the patrol: the steward holder's, with the panels on. */
34export function patrolHere(): boolean {
35 return panelsOn() && isHolder()
36}
37
38/** The UTC offset an ISO time carries, in minutes (null: none). */
39export function offsetOf(iso: string): number | null {
40 if (/Z$/.test(iso)) return 0
41 const m = /([+-])(\d\d):?(\d\d)$/.exec(iso)
42 if (m === null) return null
43 return (m[1] === '-' ? -1 : 1) * (Number(m[2]) * 60 + Number(m[3]))
44}
45
46/** `MM-DD HH:MM` of an epoch second on the steward's clock (the beat's own offset). */
47function stamp(sec: number, offset: number): string {
48 return new Date((sec + offset * 60) * 1000).toISOString().slice(5, 16).replace('T', ' ')
49}
50
51/** `HH:MM` of an epoch second on the steward's clock, its date too when it is not `day` (`MM-DD`). */
52export function clockOf(sec: number, offset: number, day = ''): string {
53 if (sec <= 0) return '-'
54 const s = stamp(sec, offset)
55 return day === '' || s.slice(0, 5) === day ? s.slice(6) : s
56}
57
58export type PatrolSection = { id: string; head: string; lines: string[] }
59export type PatrolModel = { title: string; changed: boolean; sections: PatrolSection[]; foot: string }
60
61/** The patrol in text: the title, five sections, the footer. Pure. */
62export function patrolModel(p: Patrol, writesCap: number): PatrolModel {
63 const off = offsetOf(p.at) ?? 0
64 const day = p.at.slice(5, 10)
65 const at = p.at.length >= 16 ? p.at.slice(11, 16) : '-'
66 const parked = p.parked.map(k => t('panel_patrol_parked_fmt',
67 k.key || k.ref, k.wait.length > 0 ? k.wait.join(' · ') : '-', clockOf(k.at, off, day)))
68 return {
69 title: p.beat === 0 ? t('panel_patrol_none')
70 : t('panel_patrol_title_fmt', String(p.beat), at, t(p.changed ? 'panel_patrol_changed' : 'panel_patrol_calm')),
71 changed: p.changed,
72 sections: [
73 {
74 id: 'seen',
75 head: t('panel_patrol_seen'),
76 lines: [
77 t('panel_patrol_seen_fmt', String(p.drivers), String(p.openRows)),
78 t('panel_patrol_delta_fmt', String(p.events), String(p.newAsks), String(p.closed)),
79 ],
80 },
81 {
82 id: 'park',
83 head: t('panel_patrol_park_fmt', String(p.parked.length)),
84 lines: parked.length > 0 ? parked : [t('panel_patrol_park_none')],
85 },
86 {
87 id: 'answers',
88 head: t('panel_patrol_answers'),
89 lines: [t('panel_patrol_answers_fmt', String(p.bySteward), String(p.byDefault), String(p.groups))],
90 },
91 {
92 id: 'health',
93 head: t('panel_patrol_health_fmt', String(p.health)),
94 lines: [
95 ...(p.healthTop === '' ? [] : [p.healthTop]),
96 // issue #3352: sessions that should have been reaped but were not
97 ...(p.overdue > 0
98 ? [t('panel_patrol_reap_fmt', String(p.overdue), String(p.overdueStuck)), ...(p.overdueTop === '' ? [] : [p.overdueTop])]
99 : []),
100 ],
101 },
102 {
103 id: 'next',
104 head: t('panel_patrol_next_fmt', clockOf(p.nextAt, off, day)),
105 lines: [t('panel_patrol_night')],
106 },
107 ],
108 foot: t('panel_patrol_foot_fmt', String(p.writes), String(writesCap), String(p.modelCalls), String(p.deferred)),
109 }
110}
111
112async function run($: EngineInterface, argv: string[]): Promise<number> {
113 try {
114 return (await $.process.run(argv, { timeoutMs: TMUX_TIMEOUT_MS })).exitCode
115 } catch {
116 return 1
117 }
118}
119
120/** 「到编排会话」: rc 1 / 2 is said, never guessed past. */
121async function jump($: EngineInterface): Promise<void> {
122 const rc = await run($, ['bash', `${fleetBin()}/fleet-panel-jump.sh`, 'orchestrator'])
123 if (rc === 1) $.ui.toast(t('panel_patrol_jump_none'))
124 else if (rc !== 0) $.ui.toast(t('panel_patrol_jump_ambiguous'))
125}
126
127/** 「看决定单」: the decision sheet — in this same window (`/sheet`). */
128async function sheet($: EngineInterface): Promise<void> {
129 await $.command.run({ command: SHEET_COMMAND, args: '' }).catch(() => undefined)
130}
131
132export function registerPatrol(on: On): void {
133 // `/sheet p` in the holder's window opens the patrol (asked: placed from 110
134 // columns, focused, Esc closes it) — or closes it when it is placed (issue
135 // #2836: an open but unplaced pane is opened, never closed); anything else
136 // goes on (bare /sheet is the decision sheet's, sheet.tsx).
137 on('command.run', { command: SHEET_COMMAND }, async ($, e, next) => {
138 if (!patrolHere() || !PATROL_ARG.test(e.args)) return next(e)
139 if ((await $.ui.panes()).some(p => p.id === PATROL_PANE && p.isPlaced)) {
140 await $.ui.close({ id: PATROL_PANE })
141 return { text: t('panel_patrol_closed') }
142 }
143 const opened = await $.ui.open({ id: PATROL_PANE, title: t('panel_patrol_pane'), focus: true, closeOnEscape: true })
144 return { text: opened.isPlaced ? t('panel_patrol_opened') : t('panel_patrol_waits') }
145 }).catch(($, e, next) => next(e))
146
147 on('ui.render', { component: 'Pane', requestId: PATROL_PANE }, async ($, e, next) => {
148 const view = await read($, panels)
149 if (!patrolHere()) return next(e)
150 const { Box, Text, Button } = $.ui.resolve(e)
151 if (view === null) return <Text dimColor>{t('panel_empty')}</Text>
152 const cap = Number((await $.env.get('FLEET_STEWARD_WRITES')) ?? '') || WRITES_DEFAULT
153 const m = patrolModel(view.patrol, cap)
154 return (
155 <Box key="patrol" flexDirection="column">
156 <Text key="patrol-title" bold color={m.changed ? 'yellow' : 'green'} wrap="truncate-end">
157 {m.title}
158 </Text>
159 {m.sections.map(s => (
160 <Box key={`patrol-${s.id}`} flexDirection="column" marginTop={1}>
161 <Text key={`patrol-${s.id}-head`} bold wrap="truncate-end">
162 {s.head}
163 </Text>
164 {s.lines.map((line, i) => (
165 <Text key={`patrol-${s.id}-${i}`} dimColor wrap="truncate-end">
166 {line}
167 </Text>
168 ))}
169 </Box>
170 ))}
171 <Box key="patrol-buttons" flexDirection="row" marginTop={1}>
172 <Button key="patrol-orch" label={t('panel_patrol_to_orch')} hotkey="o" onPress={() => void jump($)} />
173 <Button key="patrol-sheet" label={t('panel_patrol_to_sheet')} hotkey="s" onPress={() => void sheet($)} />
174 </Box>
175 <Text key="patrol-foot" dimColor wrap="truncate-end">
176 {m.foot}
177 </Text>
178 </Box>
179 )
180 }).catch(($, e, next) => next(e))
181}
182hooks/sheet.tsx 398 lines1// The decision sheet, a pane on the steward holder's right (issue #2832, EPIC #2831
2// C1; the fleet-steward plugin's since #3115, EPIC #3111 C4).
3//
4// What the person has to decide, one line a thing: what · the suggestion · what
5// happens if nobody answers · the deadline — and three buttons a line: 按建议
6// (`y`), 翻案 / 我来答 (`f`, a one-line field, ↵ sends), 看原话 (`t`, the ticket
7// opened on the person's own computer through fleet-open.sh). Open things on
8// top; answered and defaulted ones dim at the bottom, a defaulted one marked
9// 「已按默认」 + its time (never a never:* one — those are never defaulted).
10//
11// The lines are the steward's own `groups` (bin/fleet_decision.py group, kept in
12// global/steward.state.json on every save): the panel never merges, sorts or
13// judges a row itself (EPIC #2831 共同约定 4), so the sheet's text and the panel
14// can never count differently. panels.ts reads the books; this file only draws
15// `fleet/panels` and writes nothing but its own state.
16//
17// An answer waits UNDO_MS in 「已答 · 撤回」 (`u` takes it back), then runs the ONE
18// write road, `fleet-steward-tick.sh answer --row <id> --text <t> --by person`,
19// once per row id of the group (a merged group = each of its ids, the same
20// words). A failure puts the line back as it was and toasts the reason. Each
21// answer sent is one `answer` row in logs/panel.ndjson (panels.ts writes the
22// file): `{kind:'answer', ts, gid, how, presses, ms}` — `/sheet --stats` prints
23// the median presses.
24//
25// Only the window holding the steward role draws it (common.ts isHolder — the
26// orchestrator's before #3115): the first band drawn on a fullscreen
27// terminal of AUTO_COLUMNS or more opens it unasked, once (the engine seats an
28// unasked pane only from 144 columns; the main screen would set it inline over
29// the prompt — no), bare `/sheet` (immediate: it runs while a turn does) opens
30// it — asked, focused, Esc closes it, placed down to 110 columns — when it is
31// not placed, and closes it only when it is (issue #2836: a pane open but
32// unplaced is opened, never closed). Narrower, or on the main screen, the band
33// above the prompt says 「决定单 N 件待定」 with an 打开 button (`s`) while
34// open lines wait and the pane is not placed (sheetHint, drawn here as the
35// band; otherwise the band is passed on). The window's
36// `@sheet_pane` says 1 while it is up — the steward then sends one sentence
37// instead of the whole table; never opened, no option, the whole sheet as
38// before. Any other window: no pane, no `/sheet` handling here.
39
40import { atom, read, update } from 'claude-code'
41import type { EngineInterface, On, Timer } from 'claude-code'
42
43import type { PanelsView, SheetGroup, SheetPending, SheetUi } from '../types'
44import { SHEET_OPTION, SHEET_PANE, TMUX_TIMEOUT_MS, fleetBin, isHolder, t, windowOptionsArgv } from './common'
45import { SHEET_COMMAND, currentSession, noteAnswer, panelsOn } from './panels'
46
47export { SHEET_OPTION, SHEET_PANE }
48/** How long an answer can be taken back before it is sent. */
49export const UNDO_MS = 10_000
50/** fleet-steward-tick.sh answer posts one comment; its own timeouts are 60 s. */
51export const ANSWER_TIMEOUT_MS = 90_000
52export const OPEN_TIMEOUT_MS = 20_000
53/** The engine places an unasked pane only from here; below it nothing opens unasked. */
54export const AUTO_COLUMNS = 144
55
56export const SHEET_IDLE: SheetUi = { pending: {}, editing: '', expanded: '', sending: [], presses: {} }
57
58const panels = atom({ plugin: 'fleet-steward', key: 'panels' } as const, null as PanelsView | null)
59const sheet = atom({ plugin: 'fleet-steward', key: 'sheet' } as const, SHEET_IDLE)
60/** Bumped on every open / close, so the band's hint redraws (the truth is $.ui.panes()). */
61const sheetPane = atom({ plugin: 'fleet-steward', key: 'sheetPane' } as const, 0)
62
63// The undo timers: a module's, so a reload re-arms them from the state (session.start).
64const timers = new Map<string, Timer>()
65// A reload is a fresh module, and opens the pane once again.
66let autoOpened = false
67
68/** Tests: forget the unasked open. */
69export function resetSheet(): void {
70 autoOpened = false
71}
72
73/** The write road for one row: the steward's own `answer`. */
74export function answerArgv(bin: string, session: string, id: string, text: string): string[] {
75 const argv = ['bash', `${bin}/fleet-steward-tick.sh`, 'answer', '--row', id, '--text', text, '--by', 'person']
76 return session === '' ? argv : [...argv, '--session', session]
77}
78
79/** Tests: is this argv an answer? */
80export function isAnswerRun(argv: readonly string[]): boolean {
81 return (argv[1] ?? '').endsWith('/fleet-steward-tick.sh') && argv[2] === 'answer'
82}
83
84export function openArgv(bin: string, url: string): string[] {
85 return ['bash', `${bin}/fleet-open.sh`, url]
86}
87
88/** `HH:MM` out of an ISO time ('' when there is none). */
89export function hhmm(iso: string): string {
90 return /T(\d\d:\d\d)/.exec(iso)?.[1] ?? ''
91}
92
93/** The dim line under a closed group: 已按默认 + its time, else 已答(by)+ the answer. */
94export function closedText(g: SheetGroup): string {
95 if (g.state === 'defaulted' && !g.never) return t('panel_sheet_defaulted_fmt', hhmm(g.closedAt) || hhmm(g.due))
96 return t('panel_sheet_answered_fmt', g.by || '—', g.answer)
97}
98
99/** The facts line of an open group: suggestion · default · deadline. */
100export function factsText(g: SheetGroup): string {
101 const parts = [
102 ...(g.suggest !== '' ? [t('panel_sheet_suggest_fmt', g.suggest)] : []),
103 t('panel_sheet_default_fmt', g.default),
104 ...(g.dueShow !== '' && g.dueShow !== '—' ? [t('panel_sheet_due_fmt', g.dueShow)] : []),
105 ]
106 return parts.join(' · ')
107}
108
109/** The groups split as drawn: open (the steward's order) on top, closed under them. */
110export function splitGroups(groups: readonly SheetGroup[], s: SheetUi): { open: SheetGroup[]; closed: SheetGroup[] } {
111 const open: SheetGroup[] = []
112 const closed: SheetGroup[] = []
113 for (const g of groups) (g.state === 'open' ? open : closed).push(g)
114 return { open, closed: closed.filter(g => !(g.gid in s.pending)) }
115}
116
117async function setPaneOption($: EngineInterface, up: boolean | null): Promise<void> {
118 const pane = await $.env.get('TMUX_PANE')
119 if (pane === undefined || pane === '') return
120 const argv = windowOptionsArgv(pane, { [SHEET_OPTION]: up === null ? null : up ? '1' : '0' })
121 if (argv !== null) await $.process.run(argv, { timeoutMs: TMUX_TIMEOUT_MS }).catch(() => undefined)
122}
123
124function bump(s: SheetUi, gid: string): Record<string, number> {
125 return { ...s.presses, [gid]: (s.presses[gid] ?? 0) + 1 }
126}
127
128function arm($: EngineInterface, gid: string, wait: number): void {
129 timers.get(gid)?.cancel()
130 timers.set(gid, $.clock.after(Math.max(0, wait), () => {
131 void fire($, gid).catch(() => undefined)
132 }))
133}
134
135/** A press that answers: into 「已答 · 撤回」 for UNDO_MS. */
136async function answer($: EngineInterface, g: SheetGroup, how: 'take' | 'turn', text: string): Promise<void> {
137 const words = text.trim()
138 if (words === '') return
139 const at = await $.clock.now()
140 const s = await read($, sheet)
141 if (g.gid in s.pending || s.sending.includes(g.gid)) return
142 const presses = bump(s, g.gid)
143 const p: SheetPending = { ids: g.ids, text: words, how, at, presses: presses[g.gid] ?? 1 }
144 await update($, sheet, v => ({ ...v, pending: { ...v.pending, [g.gid]: p }, editing: '', presses }))
145 arm($, g.gid, UNDO_MS)
146}
147
148async function undo($: EngineInterface, gid: string): Promise<void> {
149 timers.get(gid)?.cancel()
150 timers.delete(gid)
151 await update($, sheet, v => {
152 const pending = { ...v.pending }
153 delete pending[gid]
154 return { ...v, pending, presses: bump(v, gid) }
155 })
156}
157
158/** The deadline passed: write the answer back, row by row; a failure puts the line back. */
159async function fire($: EngineInterface, gid: string): Promise<void> {
160 timers.delete(gid)
161 const s = await read($, sheet)
162 const p = s.pending[gid]
163 if (p === undefined) return
164 await update($, sheet, v => {
165 const pending = { ...v.pending }
166 delete pending[gid]
167 return { ...v, pending, sending: [...v.sending.filter(x => x !== gid), gid] }
168 })
169 const t0 = await $.clock.now()
170 let why = ''
171 for (const id of p.ids) {
172 try {
173 const r = await $.process.run(answerArgv(fleetBin(), currentSession(), id, p.text), { timeoutMs: ANSWER_TIMEOUT_MS })
174 if (r.exitCode !== 0) why = (r.stderr || r.stdout).trim() || `exit ${r.exitCode}`
175 } catch (err) {
176 why = err instanceof Error ? err.message : String(err)
177 }
178 if (why !== '') break
179 }
180 if (why !== '') {
181 await update($, sheet, v => ({ ...v, sending: v.sending.filter(x => x !== gid) }))
182 $.ui.toast(t('panel_sheet_failed_fmt', why))
183 return
184 }
185 const done = await $.clock.now()
186 noteAnswer({ kind: 'answer', ts: done, gid, how: p.how, presses: p.presses, ms: done - t0 })
187 await update($, sheet, v => {
188 const presses = { ...v.presses }
189 delete presses[gid]
190 return { ...v, presses }
191 })
192}
193
194async function thread($: EngineInterface, g: SheetGroup): Promise<void> {
195 await update($, sheet, v => ({ ...v, presses: bump(v, g.gid) }))
196 const r = await $.process.run(openArgv(fleetBin(), g.url), { timeoutMs: OPEN_TIMEOUT_MS }).catch(() => null)
197 if (r === null || r.exitCode > 2) $.ui.toast(t('panel_sheet_failed_fmt', g.url))
198}
199
200/** Is the decision sheet drawn right now (the engine's record)? */
201async function sheetPlaced($: EngineInterface): Promise<boolean> {
202 return (await $.ui.panes()).some(p => p.id === SHEET_PANE && p.isPlaced)
203}
204
205/** The band's line: 「决定单 N 件待定」 while open lines wait and the pane is not placed, else ''. */
206export function sheetHint(view: PanelsView | null, placed: boolean): string {
207 const open = (view?.groups ?? []).filter(g => g.state === 'open').length
208 return open === 0 || placed ? '' : t('panel_sheet_hint_fmt', String(open))
209}
210
211async function moved($: EngineInterface): Promise<void> {
212 await update($, sheetPane, n => (n ?? 0) + 1)
213}
214
215/**
216 * Bare `/sheet` (the band's 打开 runs it too) in the holder's window: close the pane only when
217 * it is placed; otherwise open it — asked, focused, Esc closes it. Never
218 * `holdToasts`: it stays up.
219 */
220async function toggleSheet($: EngineInterface): Promise<string> {
221 if (await sheetPlaced($)) {
222 await $.ui.close({ id: SHEET_PANE })
223 await setPaneOption($, false)
224 await moved($)
225 return t('panel_sheet_closed')
226 }
227 const r = await $.ui.open({ id: SHEET_PANE, title: t('panel_sheet_title'), focus: true, closeOnEscape: true })
228 await setPaneOption($, r.isPlaced)
229 await moved($)
230 return r.isPlaced ? t('panel_sheet_opened') : t('panel_sheet_waiting')
231}
232
233export function registerSheet(on: On): void {
234 // A reload is a fresh module: re-arm what was pressed and not sent yet.
235 on('session.start', { isInteractive: true }, async ($, e, next) => {
236 const result = await next(e)
237 const s = await read($, sheet)
238 const now = await $.clock.now()
239 for (const [gid, p] of Object.entries(s.pending)) if (!timers.has(gid)) arm($, gid, p.at + UNDO_MS - now)
240 return result
241 }).catch(($, e, next) => next(e))
242
243 on('command.run', { command: SHEET_COMMAND }, async ($, e, next) => {
244 // bare /sheet only: `--stats` / `--summary` are panels.ts's, `batches` batches.tsx's
245 if (!isHolder() || !panelsOn() || e.args.trim() !== '') return next(e)
246 return { text: await toggleSheet($) }
247 }).catch(($, e, next) => next(e))
248
249 // Unasked, once: the first band drawn on a fullscreen terminal wide enough
250 // opens it. Narrower, nothing opens (the hint says so); a widening redraws the
251 // band, and that draw opens it. While open lines wait and the pane is not
252 // placed, the band is the hint and its 打开 (`s`); otherwise it is passed on.
253 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
254 const view = await read($, panels)
255 await read($, sheetPane)
256 if (!isHolder() || !panelsOn()) return next(e)
257 const vp = e.viewport
258 if (!autoOpened && vp?.isFullscreen === true && vp.columns >= AUTO_COLUMNS) {
259 autoOpened = true
260 void (async () => {
261 const r = await $.ui.open({ id: SHEET_PANE, title: t('panel_sheet_title') })
262 await setPaneOption($, r.isPlaced)
263 await moved($)
264 })().catch(() => undefined)
265 }
266 if (e.props.hasSurvey || sheetHint(view, false) === '') return next(e)
267 const hint = sheetHint(view, await sheetPlaced($).catch(() => false))
268 if (hint === '') return next(e)
269 const { Box, Text, Button } = $.ui.resolve(e)
270 return (
271 <Box key="fleet-sheet-hint" flexDirection="row" gap={1}>
272 <Text key="fleet-sheet-hint-text" color="yellow" wrap="truncate-end">{hint}</Text>
273 <Button
274 key="fleet-sheet-hint-open"
275 label={t('panel_sheet_hint_open')}
276 hotkey="s"
277 onPress={() => {
278 void $.command.run({ command: SHEET_COMMAND, args: '' }).catch(() => undefined)
279 }}
280 />
281 </Box>
282 )
283 }).catch(($, e, next) => next(e))
284
285 // The person's close mark: the steward sends the whole sheet again.
286 on('ui.close', { id: SHEET_PANE }, async ($, e, next) => {
287 const result = await next(e)
288 if (isHolder() && e.origin.kind !== 'unload') {
289 await setPaneOption($, false)
290 await moved($)
291 }
292 return result
293 }).catch(($, e, next) => next(e))
294
295 on('ui.render', { component: 'Pane', requestId: SHEET_PANE }, async ($, e, next) => {
296 // Read first: the reads subscribe this pane to both states.
297 const view = await read($, panels)
298 const s = await read($, sheet)
299 if (!isHolder()) return next(e)
300 const ui = $.ui.resolve(e)
301 const { Box, Text, Button } = ui
302 const groups = view?.groups ?? []
303 const { open, closed } = splitGroups(groups, s)
304 if (open.length === 0 && closed.length === 0) {
305 return <Text dimColor>{view === null ? t('panel_empty') : t('panel_sheet_empty')}</Text>
306 }
307 // The hotkeys sit on ONE line at a time (two would clash): the first still to answer, the first to undo.
308 const lead = open.find(g => !(g.gid in s.pending) && !s.sending.includes(g.gid))?.gid ?? ''
309 const undoLead = open.find(g => g.gid in s.pending)?.gid ?? ''
310 const field = 'Input' in ui ? ui.Input : undefined
311 const line = (g: SheetGroup, n: number) => {
312 const p = s.pending[g.gid]
313 const sending = s.sending.includes(g.gid)
314 const hot = g.gid === lead
315 return (
316 <Box key={`g:${g.gid}`} flexDirection="column" marginBottom={1}>
317 <Text key={`item:${g.gid}`} bold>{`${n}. ${g.item}`}</Text>
318 <Text key={`facts:${g.gid}`} dimColor>{factsText(g)}</Text>
319 {g.from !== '' && <Text key={`from:${g.gid}`} dimColor>{g.from}</Text>}
320 {p !== undefined && (
321 <Box key={`pending:${g.gid}`} flexDirection="row" gap={1}>
322 <Text key={`pending-text:${g.gid}`} color="green">{t('panel_sheet_pending_fmt', p.text)}</Text>
323 <Button
324 key={`undo:${g.gid}`}
325 label={t('panel_sheet_undo')}
326 {...(g.gid === undoLead ? { hotkey: 'u' } : {})}
327 onPress={() => undo($, g.gid)}
328 />
329 </Box>
330 )}
331 {p === undefined && sending && <Text key={`sending:${g.gid}`} dimColor>{t('panel_sheet_sending')}</Text>}
332 {p === undefined && !sending && (
333 <Box key={`buttons:${g.gid}`} flexDirection="row" gap={1}>
334 {g.suggest !== '' && (
335 <Button
336 key={`take:${g.gid}`}
337 label={t('panel_sheet_take')}
338 variant="primary"
339 {...(hot ? { hotkey: 'y' } : {})}
340 onPress={() => answer($, g, 'take', g.suggest)}
341 />
342 )}
343 <Button
344 key={`turn:${g.gid}`}
345 label={g.suggest !== '' ? t('panel_sheet_overturn') : t('panel_sheet_own')}
346 {...(hot ? { hotkey: 'f' } : {})}
347 onPress={() => update($, sheet, v => ({ ...v, editing: v.editing === g.gid ? '' : g.gid, presses: bump(v, g.gid) }))}
348 />
349 {g.url !== '' && (
350 <Button
351 key={`thread:${g.gid}`}
352 label={t('panel_sheet_thread')}
353 dimColor
354 {...(hot ? { hotkey: 't' } : {})}
355 onPress={() => thread($, g)}
356 />
357 )}
358 {g.asks.length > 1 && (
359 <Button
360 key={`asks:${g.gid}`}
361 label={t('panel_sheet_asks_fmt', String(g.asks.length))}
362 dimColor
363 onPress={() => update($, sheet, v => ({ ...v, expanded: v.expanded === g.gid ? '' : g.gid }))}
364 />
365 )}
366 </Box>
367 )}
368 {p === undefined && !sending && s.editing === g.gid && field !== undefined && (() => {
369 const Input = field
370 return (
371 <Input
372 key={`input:${g.gid}`}
373 label={t('panel_sheet_input')}
374 placeholder={t('panel_sheet_placeholder')}
375 submitLabel={t('panel_sheet_send')}
376 autoFocus
377 onSubmit={value => answer($, g, 'turn', value)}
378 />
379 )
380 })()}
381 {s.expanded === g.gid && g.asks.map(a => (
382 <Text key={`ask:${a.id}`} dimColor wrap="wrap">{`· ${hhmm(a.asked)} ${a.item}`}</Text>
383 ))}
384 </Box>
385 )
386 }
387 return (
388 <Box key="fleet-sheet" flexDirection="column">
389 {open.map((g, i) => line(g, i + 1))}
390 {closed.map(g => (
391 <Text key={`closed:${g.gid}`} dimColor wrap="truncate-end">{`✓ ${g.item} — ${closedText(g)}`}</Text>
392 ))}
393 {open.length > 0 && <Text key="hint" dimColor>{t('panel_sheet_hint')}</Text>}
394 </Box>
395 )
396 }).catch(($, e, next) => next(e))
397}
398hooks/batches-model.ts 136 lines1// The batches pane's pure half (issue #2833, EPIC #2831 C2): the books' text in,
2// the pane's rows out. No `$` — panels.ts reads, panels-model.ts folds this in.
3//
4// - a batch per epic-running.d mark (already parsed: panels-model parseMark),
5// its driver off steward.state.json `drivers[<repo#N>]` (`#<N>` too), its
6// parked count = park.json rows whose `origin` is that driver;
7// - a driver the book names with NO mark, whose last row in the orchestrator's
8// children ledger is not an ending (MERGED / REAPED / FAILED / STOPPED) and
9// whose EPIC the steward has not seen close: 「没写心跳 · 进度读不到」;
10// - the batches the steward saw close today (`todo.epics[ref].closed`, local
11// day), not running any more: one grey line each;
12// - 「待你动手」: `todo.items` not done / skipped, by due then id.
13
14import type { Batch, Board, BoardBatch, BoardDone, BoardTodo } from '../types'
15import { parseLedger } from './common'
16
17type Obj = Record<string, unknown>
18
19const str = (v: unknown): string => (typeof v === 'string' ? v : '')
20const num = (v: unknown): number => (typeof v === 'number' && Number.isFinite(v) ? v : 0)
21const obj = (v: unknown): Obj => (typeof v === 'object' && v !== null && !Array.isArray(v) ? (v as Obj) : {})
22
23function json(text: string): Obj {
24 try {
25 return obj(JSON.parse(text) as unknown)
26 } catch {
27 return {}
28 }
29}
30
31/** Strip a `<slug>:` prefix (the ledger keys children bare). */
32export function bareKey(key: string): string {
33 const i = key.lastIndexOf(':')
34 return i < 0 ? key : key.slice(i + 1)
35}
36
37/** A ledger state after which the driver is no longer going. */
38const ENDED = new Set(['MERGED', 'REAPED', 'FAILED', 'STOPPED', 'CLOSED'])
39/** followup.py's CLOSED. */
40const TODO_CLOSED = new Set(['done', 'skipped'])
41
42export const EMPTY_BOARD: Board = { batches: [], done: [], todo: [] }
43
44/** `owner/name#N` / `#N` → [repo, N]; null when it is neither. */
45export function splitRef(ref: string): [string, number] | null {
46 const m = /^([^#\s]*)#(\d+)$/.exec(ref)
47 return m === null ? null : [m[1] as string, Number(m[2])]
48}
49
50function sameDay(aMs: number, bMs: number): boolean {
51 const a = new Date(aMs)
52 const b = new Date(bMs)
53 return a.getFullYear() === b.getFullYear() && a.getMonth() === b.getMonth() && a.getDate() === b.getDate()
54}
55
56export type BoardText = { state: string; park: string; ledger: string }
57
58/** The marks (parsed) and the books' text → the pane's rows. `at` is the read's epoch ms. */
59export function foldBoard(marks: readonly Batch[], src: BoardText, at: number): Board {
60 const st = json(src.state)
61 const drivers = obj(st.drivers)
62 const driverOf = (repo: string, epic: number): string =>
63 str(drivers[`${repo}#${epic}`]) || str(drivers[`#${epic}`])
64
65 const parkedBy = new Map<string, number>()
66 for (const v of Object.values(obj(json(src.park).parked))) {
67 const o = bareKey(str(obj(v).origin))
68 if (o !== '') parkedBy.set(o, (parkedBy.get(o) ?? 0) + 1)
69 }
70 const parkedFor = (driver: string): number => (driver === '' ? 0 : parkedBy.get(bareKey(driver)) ?? 0)
71
72 const batches: BoardBatch[] = marks.map(b => {
73 const ref = b.repo === '' || b.repo === '-' ? `#${b.epic}` : `${b.repo}#${b.epic}`
74 const driver = driverOf(b.repo === '-' ? '' : b.repo, b.epic)
75 return { ...b, ref, driver, parked: parkedFor(driver), noMark: false }
76 })
77 const marked = new Set(batches.map(b => b.ref))
78
79 const todo = obj(st.todo)
80 const epics = obj(todo.epics)
81 const closedAt = (ref: string): number => num(obj(epics[ref]).closed)
82
83 // A driver going with no mark (a loop that never stamps one).
84 const last = parseLedger(src.ledger)
85 for (const [ref, v] of Object.entries(drivers)) {
86 const r = splitRef(ref)
87 const driver = str(v)
88 if (r === null || driver === '' || marked.has(ref)) continue
89 const row = last.get(bareKey(driver))
90 if (row === undefined || ENDED.has(row.state) || closedAt(ref) > 0) continue
91 marked.add(ref)
92 batches.push({
93 repo: r[0], epic: r[1], tick: 0, landed: 0, members: 0, live: null, inflight: null, epoch: 0, ttl: 0,
94 fresh: false, title: '', ref, driver, parked: parkedFor(driver), noMark: true,
95 })
96 }
97
98 const done: BoardDone[] = []
99 for (const ref of Object.keys(epics).sort()) {
100 const c = closedAt(ref)
101 const r = splitRef(ref)
102 if (r === null || c <= 0 || marked.has(ref) || !sameDay(c * 1000, at)) continue
103 done.push({ ref, epic: r[1], title: '' })
104 }
105
106 const items: BoardTodo[] = []
107 for (const v of Object.values(obj(todo.items))) {
108 const i = obj(v)
109 if (TODO_CLOSED.has(str(i.state)) || str(i.what) === '') continue
110 const srcs = Array.isArray(i.sources) ? i.sources : []
111 items.push({ id: str(i.id), kind: str(i.kind), what: str(i.what), due: str(i.due), url: str(obj(srcs[0]).url) })
112 }
113 items.sort((a, b) => (a.due || '9').localeCompare(b.due || '9') || a.id.localeCompare(b.id))
114
115 return { batches, done, todo: items }
116}
117
118/** `██████░░░░` — landed of members, `width` cells; '' when there is no count. */
119export function bar(landed: number, members: number, width = 10): string {
120 if (members <= 0) return ''
121 const k = Math.max(0, Math.min(width, Math.round((landed / members) * width)))
122 return '█'.repeat(k) + '░'.repeat(width - k)
123}
124
125/** `HH:MM` local, and whole minutes since, for a heartbeat epoch (s) seen at `at` (ms). */
126export function beatClock(epochSec: number, at: number): { hhmm: string; mins: number } {
127 const d = new Date(epochSec * 1000)
128 const hhmm = `${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`
129 return { hhmm, mins: Math.max(0, Math.floor((at - epochSec * 1000) / 60_000)) }
130}
131
132/** The kind's mark on a 「待你动手」 row (followup.py's KINDS). */
133export function todoIcon(kind: string): string {
134 return kind === 'stable' ? '⇪' : kind === 'hub-deploy' ? '☁' : '✋'
135}
136hooks/common.ts 165 lines1// What the steward plugin's panels share (issue #3115, EPIC #3111 C4): the
2// strings, where the fleet's scripts are, the window-option write, a children
3// ledger's reader — and WHO HOLDS THE STEWARD ROLE, which is the one switch here.
4//
5// The panels moved out of the core mod (mod/fleet) into this plugin. A plugin
6// cannot import another plugin's files, so the few helpers they leaned on there
7// live here; nothing else.
8//
9// The holder: bin/fleet-role-take.sh (take_role steward, run by /fleet-steward)
10// writes the role's ONE line in $FLEET_CONF_DIR/global/roles.held —
11// `steward TAB <session> TAB <fleet_id> TAB <window id> TAB <epoch>` — and stamps
12// the window `@fleet_role_def steward`. panels.ts stats that file on its one-second
13// tick and reads it when it moved: this window holds the role ⇔ the line names
14// its @fleet_id (else its window id). So the panels follow a /fleet-steward typed
15// after the session started, and stop the tick after the role is dropped or
16// stolen — no process is run for it. No line, another window, or a window that is
17// no fleet pane ⇒ nothing here draws, opens or answers.
18
19/** How long one tmux call may take before the run is abandoned. */
20export const TMUX_TIMEOUT_MS = 5000
21
22/** The three panes, and the window option that says the decision sheet is up. */
23export const SHEET_PANE = 'fleet-sheet'
24export const PATROL_PANE = 'fleet-patrol'
25export const BATCHES_PANE = 'fleet-batches'
26export const SHEET_OPTION = '@sheet_pane'
27
28let strings: Record<string, string> = {}
29let bin = ''
30let held = false
31
32/** Where the fleet's scripts are: FLEET_MCP_BIN (a dev tree), else the live install. */
33export function binDir(fleetBin: string | undefined, home: string | undefined): string {
34 return fleetBin || `${home ?? ''}/.claude/fleet/bin`
35}
36
37/** panels.ts's session.start: the scripts' directory for every run here. */
38export function setBin(dir: string): void {
39 bin = dir
40}
41
42/** The fleet's bin/ (set at session.start; the live install's before that). */
43export function fleetBin(): string {
44 return bin || binDir(undefined, '')
45}
46
47/** The argv that prints the `panel_` strings (KEY NUL TEXT NUL, a \001 per printf slot). */
48export function stringsArgv(dir: string): string[] {
49 return ['sh', `${dir}/fleet-ui-lang.sh`, 'dump', 'panel_']
50}
51
52/** Tests: is this argv the strings read? */
53export function isStringsRun(argv: readonly string[]): boolean {
54 return argv[0] === 'sh' && (argv[1] ?? '').endsWith('/fleet-ui-lang.sh') && argv[2] === 'dump'
55}
56
57/** Take the dump; one that cannot be read leaves every key showing itself. */
58export function takeStrings(dump: string): void {
59 const parts = dump.split('\0')
60 const out: Record<string, string> = {}
61 for (let i = 0; i + 1 < parts.length; i += 2) if (parts[i] !== '') out[parts[i] as string] = parts[i + 1] as string
62 strings = out
63}
64
65/** One string, its \001 slots filled in order. */
66export function t(key: string, ...args: string[]): string {
67 let i = 0
68 return (strings[key] ?? key).replace(/\u0001/g, () => args[i++] ?? '')
69}
70
71/**
72 * The argv that sets (string) or unsets (null) window options on `pane`'s
73 * window, all in ONE tmux call (`;`-chained); null when there is nothing to do.
74 * A bare `tmux` lands on this fleet's own server through $TMUX; `-t` pins the
75 * write to this pane's window.
76 */
77export function windowOptionsArgv(pane: string, options: Record<string, string | null>): string[] | null {
78 const argv: string[] = ['tmux']
79 for (const [key, value] of Object.entries(options)) {
80 if (argv.length > 1) argv.push(';')
81 if (value === null) argv.push('set-option', '-w', '-u', '-t', pane, key)
82 else argv.push('set-option', '-w', '-t', pane, key, value)
83 }
84 return argv.length > 1 ? argv : null
85}
86
87/** The one tmux read at session.start: this pane's window — its id, @fleet_id and @issue. */
88export function windowArgv(pane: string): string[] {
89 return ['tmux', 'display-message', '-p', '-t', pane, '#{window_id}|#{@fleet_id}|#{@issue}']
90}
91
92/** Tests: is this argv the window read? */
93export function isWindowRun(argv: readonly string[]): boolean {
94 return argv[0] === 'tmux' && argv[1] === 'display-message' && argv[argv.length - 1] === '#{window_id}|#{@fleet_id}|#{@issue}'
95}
96
97export type Self = { wid: string; fid: string; issue: string }
98
99export function parseWindow(read: string): Self {
100 const [wid = '', fid = '', issue = ''] = read.trim().split('|')
101 return { wid, fid, issue }
102}
103
104/** Does roles.held name this window as the steward? Its @fleet_id first, else its window id. */
105export function holdsSteward(rolesHeld: string, self: Self): boolean {
106 if (self.wid === '' && self.fid === '') return false
107 let line: string[] | undefined
108 for (const l of rolesHeld.split('\n')) {
109 const f = l.split('\t')
110 if (f[0] === 'steward') line = f
111 }
112 if (line === undefined) return false
113 const fid = line[2] ?? ''
114 const wid = line[3] ?? ''
115 if (fid !== '' && self.fid !== '') return fid === self.fid
116 return wid !== '' && wid === self.wid
117}
118
119/** panels.ts: the last roles.held read said this window holds the role (or not). */
120export function setHeld(on: boolean): void {
121 held = on
122}
123
124/** Does this window hold the steward role right now? Every hook here asks this first. */
125export function isHolder(): boolean {
126 return held
127}
128
129/** Tests: forget everything. */
130export function resetCommon(): void {
131 strings = {}
132 bin = ''
133 held = false
134}
135
136export type LedgerRow = { child: string; state: string; verdict: string }
137
138/** Strip a `<slug>:` prefix off a ledger / origin key. */
139function bare(key: string): string {
140 const i = key.lastIndexOf(':')
141 return i < 0 ? key : key.slice(i + 1)
142}
143
144/** The newest outcome row per child of a children ledger (.ndjson). */
145export function parseLedger(text: string): Map<string, LedgerRow> {
146 const rows: Array<{ seq: number; row: LedgerRow }> = []
147 for (const line of text.split('\n')) {
148 if (line.trim() === '') continue
149 try {
150 const d = JSON.parse(line) as Record<string, unknown>
151 if (typeof d.child !== 'string' || typeof d.state !== 'string') continue
152 rows.push({
153 seq: typeof d.seq === 'number' ? d.seq : 0,
154 row: { child: bare(d.child), state: d.state, verdict: typeof d.verdict === 'string' ? d.verdict : '' },
155 })
156 } catch {
157 // A torn line is skipped, never fatal.
158 }
159 }
160 rows.sort((a, b) => a.seq - b.seq)
161 const last = new Map<string, LedgerRow>()
162 for (const { row } of rows) last.set(row.child, row)
163 return last
164}
165hooks/panels-model.ts 299 lines1// The panels' pure half (issue #2835, EPIC #2831 C4): text in, view model out.
2// No `$` here — panels.ts says when to read, lifecycle.ts does the reading.
3//
4// Sources, every one a book someone else writes (the panels only read):
5// - global/steward.state.json the steward's state: the sheet, its rows, the
6// beat, the card, the to-do count, the parked list
7// - global/steward.delta.json what the last beat saw
8// - global/epic-running.d/* one `key: value` mark per running EPIC batch
9// - global/park.json the park book (bin/fleet_park.py)
10// - fleets/<sess>/children/orchestrator.ndjson the orchestrator's children
11// A source that is missing or torn just leaves its part empty.
12
13import { foldBoard } from './batches-model'
14import { parseLedger } from './common'
15
16export type { Batch, PanelsView, Parked, Patrol, QueueRow, Sheet, SheetGroup, SheetRow, Todo } from '../types'
17import type { Batch, PanelsView, Parked, Patrol, QueueRow, Sheet, SheetGroup, SheetRow, Todo } from '../types'
18
19type Obj = Record<string, unknown>
20
21function json(text: string): Obj | null {
22 try {
23 const v = JSON.parse(text) as unknown
24 return typeof v === 'object' && v !== null && !Array.isArray(v) ? (v as Obj) : null
25 } catch {
26 return null
27 }
28}
29
30const str = (v: unknown): string => (typeof v === 'string' ? v : '')
31const num = (v: unknown): number => (typeof v === 'number' && Number.isFinite(v) ? v : 0)
32const obj = (v: unknown): Obj => (typeof v === 'object' && v !== null && !Array.isArray(v) ? (v as Obj) : {})
33const arr = (v: unknown): unknown[] => (Array.isArray(v) ? v : [])
34
35/**
36 * Who closed a row: `by` as the steward wrote it (steward · person · default);
37 * a row written before the field (#2834) is read off its state — defaulted ⇒
38 * default, answered ⇒ person — and an open one is nobody's yet ('').
39 */
40export function rowBy(r: Obj): string {
41 const by = str(r.by)
42 if (by !== '') return by
43 const st = str(r.state)
44 return st === 'defaulted' ? 'default' : st === 'answered' ? 'person' : ''
45}
46
47/** The first sentence of a finding (its first line, cut at the first full stop), ≤ 80 chars. */
48export function firstSentence(text: string): string {
49 const line = (text.split('\n')[0] ?? '').trim()
50 const m = /^(.*?[。!?]|.*?[.!?](?=\s|$))/.exec(line)
51 const one = (m?.[1] ?? line).trim()
52 return one.length > 80 ? `${one.slice(0, 79)}…` : one
53}
54
55function health(h: Obj): { n: number; top: string } {
56 const active = obj(h.active)
57 const issues = obj(h.issues)
58 const keys = Object.keys(active)
59 if (keys.length === 0) return { n: 0, top: '' }
60 // newest by the issue it was filed on; else the last the steward noted
61 const at = (k: string): string => str(obj(issues[k]).at)
62 const newest = keys.reduce((a, k) => (at(k) > at(a) ? k : a), keys[keys.length - 1] as string)
63 return { n: keys.length, top: firstSentence(str(obj(active[newest]).msg) || newest) }
64}
65
66function sheetRow(id: string, r: Obj): SheetRow {
67 return {
68 id,
69 item: str(r.item),
70 suggest: str(r.suggest),
71 default: str(r.default),
72 due: str(r.due),
73 kind: str(r.kind),
74 src: str(r.src),
75 url: str(r.url),
76 state: str(r.state),
77 by: rowBy(r),
78 asked: str(r.asked),
79 }
80}
81
82/** One of the steward's `groups` (fleet_decision.group, issue #2832); null without an id. */
83function sheetGroup(v: unknown): SheetGroup | null {
84 const g = obj(v)
85 const ids = arr(g.ids).map(str).filter(id => id !== '')
86 const gid = str(g.gid) || (ids[ids.length - 1] ?? '')
87 if (gid === '') return null
88 return {
89 gid,
90 ids: ids.length > 0 ? ids : [gid],
91 item: str(g.item),
92 suggest: str(g.suggest),
93 default: str(g.default),
94 due: str(g.due),
95 dueShow: str(g.due_show),
96 kind: str(g.kind),
97 never: g.never === true,
98 src: str(g.src),
99 url: str(g.url),
100 from: str(g.from),
101 state: str(g.state),
102 by: str(g.by),
103 answer: str(g.answer),
104 closedAt: str(g.closed_at),
105 asked: str(g.asked),
106 asks: arr(g.asks).map(a => {
107 const o = obj(a)
108 return { id: str(o.id), item: str(o.item), asked: str(o.asked), url: str(o.url) }
109 }),
110 }
111}
112
113function parked(v: unknown): Parked[] {
114 return arr(v).map(o => {
115 const p = obj(o)
116 return { ref: str(p.ref), at: num(p.at), wait: arr(p.wait).map(str), key: str(p.key) }
117 }).filter(p => p.ref !== '')
118}
119
120export const EMPTY_PATROL: Patrol = {
121 beat: 0, at: '', changed: false, writes: 0, nextAt: 0, card: [], modelCalls: 0,
122 parked: [], newAsks: 0, closed: 0, defaulted: 0,
123 drivers: 0, openRows: 0, events: 0, bySteward: 0, byDefault: 0, groups: 0, health: 0, healthTop: '', deferred: 0, page: '',
124 overdue: 0, overdueStuck: 0, overdueTop: '',
125}
126
127export type StateView = { sheet: Sheet | null; groups: SheetGroup[]; todo: Todo; patrol: Patrol }
128
129/** steward.state.json → the sheet (its rows in the sheet's order), the to-do count, the beat. */
130export function parseState(text: string): StateView {
131 const d = json(text)
132 if (d === null) return { sheet: null, groups: [], todo: { open: 0, desk: '' }, patrol: { ...EMPTY_PATROL } }
133 const rows = obj(d.rows)
134 const s = obj(d.sheet)
135 const ids = arr(s.rows).map(str).filter(id => id !== '')
136 const sheet: Sheet | null = str(s.id) === '' && ids.length === 0 ? null : {
137 id: str(s.id),
138 at: str(s.at),
139 where: str(s.where),
140 sent: s.sent === true,
141 rows: ids.filter(id => id in rows).map(id => sheetRow(id, obj(rows[id]))),
142 }
143 const beat = obj(d.beat)
144 // today = the beat's own local day (the steward writes on the person's clock)
145 const today = str(beat.at).slice(0, 10)
146 let bySteward = 0
147 let byDefault = 0
148 let openRows = 0
149 for (const v of Object.values(rows)) {
150 const r = obj(v)
151 if (str(r.state) === 'open') openRows++
152 if (today === '' || (str(r.closed_at) || str(r.asked)).slice(0, 10) !== today) continue
153 const by = rowBy(r)
154 if (by === 'steward') bySteward++
155 else if (by === 'default') byDefault++
156 }
157 const open = sheet?.rows.filter(r => r.state === 'open') ?? []
158 const groups = new Set(open.map(r => str(obj(rows[r.id]).group) || r.id)).size
159 const hl = health(obj(d.health))
160 const od = obj(d.reap_overdue)
161 return {
162 sheet,
163 groups: arr(d.groups).map(sheetGroup).filter((g): g is SheetGroup => g !== null),
164 todo: { open: num(d.todo_open), desk: str(obj(d.todo).desk) },
165 patrol: {
166 ...EMPTY_PATROL,
167 beat: num(beat.n),
168 at: str(beat.at),
169 changed: beat.changed === true,
170 writes: num(beat.writes),
171 nextAt: num(d.next_at),
172 card: arr(d.card).map(str),
173 modelCalls: num(d.model_calls),
174 parked: parked(d.parked),
175 drivers: Object.keys(obj(d.drivers)).length,
176 openRows,
177 bySteward,
178 byDefault,
179 groups,
180 health: hl.n,
181 healthTop: hl.top,
182 deferred: arr(d.deferred).length,
183 page: str(obj(d.page).url),
184 overdue: num(od.n),
185 overdueStuck: num(od.stuck),
186 overdueTop: str(od.top),
187 },
188 }
189}
190
191export type DeltaView = { at: string; newAsks: number; closed: number; defaulted: number; events: number }
192
193/** steward.delta.json → what the last beat saw, counted. */
194export function parseDelta(text: string): DeltaView {
195 const d = json(text) ?? {}
196 return {
197 at: str(d.at), newAsks: arr(d.new_asks).length, closed: arr(d.closed).length, defaulted: arr(d.defaulted).length,
198 events: arr(d.events).length,
199 }
200}
201
202/** One epic-running.d mark (`key: value` lines, bin/fleet-epic-heartbeat.sh) → a batch, or null. */
203export function parseMark(text: string, nowSec: number): Batch | null {
204 const kv: Record<string, string> = {}
205 for (const line of text.split('\n')) {
206 const m = /^([a-z_]+):\s*(.*)$/.exec(line.trim())
207 if (m) kv[m[1] as string] = (m[2] as string).trim()
208 }
209 const int = (k: string): number | null => (/^\d+$/.test(kv[k] ?? '') ? Number(kv[k]) : null)
210 const epic = int('epic')
211 if (epic === null) return null
212 const epoch = int('epoch') ?? 0
213 const ttl = int('ttl') ?? 0
214 return {
215 repo: kv.repo ?? '',
216 epic,
217 tick: int('tick') ?? 0,
218 landed: int('landed') ?? 0,
219 members: int('members') ?? 0,
220 live: int('live'),
221 inflight: int('inflight'),
222 epoch,
223 ttl,
224 fresh: epoch > 0 && nowSec - epoch <= ttl,
225 // # compat-1v: 下一批删 — a mark from before #2833 has no title: (the pane shows #N)
226 title: kv.title ?? '',
227 }
228}
229
230/** Every mark → the batches, newest heartbeat first. */
231export function parseMarks(marks: ReadonlyArray<{ name: string; text: string }>, nowSec: number): Batch[] {
232 const out: Batch[] = []
233 for (const m of marks) {
234 if (m.name.startsWith('.')) continue
235 const b = parseMark(m.text, nowSec)
236 if (b !== null) out.push(b)
237 }
238 return out.sort((a, b) => b.epoch - a.epoch || a.epic - b.epic)
239}
240
241/** park.json → the parked sessions, oldest first (the steward's state carries the same list when the book is gone). */
242export function parsePark(text: string): Parked[] {
243 const d = json(text)
244 if (d === null) return []
245 const out: Parked[] = []
246 for (const [ref, v] of Object.entries(obj(d.parked))) {
247 const p = obj(v)
248 out.push({ ref, at: num(p.at), wait: arr(p.wait).map(str), key: str(p.key) })
249 }
250 return out.sort((a, b) => a.at - b.at)
251}
252
253/** The orchestrator's children ledger → each child's last row, in ledger order. */
254export function parseQueue(text: string): QueueRow[] {
255 return [...parseLedger(text).values()].map(r => ({ child: r.child, state: r.state, verdict: r.verdict }))
256}
257
258export type PanelsText = {
259 state: string
260 delta: string
261 marks: ReadonlyArray<{ name: string; text: string }>
262 park: string
263 ledger: string
264}
265
266/** Every source's text → the view. `at` is the read's epoch ms, `ms` how long it took. */
267export function foldPanels(src: PanelsText, at: number, ms: number): PanelsView {
268 const st = parseState(src.state)
269 const delta = parseDelta(src.delta)
270 const park = parsePark(src.park)
271 const batches = parseMarks(src.marks, Math.floor(at / 1000))
272 return {
273 sheet: st.sheet,
274 groups: st.groups,
275 batches,
276 todo: st.todo,
277 queue: parseQueue(src.ledger),
278 patrol: {
279 ...st.patrol,
280 parked: src.park.trim() === '' ? st.patrol.parked : park,
281 newAsks: delta.newAsks,
282 closed: delta.closed,
283 defaulted: delta.defaulted,
284 events: delta.events,
285 },
286 board: foldBoard(batches, src, at),
287 at,
288 ms,
289 }
290}
291
292/** Nearest-rank percentile of `xs` (0 when empty). */
293export function percentile(xs: readonly number[], p: number): number {
294 if (xs.length === 0) return 0
295 const s = [...xs].sort((a, b) => a - b)
296 const i = Math.min(s.length - 1, Math.max(0, Math.ceil((p / 100) * s.length) - 1))
297 return s[i] as number
298}
299types/index.d.ts 175 lines1// The fleet-steward plugin's contract (issue #3115, EPIC #3111 C4): what
2// panels-model.ts folds out of the steward's books, and the values its mod
3// module keeps in $.state. Moved here from the core mod's types (mod/fleet).
4
5// The orchestrator's / steward's panels (issue #2835): what panels-model.ts
6// folds out of the books.
7
8export type SheetRow = {
9 id: string
10 item: string
11 suggest: string
12 default: string
13 due: string
14 kind: string
15 src: string
16 url: string
17 state: string
18 by: string
19 asked: string
20}
21
22export type Sheet = { id: string; at: string; where: string; sent: boolean; rows: SheetRow[] }
23
24/** One ask behind a group: its own words (the panel's 原话). */
25export type SheetAsk = { id: string; item: string; asked: string; url: string }
26
27/**
28 * One line of the decision sheet's panel (issue #2832): fleet_decision.group's
29 * output, kept by the steward as `groups` in steward.state.json — the panel
30 * never merges on its own. `gid` is the latest ask's row id, `ids` every row it
31 * answers; `default` / `dueShow` are already the words to draw.
32 */
33export type SheetGroup = {
34 gid: string
35 ids: string[]
36 item: string
37 suggest: string
38 default: string
39 due: string
40 dueShow: string
41 kind: string
42 never: boolean
43 src: string
44 url: string
45 from: string
46 state: string
47 by: string
48 answer: string
49 closedAt: string
50 asked: string
51 asks: SheetAsk[]
52}
53
54/** An answer pressed and not sent yet: it waits UNDO_MS for `u`. */
55export type SheetPending = { ids: string[]; text: string; how: 'take' | 'turn'; at: number; presses: number }
56
57/** The `{plugin:'fleet', key:'sheet'}` state: the panel's own, never the books'. */
58export type SheetUi = {
59 pending: Record<string, SheetPending>
60 /** The group whose 翻案 field is open ('' none). */
61 editing: string
62 /** The group whose 原话 are shown ('' none). */
63 expanded: string
64 /** Groups being written back right now. */
65 sending: string[]
66 /** Presses on a group since it was last answered (the metric's 按几下). */
67 presses: Record<string, number>
68}
69
70export type Batch = {
71 repo: string
72 epic: number
73 tick: number
74 landed: number
75 members: number
76 /** null: a mark written before the heartbeat carried the reading (#2247). */
77 live: number | null
78 inflight: number | null
79 epoch: number
80 ttl: number
81 fresh: boolean
82 /** The mark's `title:` line (issue #2833); '' on a mark from before it. */
83 title: string
84}
85
86export type Parked = { ref: string; at: number; wait: string[]; key: string }
87
88export type QueueRow = { child: string; state: string; verdict: string }
89
90export type Patrol = {
91 beat: number
92 at: string
93 changed: boolean
94 writes: number
95 nextAt: number
96 card: string[]
97 modelCalls: number
98 parked: Parked[]
99 newAsks: number
100 closed: number
101 defaulted: number
102 /** The rest of what the patrol panel shows (issue #2834). */
103 drivers: number
104 openRows: number
105 events: number
106 /** Today's closes (the beat's local day): answered by the steward, by their default. */
107 bySteward: number
108 byDefault: number
109 /** What is handed to the person: open sheet groups (a row's `group`, else one a row). */
110 groups: number
111 health: number
112 /** The newest active health finding's first sentence ('' when none). */
113 healthTop: string
114 deferred: number
115 /** The steward page's URL ('' when none). */
116 page: string
117 /** Sessions past their reap policy and still open (issue #3352, bin/fleet-reap-overdue.py);
118 * how many of those are stuck > 30 min, and the longest-stuck one's line. */
119 overdue: number
120 overdueStuck: number
121 overdueTop: string
122}
123
124export type Todo = { open: number; desk: string }
125
126/** One batch on the batches pane (issue #2833): a mark, or a driver the ledger still has going with no mark. */
127export type BoardBatch = Batch & {
128 /** `<repo>#<N>`, or `#<N>` with no repo. */
129 ref: string
130 /** The driver's key off the steward's `drivers` book; '' when it names none. */
131 driver: string
132 /** Sessions of this batch the steward parked (park.json, by their origin). */
133 parked: number
134 /** No mark at all: the ledger says the driver is going, the progress cannot be read. */
135 noMark: boolean
136}
137
138/** A batch the steward saw close today (its `todo.epics` row `done`). */
139export type BoardDone = { ref: string; epic: number; title: string }
140
141/** One open row of the steward's 「待你动手」 list (`todo.items`). */
142export type BoardTodo = { id: string; kind: string; what: string; due: string; url: string }
143
144export type Board = { batches: BoardBatch[]; done: BoardDone[]; todo: BoardTodo[] }
145
146/** The `{plugin:'fleet-steward', key:'panels'}` state (EPIC #2831's interface). */
147export type PanelsView = {
148 sheet: Sheet | null
149 /** The decision sheet's lines, open first (issue #2832); [] from a steward before it. */
150 groups: SheetGroup[]
151 batches: Batch[]
152 todo: Todo
153 queue: QueueRow[]
154 patrol: Patrol
155 /** The batches pane's rows (issue #2833). */
156 board: Board
157 /** When this view was read (epoch ms). */
158 at: number
159 /** How long the read took (ms). */
160 ms: number
161}
162
163declare module 'claude-code' {
164 interface PluginState {
165 'fleet-steward': {
166 /** What the panels last read from the books (issue #2835); null before the first read. */
167 panels: PanelsView | null
168 /** The decision sheet's own state (issue #2832). */
169 sheet: SheetUi
170 /** Bumped on each open / close of the decision sheet (issue #2836): the band's hint redraws. */
171 sheetPane: number
172 }
173 }
174}
175