SLOPSHOPPER

fleet-steward

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…

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

claude-fleet

English | 简体中文

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

Use your AI subscriptions to move a small team's development tasks forward in parallel.

claude-fleet organizes Claude Code and Codex CLI sessions in tmux: one window and one isolated git worktree per task, GitHub Issues as the backlog, and PRs as the delivery path. Run features, fixes, tests and documentation alongside each other; use one dashboard to follow progress and handle blockers.

The core workflow uses the CLIs' subscription sign-in, with no separate model API key required. Optional Claude subscription account pools and quota-aware routing assign new work by available 5-hour / 7-day headroom and resume tasks on another account when needed. Codex shares the task workflow; its account rotation and quota management are not wired yet. See the agent capability table.

Built from daily use on an always-on Mac mini. A local transcript audit found a historical peak of 25 Claude main sessions processing tasks in parallel, across 25 independent worktrees, on September 1, 2026. A separate interval held at least 15 concurrent task directories for 14.46 minutes. These count overlapping CLI processing turns, not simultaneous server-side token generation or a throughput multiplier. Evidence and counting method.

dashboard status bar

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

What you get

  • Subscription-driven parallel work for small teams. Give independent tasks their own sessions and worktrees, follow them from a shared dashboard, and bring the results together through PRs. Worktrees separate uncommitted changes; related tasks still need an agreed merge order and may have conflicts.
  • Attention signals in the window list. Claude Code hooks stamp each window's state the instant it changes: a cyan braille spinner pulses while a session works, indigo while a /loop waits between iterations, green ✓ when a turn finishes, red ! + bell when a session is blocked on your answer. No polling lag — colors flip on the hook, not on the status-interval timer.
  • Urgency-sorted windows. Windows re-slot themselves so position 1 is always the session that needs you most (needs > done > working > looping > idle). Your view never jumps — the sorter restores focus after every move. The client's task list puts the session that needs you first.
  • A mission-control dashboard (FLEET_DASH_WINDOW=1): an fzf panel listing every session with state glyph, bound issue, model, and context %. It lives as an embedded pane in the plan hub, which holds the dash and nothing else; the task list replaced it by default (#1533), and its prefix+g / F9 keys left the node with the rest of the person's keys (#1714). Enter jumps. The prompt line at the bottom is the quick-scratch box: type a name and press Enter — it spawns a scratch session (own writable scratch-N worktree, no issue) named after that text, with the full text prefilled in the first input as an unsent, editable draft. Chinese and spaces are fine (the window title is capped at 24 columns — 12 CJK glyphs; the draft is not clipped). The prompt label is the fleet's default agent for a new session (claude ▸ / codex ▸); Ctrl-V flips it, persisted to the fleet's conf — that key (or the config modal) is how you pick the agent; typed text supplies the name and draft. Ctrl-N is the issue-bound path: it files a GitHub issue and spawns a worker session bound to it. (Every dash Ctrl- key is checked against your tmux prefix at launch and moved to its Alt- twin when it collides — tmux would eat it otherwise; ? shows the real key.) Ctrl-S opens the same raw scratch under its auto scratch-N name (plain claude, no issue — but in its own writable scratch-N worktree, so an experiment can push a branch and open a PR like any worker). Once a scratch has talked its way to a real requirement it can become the worker for it, in place: filing with fleet-issue-file.sh --title "…" --bind (or fleet-bind.sh <N> for an existing issue) renames its branch scratch-N → issue-N, binds the window and claims the issue — no second session re-grounding from zero (#520). Set FLEET_SCRATCH_POOL=1 to keep one pre-started and ready: ⌃s then hands you a session you can type into immediately (0.46s to the window, 0.30s to the first keystroke) instead of one that spends ~7s booting — the last second of which paints a ❯ box that silently swallows whatever you type.

backlog

  • GitHub backlog panel (bin/tmux-issues.sh; its prefix+b door left the node with #1714 — #1739 decides the client's): open issues grouped by milestone (roadmap | unplanned panes). Enter on an issue creates a worktree issue-<N> off your base branch and starts claude seeded to read, claim, and implement it. Issues being worked show ▶ <window>. Manage issues without leaving tmux: the modal is list-only by default, and Space toggles a preview pane showing the highlighted issue's body, labels, milestone, assignees, and recent comments — word-wrapped to the pane so nothing splits mid-word. / turns on type-to-filter; Ctrl-X closes (triages) an issue after a y/n confirm; Ctrl-O opens it on the web. Priority shows as a p0/p1/p2 tag on each row and orders issues within a milestone; Ctrl-Y cycles a highlighted issue's priority (none→p2→p1→p0). Ctrl-N files a one-line issue fast.
  • Background collectors keep it all instant: a 45-second daemon caches each worktree's branch, the repo's PR/CI map, open issues, per-session context tokens, and a local 5h/7d token-usage proxy. The dashboard only ever reads caches — zero inline git/gh/LLM calls.
  • Subscription-aware scheduling. Pool Claude subscription accounts, choose where new sessions start, and move existing sessions with their transcripts when an account needs a break. With TokenLedger (ccquota), use account-wide 5h/7d readings to warn before a limit, rotate early, stagger window starts, and optionally pause remote starts. See subscription and quota management.
  • Worktree lifecycle: cw <branch> spawns a worktree + Claude window; an hourly janitor removes worktrees that are merged + clean + not attached to any live pane (and never anything else).
  • Optional Claude Code status line (conf/statusline.sh): wired as a measurement bus, not a visible line (issue #1452) — it prints nothing and stamps the context %, window size, model and effort level (plus the account's rate limits) onto the pane's tmux window, where the client's top line shows 剩余 62% · Opus 5.5 · high on its right and the auto-handoff nudge reads the %. Opt-in at install time by pointing settings.json's statusLine at the live-install path, so it improves through land → /fleet-sync-install; jq-gated (inert without it). Never auto-wired. The fleet mod feeds the same script from inside the session (statusline.sh --from mod, issue #1459), so once every Claude window carries the mod the key can go — bin/fleet-statusline.sh off removes it (and the blank bottom row Claude Code keeps for any statusLine), refusing while a window would be left blind.

Architecture

Claude Code hooks (PreToolUse/PostToolUse/Stop/Notification)
      │  instant, semantic-blind
      ▼
@claude_state on the tmux window ──► spinner daemon (0.12s frames, single
      ▲                               writer, change-detected) ──► dash glyphs
      │  slow, semantic                                            + needs tally
LLM classifier (haiku, ~5min, change-gated)
      
collector daemon (60s) ──► cache files ──► fzf dashboard / backlog panels
  git · gh PRs+issues ·                     (read-only producers, render instantly)
  ctx tokens · usage proxy

Design rules that made it work:

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

Deeper reference: docs/dev/TERMS.md defines every term (what the collector/hub/dash actually are), docs/dev/ARCHITECTURE.md covers the shared-vs-per-fleet split and the path to running many fleets on one machine (one tmux session per repo), and docs/dev/STATE.md traces how each window's Claude state (working/done/needs/looping) is set, rendered, and corrected. docs/dev/EMIT.md covers the optional, off-by-default emitter that POSTs session lifecycle facts (session → issue → PR) so an external ledger can join what a week of agent work cost to what it produced — including the exact list of what does, and does not, leave the machine.

Install

The installer is Claude itself — docs/dev/INSTALL.md is the playbook:

git clone https://github.com/verkyyi/claude-fleet.git
cd claude-fleet
claude "install claude-fleet on this machine"

Claude will check dependencies, copy the scripts to ~/.claude/fleet/, write your fleet.conf (backlog repo, main checkout, base branch), append one source line to ~/.tmux.conf, merge five hook entries into ~/.claude/settings.json, install the daemons (launchd on macOS, the systemd/ user units on Linux), and verify each piece — asking before it touches anything.

Prefer manual? Every step is in docs/dev/INSTALL.md; the pieces are plain shell scripts with no hidden state.

Running it on an unattended machine (a Mac mini you reach over SSH)? Read docs/dev/HOST.md — the host setup that keeps the machine busy only with its sessions: turning off Spotlight, the unattended-Mac checklist (never sleep, no Siri, no iCloud sync, no GUI apps on the console) and how much a container VM may take. fleet-doctor's host section checks each item; fleet-host-tune.sh plans them all (--apply asks per item).

The Claude-Code side ships as a plugin

The fleet's slash commands, the base skills/ tree and the hook table also install as a Claude Code plugin, served by a marketplace in this same repo:

claude plugin marketplace add verkyyi/claude-fleet
claude plugin install fleet@claude-fleet --scope user --yes

That replaces the copy-and-merge passes above for those three, and /plugin update fleet keeps them current — so on a second machine, or a teammate's, nobody has to remember /fleet-sync-install for them. Plugin commands are namespaced (/fleet:fleet-claim); the fleet detects which install path a machine has and seeds the form that resolves, so spawns work either way. Codex does not consume Claude plugin commands directly; /fleet-sync-install generates native Codex skills from commands/*.md under each known $CODEX_HOME/skills/<name>/SKILL.md, so the same worker seed becomes $fleet-claim there.

It does not replace the playbook: bin/, conf/, the tmux layer and the daemons are machine-level and stay at ~/.claude/fleet. A plugin's install path is version-scoped and moves on every update, so nothing with a stable absolute path — a launchd unit, a tmux bind, a hook command — can point into it.

Dependencies

tmux ≥ 3.2 · fzf ≥ 0.45 (the dashboard binds use transform) · gh (authed — only for repos on GitHub; a repo at any other git address needs none) · python3 · Claude Code (the claude CLI; also used by the two optional LLM daemons). Soft: perl Time::HiRes (sharper dash spinner).

Run bin/fleet-doctor.sh to check all of these at once. Its install lines also answer "is this machine's live install current?" — the ~/.claude/fleet half is a hand-run git pull per machine, so it goes stale in silence (see bin/fleet-install-version.sh) — and "is each login still following refs/tags/stable on its own, and if not, why?" (see bin/fleet-install-follow.sh). (No standalone jq for the core — the collector only uses gh --jq, which is built in; jq is a soft dep only for the optional conf/statusline.sh status line, which exits silently without it.)

Keybindings

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

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

tmux baseline

conf/tmux-attention.conf also carries an opinionated fleet baseline the UX assumes so a clean install behaves consistently: mouse on (the clickable footer + dashboard mouse), truecolor (default-terminal + a Tc terminal-overrides so the theme's hex colors render), escape-time 10 (snappy ESC in the Claude TUI), history-limit 50000, allow-rename/automatic-rename off (the fleet navigates by explicit window names), and the Tokyo-Night status / pane / message theme. Every line is documented inline and easy to override — put your own settings in ~/.tmux.conf after the source-file line (later wins) or comment the baseline out. Truly personal bits (prefix remaps, personal binds) are intentionally left in your ~/.tmux.conf.

Configuration

One file, ~/.claude/fleet/fleet.conf (see fleet.conf.example; every key with its default and help is in conf/fleet.conf.reference):

FLEET_REPO="you/your-repo"            # backlog + PR/CI source
FLEET_MAIN="$HOME/projects/your-repo" # worktrees are created as its siblings
FLEET_BASE_BRANCH="main"
FLEET_PROTECTED_RE="^(master|main|develop|test)$"
FLEET_CTX_WINDOW=200000               # 1000000 if you run 1M-context models
FLEET_GLOBAL_MAX_SESSIONS=0          # optional hard cap on live sessions; 0 (default) = the machine's admission decides
FLEET_AGENT="claude"                  # or "codex" — see the capability matrix below

One fleet per login

A fleet ≡ a tmux session on its own tmux server, and a login runs exactly one (issues #977/#979/#980). Every repo that login works on lives in that one fleet — add them with bin/fleet-repo.sh add — so moving between repos is the dash's grouped list, never a switch between fleets. There is no fleet picker.

Several fleets on one machine means several logins. Each login has its own ~/.config/claude-fleet/, its own fleet and its own tmux socket, so a crash in one never touches another. They share one collector without clobbering each other (see docs/dev/ARCHITECTURE.md). A repo you want isolated from the rest goes to a second login. A login that ended up with two fleets folds one into the other with bin/fleet-repo.sh fold (below).

fleet                                      # open the client — the one way in (on this machine, or through the hub)
bin/fleet-up.sh you/webapp                 # first repo: clone-or-reuse ~/projects/webapp, bring the fleet up
bin/fleet-repo.sh add you/infra ~/src/infra   # another repo in the same fleet (explicit checkout dir) — or ⌃z on the dash
bin/fleet-list.sh                          # ● live / ○ down · name · repo · checkout (+ ↳ each further repo)
bin/fleet-down.sh fleet                    # kill the fleet after you type its name; `fleet up --undo` brings it back
bin/fleet-down.sh fleet --yes --purge      # from a script, and drop its conf/cache too; checkouts stay

On SSH login, shell/fleet-intro.sh prints a short, phone-width banner: this login's fleet and its repo count, the fleet line to get in, and any machine-local intro.d lines — and then opens the client: an interactive SSH login runs bin/fleet, the same client you run on your own computer, here reading this machine (its bar says 客户端在 <机器> 上运行) — never a direct attach to the node's own session (issue #1711). scp / rsync / ssh host cmd are never touched; ~/.hushfleet-attach keeps the banner only. See docs/dev/INSTALL.md step 7 for the one ~/.zshrc line, source ~/.claude/fleet/shell/fleet-login.zsh.

Several repos in one fleet. From inside the fleet, ⌃z on the dash or the task sidebar's row menu (.) item g + 仓库… opens a popup that asks just owner/name (a GitHub URL is fine) and adds it — bin/dash-repo-add.sh, the same script behind both, issue #1103 — so on an iPad nothing leaves the screen: the checkout is ~/projects/<name> (reused if it already is that repo, cloned if missing, the clone's progress in the popup), the verdict stays up until you dismiss it, and the new repo's heading is on the dash's next frame — no restart. The shell form, bin/fleet-repo.sh add you/infra [<checkout>], is the same registration (clone-or-reuse, like fleet-up.sh) and the one that takes a different checkout dir; list shows what it hosts and remove drops one. The rest follows on its own, as it does for the first repo: a warning if Claude Code has not trusted the checkout, and the background daemons woken so the dash picks the repo up within a tick; fleet-doctor.sh's repos row lists every hosted repo and whether it is healthy. All hosted repos are equal — there is no main repo. Once a fleet hosts two:

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

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

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

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

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

Manage registered machines through MCP

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

Source 9 files
hooks/register.ts 22 lines
1// 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}
22
hooks/batches.tsx 175 lines
1// 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}
175
hooks/panels.ts 531 lines
1// 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}
531
hooks/patrol.tsx 182 lines
1// 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}
182
hooks/sheet.tsx 398 lines
1// 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}
398
hooks/batches-model.ts 136 lines
1// 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}
136
hooks/common.ts 165 lines
1// 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}
165
hooks/panels-model.ts 299 lines
1// 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}
299
types/index.d.ts 175 lines
1// 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