SLOPSHOPPER

fleet-pane

/fleet: every Claude session on this Mac, waiting-on-you first and dead ones marked, with reply and copy-resume; a quiet status line counts the sessions…

newpanecommandtoaststatusprocess
v0.1.0MITupdated 2026-10-03henderson-tech/vybava/mods/fleet-pane
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · fleet-pane
│ ┃ Fleet ✕ › fix the failing auth test and add an audit log call │ ┃ `vybava fleet` failed (exit 0): no JSON on … │ ● fleet-pane: `vybava fleet` failed (exit 0): no JSON on stdout │ ⏺ Read(src/auth.ts) │ ⎿ Read 6 lines │ ⏺ Update(src/auth.ts) │ ⎿ Added 2 lines, removed 1 line │ ⏺ Bash(bun test) │ ⎿ 3 pass, 1 fail │ │ ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ › /fleet │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Fleet
`vybava fleet` failed (exit 0): no JSON on stdout
README

Výbava

Výbava is FixIt Technologies' portable engineering environment: small tools, agent skills, and workstation diagnostics, distributed as one catalog where every item installs independently.

Packages

IDKindWhat it does
memorylintappletValidate and maintain AI memory homes — schema, indexes, wikilinks, fixtures, write hooks. → docs/memorylint.md
claude-guardsappletPreToolUse guard hooks for Claude Code — destructive git/docker, secret dumps, host-input automation, commit secrets, whole-disk walks, per-look Appium sessions, uncapped local test runners, and the context-budget rules (no shell-rewritten files, no whole-file dumps, no transcript reads); doctor re-wires hooks a settings.json rewrite dropped, vybava setup mac applies host settings. → docs/claude-guards.md
lokappletLocale catalogs for AI sessions — configured in vybava.config.ts, queried by key, written by verb with every locale kept in sync, extracted from source, gated in CI. → docs/lok.md
merge-assistappletMerge main without the mechanical conflicts — catalogs merge by key, generated files take theirs and regenerate, unmerged migrations renumber past the base; one table of what is left. → docs/merge-assist.md
vybava configverbThe shared per-repo vybava.config.ts every applet reads — init scaffolds it with typed helpers, check gates CI, show prints the evaluated JSON. → docs/config.md
shrtappletTerminal-safe short links on luko.to — offline repo rules, team-shared dynamic rules, minted codes; also the redirector server. → docs/shrt.md
postaappletDrive a shared test mailbox end to end — mint a per-run plus-address, wait for the mail a journey triggered, take its links and attachments. → docs/posta.md
fontfreezeappletFreeze variable webfonts at rendered axis positions and subset per language.
perfrigappletPerformance drills from a testing/<project>/perf manifest — ramp to first failure, percentile report.
framestatsappletAndroid frame metrics for agents - parse gfxinfo framestats dumps (cadence, present intervals, per-gesture first-input and release-window frames) and perfetto traces (per-frame UI and RenderThread work, texture uploads, ART pauses, FrameTimeline jank). → docs/framestats.md
perflab / expo-device-perfapplet + skillPhysical-device performance lab for Expo / React Native - one lease holder per phone (raw adb/devicectl on a leased phone is refused by claude-guards), native builds keyed by a portable Expo fingerprint, JS bundle variants without native rebuilds, measured runs through the project's vybava.config.ts perflab adapter, Perfetto and xctrace probes, and every number re-derived from kept evidence with compare and budget gates. The skill carries the loop, the instrument choice and the render-cost rules. → docs/perflab.md
ingressgenappletRender and drift-check complete default-deny Docker ingress policies from a manifest.
reconcileappletPull-based GitOps for the infra boxes — converge a VPS to its infra repo's merged main from a per-box manifest: HELD hotfixes, transactional nginx hooks, commit rollback, textfile metrics, mesh-only status page + estate hub. → docs/reconcile.md
readiness / release-readinessapplet + skillRelease prep of a whole integration branch: the skill runs authority questions, an inventory with a completeness critic, one named lane agent per journey cluster (own story, QA task, usertest board), capacity governance, a merged-branch roll-up and a readiness board. The applet validates the vybava.config.ts readiness adapter, freezes production..integration ranges, and seeds and renders the run directory. → docs/readiness.md
storesappletStore release verbs for App Store Connect, Google Play and EAS - versions, builds, the live listing and its store.config.json drift check, prepare/submit an App Store version, Play completed or staged rollouts that keep the live release (5xx retried with a fresh edit), and the one-distribution-certificate iOS signing check; credentials injected by onyx or read from gitignored files, never printed. → docs/stores.md
ui-loopappletThe UI polish loop's deterministic layer: syncs the TypeScript/Playwright capture harness (typed screen manifest, capture + lint, app map) into a repo with a drift gate, runs passes from the vybava.config.ts uiLoop section (on a Devbox too), split-publishes them to vitrinka and scores the review backlog. The vitrinka map / review-loop workflows drive it. → docs/uiloop.md
issues / issue-sweepapplet + skillA repository's whole GitHub issue backlog to verified outcomes: Codex lanes triage every open issue against a frozen base with a skeptic on each not-live verdict, one human gate settles every decision, live bugs are fixed test-first (RED proven, Claude fixer, independent Codex verify) into one PR per issue or dependency group, stale issues get evidence comments and approved closes, every issue is matched to its vitrinka task and the run is reported as a vitrinka artifact. → docs/issues.md
polish-kitappletThe polish skill's deterministic layer: infers app/ui/api targets from the diff through the vybava.config.ts polish globs, resolves device lanes (iOS simulators and phones, Android devices and emulators, URLs) with the exact create/boot fix, keeps the pass ledger (run.json cells: lane x screen x theme x nav x text size, plus adverse-condition matrix cells), shoots simulators and Android natively, renders contact and edge-zoom sheets and the Markdown report with a delta. → docs/polish-kit.md
prm / push-all / sync / push-backskillsThe git family — prm is the one PR verb (create → review rounds → gated merge → teardown), push-all the commit doctrine, sync the pull/merge-up flow, push-back the claim verifier. Install with vybava install ai-git.
gitkitappletThe deterministic layer those skills execute — PR selectors, review triage, merge gates, worktrees, path classification — as vybava gitkit <script>. → docs/gitkit.md
vybava setup teamverbOne-click henderson-tech Mac — the henderson group's tools (Onyx + Helium with the lazy browser MCPs, vitrinka, SwitcherooBar, Pultík; devbox opt-in) through their own channels plus the git family, as a checklist or --yes --json. → docs/setup-team.md
vybava setup agentsverbPlan, apply and check lazy Appium and shared cmux badges; safe fleet finish/resume and codexsync audit. → docs/agent-setup.md
codexsyncappletRender ~/.claude skills and commands into ~/.agents/skills, the structure Codex discovers — nesting preserved, each command a source-command skill, duplicate discovery suppressed. → docs/codexsync.md
ccxskillClaude Code stays orchestrator and app-source author; usertests, UI verification, computer use, app mapping and e2e writing/running go to Codex — the native codex subagent inside cc sessions, never the Codex CLI — routing table, briefs per mode, status handling, browser etiquette, commit duties, Opus fallback.
codexusageappletExplain where the Codex plan limit went — per-thread spend from ~/.codex rollouts, the derived allowance, and the runway left at the measured burn rate. → docs/codexusage.md
tokentimeappletWhere your AI tokens went — Claude Code transcripts and Codex rollouts indexed incrementally into permanent hour × project × model buckets (worktrees fold into their repo), rolled up by day, hour, project and model with an API-equivalent USD value. → docs/tokentime.md
readeffappletHow agents navigate code — what Claude Code and Codex reads and searches put into context against what they changed: re-reads, whole-file reads of big files, search hit rates and the most-read files, per repository, session and file. → docs/readeff.md
find-sessionapplet + skillFind the Claude Code session a pasted conversation came from — its author ranked above sessions that only quoted it — and the one line that resumes it from its launch directory under the switcheroo preset it started with (cc, cco, ccoo, …); the /find-session skill wraps it. Install with vybava install find-session-cli find-session. → docs/find-session.md
repolicyappletHold GitHub repository settings to a declared policy across whole owners — GitHub inherits no organization default, so audit reports the drift (exit 1) and apply converges it, touching only the settings the policy names. → docs/repolicy.md
menubar-doctorappletFind and fix macOS menu-bar items that run but never appear — since macOS 26 Control Center files a status item under the process that launched the app, so anything started from a terminal is filed under the terminal and stays invisible while its switch is off. → docs/menubar-doctor.md
reclaim / reclaiming-disk-spaceapplet + skillEmergency disk reclaim for a dev Mac — a fixed ladder of regenerating caches deleted biggest-first with live df after every step, an early-stop target, and by-hand notes for what it refuses to touch. The skill adds the read-only audit (whole-Data-volume size ranking, orphaned Docker volumes by compose project name, session residue: shared-temp scratch, untracked ~/Exports evidence, transcripts, idle simulators) and the confirm-then-delete workflow. Install with vybava install reclaim reclaiming-disk-space. → docs/reclaim.md
macwatchappletMac load sampler with owner attribution - append load, memory and the heaviest processes to a TSV every interval, each tagged with project, directory and the claude/codex session that spawned it; report integrates the file into offenders, per-project and per-session totals and a spike ledger. → docs/macwatch.md
plugin-gcappletGarbage-collect the Claude Code plugin cache — every version ever installed is kept behind a PID refcount that abandoned sessions never release, and the payload is almost all node_modules; reports by default, deletes only on --apply. → docs/plugin-gc.md
vpnappletPersistent macOS WireGuard tunnels outside WireGuard.app — install pulls the Onyx profile into a root-only LaunchDaemon that survives reboots; status reports the route truth (up via wg-quick (utun11)) beside the app's Disconnected, the daemon kind and whether the tunnel DNS answers. → docs/vpn.md
uplinkappletSend one process's traffic out of an iPhone's USB Personal Hotspot while the Mac's default route and VPN stay put — devices finds attached phones by UDID and their hotspot interface, relay is a loopback CONNECT relay bound to it (IP_BOUND_IF: no root, no routes), measure times a bounded random upload. claude-switcheroo's daemon drives it. → docs/uplink.md
onyx-restappletOne REST call with an Onyx-injected secret — Bearer or HTTP Basic, the response in a 0600 --out file; --base first and once, so an Onyx allowed_commands prefix pins the host. → docs/onyx-rest.md
vybava vault-stdinverbDeliver Onyx-injected JSON through a private process pipe; no plaintext credential files or terminal output. → docs/vault-stdin.md
handoffsappletHandoff ledger upkeep — handoffs reconcile judges every open handoff by whether its branches and PRs are still alive and archives the dead ones; unknown is never touched. → docs/handoffs.md
pressappletDeterministic state for the document family — project resolution, ~/Exports/<project>/ config and index, ARES lookups, shared doctrine. → docs/press.md
press-pdf / press-logo / press-offer / press-emailskillsOffer, documentation and legal PDFs; brand marks; Czech commercial DOCX; Outlook-paste client emails. Issuer identity stays machine-local. → docs/press.md

Groups (recommended, experimental, claude-hooks, ai-git, press-family, everything) are composable presets in the catalog — never code. claude-hooks is what a Claude home's hooks call; on Linux (a Devbox portal box) the same release archive installs it: vybava install claude-hooks.

Install

Homebrew (everything is public — no authentication needed):

brew install --cask FixIt-Technologies/tap/vybava

CI images, workflows and provisioning scripts use the release installer in ci/ — never a checkout of this repository:

curl -fsSL -o /tmp/vybava-install.sh \
  https://raw.githubusercontent.com/FixIt-Technologies/vybava/v0.3.3/ci/install.sh \
  && bash /tmp/vybava-install.sh --version 0.3.3 --bin-dir /usr/local/bin --install memorylint,hotfix

From source:

go build -o ./bin/vybava ./cmd/vybava
./bin/vybava catalog list
./bin/vybava install recommended

install takes item or group selectors (default: the recommended group) and supports --agent claude|codex|all, --scope user|project, --dry-run, and --json. Installed applets are links to the vybava binary, so vybava memory lint . and memorylint . are equivalent.

Layout

catalog/catalog.yaml   package and group source of truth
cmd/vybava/            multicall entrypoint
internal/<id>/         one focused Go package per capability
skills/<id>/           canonical cross-agent skill payloads (SKILL.md plus any
                       references/ and assets/ the skill ships)
docs/                  per-tool references, release flow, decisions
Dockerfile             the luko.to redirector image (deployik app "luko")

Extending

One payload + one catalog entry = one package; presets are one more catalog line. Contract: docs/decisions/0001-modular-catalog.md · checklist: CONTRIBUTING.md · releases: docs/homebrew.md.

Source 2 files
hooks/register.tsx 504 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register, RenderSurface, Timer } from 'claude-code'
3
4import type { FleetPaneCodex, FleetPaneSession, FleetView } from '../types'
5import type { CodexRow, Envelope, FleetSession, FleetSnapshot, FleetSummary } from '../types/fleet.gen'
6
7const view = atom({ plugin: 'fleet-pane', key: 'view' } as const, null as FleetView | null)
8const codex = atom({ plugin: 'fleet-pane', key: 'codex' } as const, [] as FleetPaneCodex[])
9const problem = atom({ plugin: 'fleet-pane', key: 'problem' } as const, null as string | null)
10const replyTo = atom({ plugin: 'fleet-pane', key: 'replyTo' } as const, null as string | null)
11const notice = atom({ plugin: 'fleet-pane', key: 'notice' } as const, null as string | null)
12const self = atom({ plugin: 'fleet-pane', key: 'self' } as const, null as string | null)
13
14const PANE = 'fleet'
15const FAST_MS = 5_000
16const CODEX_MS = 60_000
17const STATUS_MS = 30_000
18const STALE_MS = 2 * 60_000
19const RUN_TIMEOUT_MS = 20_000
20// The row's action buttons, drawn plain: "y: reply  c: copy".
21const ACTIONS_WIDTH = 18
22// "r: refresh  x: close".
23const HEADER_ACTIONS_WIDTH = 22
24const PROJECT_WIDTH = 12
25const DETAIL_WIDTH = 14
26const AGE_WIDTH = 6
27
28// The pane's refresh timers run only while it is open; a reload drops them
29// with the module, and session.start restarts them for a pane still open.
30let fast: Timer | null = null
31let slow: Timer | null = null
32const inFlight = new Set<'fast' | 'codex'>()
33
34// fleet draws what `vybava fleet --json` says and never decides it: grouping
35// and liveness are the Go applet's. The status line reads the summary file
36// the watch daemon publishes, so no session spawns a process on a timer.
37export const register: Register = on => {
38  on('session.start', async ($, e, next) => {
39    const started = await next(e)
40    await $.command.register({
41      name: 'fleet',
42      description: 'Every Claude session on this Mac, waiting on you first; reply or copy a resume line',
43      immediate: true,
44    })
45    const id = await $.session.id()
46    await update($, self, () => id)
47    $.clock.every(STATUS_MS, () => {
48      void refreshStatus($)
49    })
50    void refreshStatus($)
51    const panes = await $.ui.panes()
52    if (panes.some(pane => pane.id === PANE)) {
53      startRefresh($)
54    }
55    return started
56  })
57
58  // /clear and /resume give the session a new id without a session.start.
59  on('classic.SessionStart', async ($, e, next) => {
60    const ran = await next(e)
61    await update($, self, () => e.session_id)
62    return ran
63  })
64
65  on('command.run', { command: 'fleet' }, async $ => {
66    const opened = await $.ui.open({ id: PANE, title: 'Fleet', focus: true, closeOnEscape: true })
67    if (!opened.isPlaced) {
68      $.ui.toast(`the pane is waiting for room (${opened.reason})`)
69    }
70    startRefresh($)
71    return {}
72  })
73
74  on('ui.close', { id: PANE }, async ($, e, next) => {
75    stopRefresh()
76    await update($, replyTo, () => null)
77    return next(e)
78  })
79
80  on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
81    const table = $.ui.resolve(e)
82    const { Box, Text, Button } = table
83    const width = Math.max(40, e.props.bodyColumns)
84    const snapshot = await read($, view)
85    const codexRows = await read($, codex)
86    const failure = await read($, problem)
87    const replying = await read($, replyTo)
88    const outcome = await read($, notice)
89    const me = await read($, self)
90
91    if (snapshot === null) {
92      return (
93        <Box flexDirection="column">
94          <Text color={failure === null ? undefined : 'red'} wrap="truncate-end">
95            {failure ?? 'Reading the fleet…'}
96          </Text>
97        </Box>
98      )
99    }
100
101    const groups = groupSessions(snapshot.sessions)
102    const waitingOnYou = groups.waiting.filter(one => one.sessionId !== me).length
103    const first = groups.waiting.find(one => one.sessionId !== me)
104    const textWidth = width - ACTIONS_WIDTH
105
106    const row = (session: FleetPaneSession) => {
107      const isSelf = session.sessionId === me
108      const isDead = session.state === 'dead'
109      const isFirst = first !== undefined && session.sessionId === first.sessionId
110      const target = session
111      return (
112        <Box key={`row:${session.sessionId}`} flexDirection="column">
113          <Box flexDirection="row">
114            <Box width={textWidth}>
115              <Text wrap="truncate-end" dimColor={isSelf}>
116                {sessionLine(session, isSelf, textWidth)}
117              </Text>
118            </Box>
119            <Box flexDirection="row" gap={2}>
120              {!isSelf && !isDead && (
121                <Button
122                  key={`reply:${session.sessionId}`}
123                  label="reply"
124                  plain
125                  dimColor={!isFirst}
126                  {...(isFirst ? { hotkey: 'y' } : {})}
127                  onPress={() => {
128                    void openReply($, target.sessionId)
129                  }}
130                />
131              )}
132              <Button
133                key={`copy:${session.sessionId}`}
134                label="copy"
135                plain
136                dimColor={!isFirst}
137                {...(isFirst ? { hotkey: 'c' } : {})}
138                onPress={press => {
139                  void copyResume($, target, press.surface)
140                }}
141              />
142            </Box>
143          </Box>
144          {replying === session.sessionId && 'Input' in table && (
145            <Box flexDirection="column" paddingLeft={2}>
146              <table.Input
147                key="reply-text"
148                label={`reply to ${displayName(session)}: `}
149                placeholder="type, Enter sends · Esc returns"
150                submitLabel="send"
151                autoFocus
152                onSubmit={value => {
153                  void sendReply($, target, value)
154                }}
155              />
156              <Text dimColor wrap="truncate-end">
157                {session.state === 'busy'
158                  ? 'it is busy: the reply lands inside its running turn'
159                  : 'the reply arrives as a message from the fleet mod'}
160              </Text>
161              <Button
162                key="reply-cancel"
163                label="cancel"
164                plain
165                dimColor
166                onPress={() => {
167                  void update($, replyTo, () => null)
168                }}
169              />
170            </Box>
171          )}
172          {replying === session.sessionId && !('Input' in table) && (
173            <Text dimColor wrap="truncate-end">
174              this surface has no text field: reply from the terminal or desktop
175            </Text>
176          )}
177        </Box>
178      )
179    }
180
181    const idleParts = [`idle · ${groups.idle.length}`]
182    if (snapshot.counts.shell > 0) {
183      idleParts.push(`${snapshot.counts.shell} at a shell`)
184    }
185    if (snapshot.counts.ended + snapshot.counts.unknown > 0) {
186      idleParts.push(`${snapshot.counts.ended + snapshot.counts.unknown} ended or unknown`)
187    }
188
189    return (
190      <Box flexDirection="column">
191        <Box flexDirection="row">
192          <Box width={width - HEADER_ACTIONS_WIDTH}>
193            <Text bold wrap="truncate-end">
194              {`Fleet · ${snapshot.counts.total} sessions · ${waitingOnYou} waiting on you`}
195            </Text>
196          </Box>
197          <Box flexDirection="row" gap={2}>
198            <Button
199              key="refresh"
200              label="refresh"
201              plain
202              hotkey="r"
203              onPress={() => {
204                void refresh($, true)
205              }}
206            />
207            <Button
208              key="close"
209              label="close"
210              plain
211              role="dismiss"
212              hotkey="x"
213              onPress={() => {
214                void closePane($)
215              }}
216            />
217          </Box>
218        </Box>
219        {failure !== null && (
220          <Text color="red" wrap="truncate-end">
221            {failure}
222          </Text>
223        )}
224        {outcome !== null && (
225          <Text dimColor wrap="truncate-end">
226            {outcome}
227          </Text>
228        )}
229        <Text dimColor>{rule(`waiting on you · ${groups.waiting.length}`, width)}</Text>
230        {groups.waiting.length === 0 && <Text dimColor>  nobody is waiting on you</Text>}
231        {groups.waiting.map(row)}
232        {groups.dead.length > 0 && <Text dimColor>{rule(`died while busy · ${groups.dead.length}`, width)}</Text>}
233        {groups.dead.map(row)}
234        <Text dimColor>{rule(`working · ${groups.working.length}`, width)}</Text>
235        {groups.working.map(row)}
236        <Text dimColor>{rule(idleParts.join(' · '), width)}</Text>
237        {codexRows.length > 0 && <Text dimColor>{rule(`codex (read-only) · ${codexRows.length}`, width)}</Text>}
238        {codexRows.map(one => (
239          <Text dimColor wrap="truncate-end">
240            {codexLine(one, snapshot.generatedAt, width)}
241          </Text>
242        ))}
243      </Box>
244    )
245  })
246}
247
248function startRefresh($: EngineInterface): void {
249  if (fast !== null) {
250    return
251  }
252  void refresh($, true)
253  fast = $.clock.every(FAST_MS, () => {
254    void refresh($, false)
255  })
256  slow = $.clock.every(CODEX_MS, () => {
257    void refresh($, true)
258  })
259}
260
261// closePane stops the timers itself: the ui.close hook covers the person's
262// close and Escape, and this covers our own button whatever the chain does.
263async function closePane($: EngineInterface): Promise<void> {
264  stopRefresh()
265  await update($, replyTo, () => null)
266  await $.ui.close({ id: PANE })
267}
268
269function stopRefresh(): void {
270  fast?.cancel()
271  slow?.cancel()
272  fast = null
273  slow = null
274}
275
276// refresh runs `vybava fleet --json`; the Codex rows (lsof per process) only
277// on the slow beat. A failure is logged and drawn, never swallowed.
278async function refresh($: EngineInterface, withCodex: boolean): Promise<void> {
279  const kind = withCodex ? 'codex' : 'fast'
280  if (inFlight.has(kind)) {
281    return
282  }
283  inFlight.add(kind)
284  try {
285    const argv = withCodex ? ['vybava', 'fleet', '--json'] : ['vybava', 'fleet', '--json', '--no-codex']
286    let ran
287    try {
288      ran = await $.process.run(argv, { timeoutMs: RUN_TIMEOUT_MS })
289    } catch (error) {
290      await fail($, `could not run \`vybava fleet\`: ${messageOf(error)}`)
291      return
292    }
293    const envelope = parseEnvelope(ran.stdout)
294    if (envelope === null || !envelope.ok || envelope.data === undefined) {
295      await fail($, `\`vybava fleet\` failed (exit ${ran.exitCode}): ${reasonOf(envelope, ran.stderr)}`)
296      return
297    }
298    const data = envelope.data
299    await update($, view, () => ({ generatedAt: data.generatedAt, counts: data.counts, sessions: data.sessions.map(paneSession) }))
300    if (withCodex) {
301      await update($, codex, () => data.codex.map(paneCodex))
302    }
303    await update($, problem, () => null)
304  } finally {
305    inFlight.delete(kind)
306  }
307}
308
309async function fail($: EngineInterface, text: string): Promise<void> {
310  $.ui.log(text, { to: 'debug' })
311  await update($, problem, () => text)
312}
313
314// refreshStatus reads the published summary: no file, no line (the daemon
315// is not installed); an old file says so instead of showing old counts.
316async function refreshStatus($: EngineInterface): Promise<void> {
317  const home = await $.env.get('HOME')
318  if (home === undefined || home === '') {
319    $.ui.log('HOME is unset; no summary to read', { to: 'debug' })
320    return
321  }
322  const path = `${home}/.local/state/vybava/fleet/summary.json`
323  if (!(await $.fs.exists(path))) {
324    $.ui.status(undefined)
325    return
326  }
327  let summary: FleetSummary
328  try {
329    summary = parseSummary(await $.fs.read(path))
330  } catch (error) {
331    $.ui.log(`${path} is unreadable: ${messageOf(error)}`, { to: 'debug' })
332    $.ui.status('fleet summary unreadable · /fleet')
333    return
334  }
335  const age = (await $.clock.now()) - Date.parse(summary.generatedAt)
336  if (!(age <= STALE_MS)) {
337    $.ui.status('fleet summary stale (is vybava.watchd running?)')
338    return
339  }
340  const me = await read($, self)
341  const waiting = summary.waiting.filter(one => one.sessionId !== me).length
342  $.ui.status(waiting > 0 ? `${waiting} waiting on you · /fleet` : undefined)
343}
344
345async function openReply($: EngineInterface, sessionId: string): Promise<void> {
346  await update($, notice, () => null)
347  await update($, replyTo, () => sessionId)
348}
349
350// sendReply delivers what the person typed and pressed, nothing else.
351async function sendReply($: EngineInterface, session: FleetPaneSession, value: string): Promise<void> {
352  const text = value.trim()
353  if (text === '') {
354    await update($, notice, () => 'nothing sent: the reply was empty')
355    return
356  }
357  const name = displayName(session)
358  let outcome: string
359  try {
360    const sent = await $.session.send({ to: { sessionId: session.sessionId }, text })
361    outcome = sent.isDelivered ? `sent to ${name}` : `not delivered to ${name}: ${sent.reason}`
362  } catch (error) {
363    outcome = `not delivered to ${name}: ${messageOf(error)}`
364    $.ui.log(outcome, { to: 'debug' })
365  }
366  await update($, replyTo, () => null)
367  await update($, notice, () => outcome)
368}
369
370async function copyResume($: EngineInterface, session: FleetPaneSession, surface: RenderSurface): Promise<void> {
371  const copied = await $.ui.copy({ text: session.resume, surface })
372  const outcome = copied.isCopied
373    ? `copied: ${session.resume}`
374    : `copy failed (${copied.reason}): ${session.resume}`
375  await update($, notice, () => outcome)
376}
377
378type Groups = { waiting: FleetPaneSession[]; dead: FleetPaneSession[]; working: FleetPaneSession[]; idle: FleetPaneSession[] }
379
380function groupSessions(sessions: readonly FleetPaneSession[]): Groups {
381  const groups: Groups = { waiting: [], dead: [], working: [], idle: [] }
382  for (const session of sessions) {
383    if (session.state === 'waiting') {
384      groups.waiting.push(session)
385    } else if (session.state === 'dead') {
386      groups.dead.push(session)
387    } else if (session.state === 'busy') {
388      groups.working.push(session)
389    } else {
390      groups.idle.push(session)
391    }
392  }
393  groups.waiting.sort((a, b) => b.ageSeconds - a.ageSeconds)
394  return groups
395}
396
397function sessionLine(session: FleetPaneSession, isSelf: boolean, width: number): string {
398  const mark = session.state === 'waiting' ? '▸' : session.state === 'dead' ? '✕' : ' '
399  const name = `${displayName(session)}${isSelf ? ' (you)' : ''}`
400  const detail = detailOf(session)
401  const age = ageText(session.ageSeconds).padStart(AGE_WIDTH)
402  const fixed = 2 + PROJECT_WIDTH + 1 + 1 + DETAIL_WIDTH + 1 + AGE_WIDTH
403  const nameWidth = width - fixed
404  if (nameWidth < 8) {
405    return fit(`${mark} ${session.project} ${name} ${age.trim()}`, width)
406  }
407  return `${mark} ${fit(session.project, PROJECT_WIDTH)} ${fit(name, nameWidth)} ${fit(detail, DETAIL_WIDTH)} ${age}`
408}
409
410function detailOf(session: FleetPaneSession): string {
411  if (session.state === 'waiting') {
412    return session.waitingFor ?? 'waiting'
413  }
414  if (session.state === 'dead') {
415    return session.openJobs > 0 ? `died · ${session.openJobs} jobs` : 'died while busy'
416  }
417  return session.openJobs > 0 ? `${session.state} · ${session.openJobs} bg` : session.state
418}
419
420function codexLine(row: FleetPaneCodex, generatedAt: string, width: number): string {
421  const name = row.name ?? row.branch ?? row.threadId.slice(0, 8)
422  const last = row.lastCallAt === undefined ? '' : `last call ${ageText(Math.max(0, (Date.parse(generatedAt) - Date.parse(row.lastCallAt)) / 1000))} ago`
423  return fit(`  ${fit(row.project, PROJECT_WIDTH)} ${name} · ${row.calls} calls ${last}`.trimEnd(), width)
424}
425
426// paneSession and paneCodex keep what the pane draws; their parameter types
427// are the generated Go contract, so a drift there fails tsc here.
428function paneSession(session: FleetSession): FleetPaneSession {
429  const { sessionId, name, project, state, waitingFor, ageSeconds, openJobs, resume } = session
430  return { sessionId, name, project, state, waitingFor, ageSeconds, openJobs, resume }
431}
432
433function paneCodex(row: CodexRow): FleetPaneCodex {
434  const { threadId, name, branch, project, calls, lastCallAt } = row
435  return { threadId, name, branch, project, calls, lastCallAt }
436}
437
438function displayName(session: FleetPaneSession): string {
439  return session.name ?? session.sessionId.slice(0, 8)
440}
441
442function rule(title: string, width: number): string {
443  const head = `── ${title} `
444  return head.length >= width ? fit(head, width) : head + '─'.repeat(width - head.length)
445}
446
447function fit(text: string, width: number): string {
448  if (text.length <= width) {
449    return text.padEnd(width)
450  }
451  return width <= 1 ? text.slice(0, width) : `${text.slice(0, width - 1)}…`
452}
453
454function ageText(seconds: number): string {
455  const minutes = Math.floor(seconds / 60)
456  if (minutes < 1) {
457    return `${Math.floor(seconds)}s`
458  }
459  if (minutes < 60) {
460    return `${minutes}m`
461  }
462  const hours = Math.floor(minutes / 60)
463  if (hours < 48) {
464    const rest = minutes % 60
465    return rest === 0 ? `${hours}h` : `${hours}h${rest}m`
466  }
467  return `${Math.floor(hours / 24)}d`
468}
469
470function parseEnvelope(stdout: string): Envelope<FleetSnapshot> | null {
471  let parsed: unknown
472  try {
473    parsed = JSON.parse(stdout)
474  } catch {
475    return null
476  }
477  if (typeof parsed !== 'object' || parsed === null || !('ok' in parsed) || typeof parsed.ok !== 'boolean' || !('diagnostics' in parsed) || !Array.isArray(parsed.diagnostics)) {
478    return null
479  }
480  // The shape past these keys is the Go contract, held by fleet.gen.d.ts's drift test.
481  return parsed as Envelope<FleetSnapshot>
482}
483
484function parseSummary(text: string): FleetSummary {
485  const parsed: unknown = JSON.parse(text)
486  if (typeof parsed !== 'object' || parsed === null || !('generatedAt' in parsed) || typeof parsed.generatedAt !== 'string' || !('waiting' in parsed) || !Array.isArray(parsed.waiting)) {
487    throw new Error('not a fleet summary')
488  }
489  return parsed as FleetSummary
490}
491
492function reasonOf(envelope: Envelope<FleetSnapshot> | null, stderr: string): string {
493  const diagnostic = envelope?.diagnostics.find(one => one.severity === 'error') ?? envelope?.diagnostics[0]
494  if (diagnostic !== undefined) {
495    return `${diagnostic.code}: ${diagnostic.detail}`
496  }
497  const line = stderr.trim().split('\n')[0]
498  return line === undefined || line === '' ? 'no JSON on stdout' : line
499}
500
501function messageOf(error: unknown): string {
502  return error instanceof Error ? error.message : String(error)
503}
504
types/index.d.ts 66 lines
1// fleet's state contract: the session values its hooks keep in $.state.
2// Self-contained by rule, so it restates the few fields the pane draws from
3// fleet.gen.d.ts (the Go contract); register.tsx assigns the generated types
4// to these, so tsc fails if the two ever stop fitting.
5
6export type FleetPaneState = 'waiting' | 'busy' | 'idle' | 'shell' | 'dead' | 'ended' | 'unknown'
7
8/** One Claude session as the pane draws it. */
9export type FleetPaneSession = {
10  sessionId: string
11  name?: string
12  project: string
13  state: FleetPaneState
14  waitingFor?: string
15  ageSeconds: number
16  openJobs: number
17  /** `cd '<cwd>' && claude --resume <id>`, what [copy] puts on the clipboard. */
18  resume: string
19}
20
21export type FleetPaneCounts = {
22  total: number
23  waiting: number
24  busy: number
25  idle: number
26  shell: number
27  dead: number
28  ended: number
29  unknown: number
30}
31
32/** One live Codex thread, read-only. */
33export type FleetPaneCodex = {
34  threadId: string
35  name?: string
36  branch?: string
37  project: string
38  calls: number
39  lastCallAt?: string
40}
41
42/** What the pane draws from: the last `vybava fleet --json` that succeeded. */
43export type FleetView = {
44  generatedAt: string
45  counts: FleetPaneCounts
46  sessions: FleetPaneSession[]
47}
48
49declare module 'claude-code' {
50  interface PluginState {
51    'fleet-pane': {
52      view: FleetView | null
53      /** Codex rows from the last refresh that read them (every ~60 s). */
54      codex: FleetPaneCodex[]
55      /** Why the last refresh failed; null once one succeeds. */
56      problem: string | null
57      /** The session whose reply field is open. */
58      replyTo: string | null
59      /** The last action's outcome: sent, not delivered, copied. */
60      notice: string | null
61      /** This session's own id: its row is marked and left out of the count. */
62      self: string | null
63    }
64  }
65}
66