Quiet rail: a read-only Firstmate band above the prompt that says what needs you and what each worker is doing (live from Herdr), and a /fm-fleet pane, for one…

A Claude Code plugin that shows your Firstmate fleet inside the Claude session: what needs you, and what every worker is doing.
It adds a two-row band above the prompt and a /fm-fleet pane. The pane's Discuss with Firstmate button puts an editable draft about a recorded call in your prompt box, so you can answer Firstmate without retyping the context.

<sub>The recording uses the made-up demo home in fixtures/demo-home. MP4 version.</sub>
The mod is read-only. It reads one Firstmate home, Herdr's agent list and a quota command. It never writes to the home, and it never dispatches, steers, resolves or merges anything. Discuss only fills your prompt box; you decide whether to send.
state/ and data/ folders.herdr agent list, for live worker state. Run Claude Code inside Herdr, or point the herdr_command option at it.Load the plugin for a session:
git clone https://github.com/dctmfoo/firstmate-mod.git
claude --plugin-dir ./firstmate-mod
Then set the Firstmate home, either with /config (the plugin's home row) or in ~/.claude/settings.json:
{
"pluginConfigs": {
"firstmate-mod@inline": {
"options": { "home": "/absolute/path/to/your/firstmate" }
}
}
}
Until a home is set, the band asks you to set the home option and the mod reads nothing. The home must be an absolute path.
The plugin manifest's userConfig owns option descriptions, defaults and limits. Use herdr_command and quota_command to choose the executables, herdr_seconds, summary_seconds and headroom_seconds to change read intervals, and headroom_toast_percent to configure headroom toasts.
The repository ships a made-up demo home. Copy it with its clock moved to now, then point the plugin at the copy and its stand-in commands:
bun scripts/demo-home.ts /tmp/fm-demo
{
"pluginConfigs": {
"firstmate-mod@inline": {
"options": {
"home": "/tmp/fm-demo",
"herdr_command": "/tmp/fm-demo/bin/herdr",
"quota_command": "/tmp/fm-demo/bin/quota-axi"
}
}
}
}

Row 1 is yours. It says Nothing needs you, or what does:
Merge? <name> <url> · 4 more need you.3 need you · oldest: <call>, 10 days.can't read Firstmate's list.Row 2 is the crew. Every worker by plain name, with how long it has been in its current state, worst news first:
| Group | Meaning |
|---|---|
| Stuck | A question on its screen, a closed window, a worker quiet for more than 10 minutes whose last word was working, resolved or nothing, or one that said blocked or failed. The reason follows the name. |
| Just finished | A quiet or closed worker that said done. It leaves the band after an hour. |
| Working | Herdr says the pane is working, reports an unknown state, or a quiet pane is still inside the 10-minute grace period. |
| Waiting | The worker said paused, or needs-decision (shown as for Firstmate), and its pane is quiet. |
A worker's name is its backlog title without the project prefix, cut at the first ;, , or (, then to 30 columns. A task the backlog does not list is named by its task id. When the row is too long, the times go first, then workers are folded into +N more, worst news last. Narrow terminals shorten FIRSTMATE to FM and wrap row 1; a link is never cut.
/fm-fleet, or the /fm-fleet button on the band, opens one view: Needs you, Stuck, Working, Waiting, then Just finished. Press r to re-read and Esc to close it.

done in the last hour, then what landed today (merged pull requests with their full link, scout reports).Low on headroom line appears only when a provider's tightest fresh limit is at or below 20%.Discuss inserts a draft like this into the prompt box. Nothing is sent until you press Enter:
Firstmate, I want to discuss the recorded owner call lighthouse-privacy-policy-s1 (Lighthouse: draft the privacy policy for Google Play and the App Store).
Recorded reason: Privacy policy draft ready for approval in the task report. Decide: 1. approve the text as drafted or ask for edits; 2. where it is hosted.
My thoughts:

/fm-refresh re-reads everything now. It only reads; it runs no session start, reconcile or supervision.⚠ firstmate-mod: …) carries summary or crew read failures, in the words of the failure. Quota failures have no visible error indicator./clear starts a new silent baseline; reload and resume keep what was already announced.Herdr knows whether a pane is working, quiet, blocked or gone, but not whether a quiet worker finished or is waiting, and it keeps no times. So each worker's Herdr state is joined with the newest typed line of its own state/<id>.status:
| Herdr | Newest status line | Shown as | Time since |
|---|---|---|---|
| working | anything | Working | Herdr turned working |
| unknown or unrecognized | anything | Working | Herdr turned unknown |
| blocked | anything | Stuck: question on its screen | Herdr turned blocked |
| idle or done | paused | Waiting | that line |
| idle or done | needs-decision | Waiting: for Firstmate | that line |
| idle or done | blocked, failed | Stuck | that line |
| idle or done | done | Just finished (one hour) | that line |
| idle or done | working, resolved or none | Working; after 10 minutes Stuck: stopped without a word | Herdr turned quiet |
| no pane listed | done | Just finished (one hour) | that line |
| no pane listed | anything else | Stuck: window closed | Herdr stamp |
The mod stamps each change in Herdr state with the time of the read that saw it. On first sighting it seeds that stamp from the newest status line's [at=…], then the task's spawn time (spawn_gen in its metadata), then the read time, so a first-seen time can be off. Nothing reads a screen.
Every read goes through Claude Code's $.fs or $.process.run (by argv, never a shell) on timers outside drawing. Drawing only reads cached session state.
| Lane | Reads |
|---|---|
| Crew | herdr agent list, one listing of state/, changed state/<id>.meta files and each tracked worker's changed state/<id>.status file. While workers are tracked, it also checks data/backlog.md and reads it when its mtime changed. |
| Summary | state/home-summary.json (schema checked in hooks/normalize.ts). For merge asks it also uses data/backlog.md and a listing of state/ to drop asks Firstmate's own records show settled. |
| Headroom | quota-axi --provider claude,codex --json --no-credential-refresh. |
A worker is a task whose state/<id>.meta names a herdr_tab_id; that tab id is the join key to Herdr's agent list. Task ids are checked against TASK_ID before they are put on a path. No lane overlaps itself, and a failed read keeps the last good value. The mod calls no write API: claude plugin validate lists every engine call the module makes, and $.fs.write is not among them.
The crew lane takes regular files' mtimes from the listing; a non-file status entry needs a separate stat. Changed metadata files are read in parallel, then changed status files are read in parallel. A changed metadata file can move a worker to a new Herdr tab, and changed backlog text refreshes worker names. If an individual file read fails, its cached metadata, status or title remains until a later read succeeds. An unknown backlog mtime forces a read.
A missing status file, or a non-file status entry whose stat fails, clears its cached line. A first metadata read failure skips that worker; a first backlog read failure leaves task ids as names.
A merge ask leaves Needs you as soon as the backlog shows its task closed, or neither a backlog row nor state/<id>.meta exists for it, without waiting for Firstmate's next forge poll. An unreadable backlog, or a task id that fails the check, leaves the ask visible.
See CONTRIBUTING.md for setup, checks, text previews and fixture generation, and docs/design.md for the design rationale.
This release reads one Firstmate home. It has no approve, merge, dispatch or steer action, and it does not include secondmate homes or a shell statusLine.
hooks/register.tsx 310 lines1// Quiet rail: the Firstmate band above the prompt and the /fm-fleet pane.
2//
3// Reads happen on lifecycle-managed timers outside every drawing, each lane
4// on its own cadence and never overlapping itself; drawings only read the
5// cached lanes from $.state. The mod writes nothing outside its own session
6// state, and offers no approve, merge, dispatch or steer action.
7
8import { atom, read, update } from 'claude-code'
9import type { ElementConstructor, EngineInterface, Register, TextProps, Timer } from 'claude-code'
10
11import type { CrewRecords, Lane, QuotaRecords } from '../types'
12import { bandLines, statusText } from './band'
13import { configOf } from './config'
14import { discussDraft } from './discuss'
15import { fleetViewOf, type FleetView } from './fleet'
16import { paneItems, type PaneItem } from './pane'
17import { type Host, newCrewCache, readCrew, readHome, readQuota } from './sources'
18import type { Line, Tone } from './text'
19import { EMPTY_LEDGER, type ToastKind, transitions } from './toasts'
20
21export const PANE_ID = 'firstmate-fleet'
22export const PANE_TITLE = 'Firstmate'
23
24/**
25 * The docked width the pane asks for: room for a full forge URL, narrow enough
26 * that the conversation keeps most of the screen. A width the person drags wins.
27 */
28const DOCK_COLUMNS = 72
29
30/** The band's own way into the pane, beside its first row. */
31const OPEN_LABEL = ' /fm-fleet'
32
33/** How long after a /clear's session.end the lanes are read again. */
34const CLEAR_SETTLE_MS = 500
35
36const homeLane = atom({ plugin: 'firstmate-mod', key: 'home' } as const, { value: null, readAt: null, error: null })
37const crewLane = atom({ plugin: 'firstmate-mod', key: 'crew' } as const, { value: null, readAt: null, error: null })
38const quotaLane = atom({ plugin: 'firstmate-mod', key: 'quota' } as const, { value: null, readAt: null, error: null })
39const toastLedger = atom({ plugin: 'firstmate-mod', key: 'ledger' } as const, EMPTY_LEDGER)
40
41const FLEET_COMMAND = {
42 name: 'fm-fleet',
43 description: 'Open the Firstmate pane: what needs you, and every worker by state (read-only)',
44}
45
46const REFRESH_COMMAND = {
47 name: 'fm-refresh',
48 description: 'Re-read the configured Firstmate home now (read-only; runs no session start, reconcile or supervision)',
49}
50
51const messageOf = (error: unknown) => (error instanceof Error ? error.message : String(error))
52
53function hostOf($: EngineInterface): Host {
54 return {
55 read: path => $.fs.read(path),
56 list: path => $.fs.list(path),
57 mtime: path => $.fs.stat(path).then(
58 stat => (stat.kind === 'file' ? stat.mtimeMs : null),
59 () => null,
60 ),
61 run: (argv, init) => $.process.run(argv, init),
62 now: () => $.clock.now(),
63 }
64}
65
66async function lanesOf($: EngineInterface) {
67 return {
68 home: await read($, homeLane),
69 crew: await read($, crewLane),
70 quota: await read($, quotaLane),
71 }
72}
73
74const COLORS: Record<Tone, { color?: string; bold?: boolean; dimColor?: boolean }> = {
75 brand: { color: 'green', bold: true },
76 head: { color: 'green' },
77 plain: {},
78 muted: { dimColor: true },
79 warn: { color: 'yellow' },
80}
81
82/** What one load of the module keeps: its options, the in-flight guards, its timers. */
83const runtime = {
84 config: configOf({}),
85 crewCache: newCrewCache(),
86 inFlight: { home: false, crew: false, quota: false },
87 timers: [] as Timer[],
88 /** The status line as this load last set it; null until it has, so a reload sets it afresh. */
89 shownStatus: null as string | undefined | null,
90 /**
91 * Starts a full re-read on the session's own engine handle (the one
92 * session.start gave the timers), so the work outlives the command, press
93 * or /clear dispatch that asked for it.
94 */
95 kick: null as ((delayMs?: number) => void) | null,
96}
97
98async function settle($: EngineInterface, kinds: ToastKind[]) {
99 try {
100 const now = await $.clock.now()
101 const view = fleetViewOf(runtime.config.home, await lanesOf($), now)
102 const status = statusText(view)
103 if (status !== runtime.shownStatus) {
104 runtime.shownStatus = status
105 await $.ui.status(status)
106 }
107 // Most reads change nothing: skip the session-state write unless the ledger moved.
108 const ledger = await read($, toastLedger)
109 if (transitions(ledger, view, runtime.config.headroomToastPercent, kinds).ledger === ledger) return
110 let toasts: string[] = []
111 await update($, toastLedger, current => {
112 const step = transitions(current, view, runtime.config.headroomToastPercent, kinds)
113 toasts = step.toasts
114 return step.ledger
115 })
116 for (const text of toasts) await $.ui.toast(text)
117 } catch {
118 // A status line or toast the host refused must not stop the lane's next read.
119 runtime.shownStatus = null
120 }
121}
122
123/** A lane after a failed read: its last good value kept beside the newest failure. */
124const failure = <T,>(prev: Lane<T>, error: unknown, at: number): Lane<T> => ({
125 ...prev,
126 error: { reason: messageOf(error), at },
127})
128
129async function refreshHome($: EngineInterface) {
130 const { config, inFlight } = runtime
131 if (config.home === null || inFlight.home) return
132 inFlight.home = true
133 try {
134 const value = await readHome(hostOf($), config.home, runtime.crewCache.backlog)
135 const at = await $.clock.now()
136 await update($, homeLane, () => ({ value, readAt: at, error: null }))
137 } catch (error) {
138 const at = await $.clock.now()
139 await update($, homeLane, prev => failure(prev, error, at))
140 } finally {
141 inFlight.home = false
142 }
143 await settle($, ['needs', 'landed'])
144}
145
146async function refreshCrew($: EngineInterface) {
147 const { config, inFlight } = runtime
148 if (config.home === null || inFlight.crew) return
149 inFlight.crew = true
150 try {
151 const prev = (await read($, crewLane)).value
152 const at = await $.clock.now()
153 const value: CrewRecords = await readCrew(hostOf($), config.home, config.herdrCommand, runtime.crewCache, prev, at)
154 await update($, crewLane, () => ({ value, readAt: at, error: null }))
155 } catch (error) {
156 const at = await $.clock.now()
157 await update($, crewLane, prev => failure(prev, error, at))
158 } finally {
159 inFlight.crew = false
160 }
161 await settle($, ['stuck'])
162}
163
164async function refreshQuota($: EngineInterface) {
165 const { config, inFlight } = runtime
166 if (config.home === null || inFlight.quota) return
167 inFlight.quota = true
168 try {
169 const value: QuotaRecords = await readQuota(hostOf($), config.quotaCommand, config.home)
170 const at = await $.clock.now()
171 await update($, quotaLane, () => ({ value, readAt: at, error: null }))
172 } catch (error) {
173 const at = await $.clock.now()
174 await update($, quotaLane, prev => failure(prev, error, at))
175 } finally {
176 inFlight.quota = false
177 }
178 await settle($, ['headroom'])
179}
180
181async function refreshAll($: EngineInterface) {
182 await Promise.all([refreshHome($), refreshCrew($), refreshQuota($)])
183}
184
185function startTimers($: EngineInterface) {
186 for (const timer of runtime.timers) timer.cancel()
187 const { config } = runtime
188 if (config.home === null) {
189 runtime.timers = []
190 return
191 }
192 runtime.kick = (delayMs = 0) => {
193 $.clock.after(delayMs, () => void refreshAll($))
194 }
195 runtime.timers = [
196 $.clock.after(0, () => void refreshAll($)),
197 $.clock.every(config.herdrMs, () => void refreshCrew($)),
198 $.clock.every(config.summaryMs, () => void refreshHome($)),
199 $.clock.every(config.headroomMs, () => void refreshQuota($)),
200 ]
201}
202
203function openPane($: EngineInterface) {
204 return $.ui.open({ id: PANE_ID, title: PANE_TITLE, focus: true, closeOnEscape: true, columns: DOCK_COLUMNS })
205}
206
207function lineOf(Text: ElementConstructor<TextProps>, line: Line) {
208 return <Text>{line.map(s => <Text {...COLORS[s.tone]}>{s.text}</Text>)}</Text>
209}
210
211export const register: Register = (on, options) => {
212 runtime.config = configOf(options)
213 const { config } = runtime
214
215 on('session.start', async ($, e, next) => {
216 await $.command.register({ ...FLEET_COMMAND, immediate: true })
217 await $.command.register({ ...REFRESH_COMMAND, immediate: true })
218 startTimers($)
219 return next(e)
220 })
221
222 // A /clear ends the conversation and resets the session's $.state, with no
223 // session.start after it: read again once the reset has landed, rather than
224 // waiting a full cadence, and let the toast ledger baseline afresh.
225 on('session.end', async ($, e, next) => {
226 if (e.reason === 'clear') runtime.kick?.(CLEAR_SETTLE_MS)
227 return next(e)
228 })
229
230 on('command.run', { command: FLEET_COMMAND.name }, async $ => {
231 const opened = await openPane($)
232 const where = opened.isPlaced ? '' : ` (waiting: ${opened.reason})`
233 return { text: `Firstmate pane opened, read-only${where}.` }
234 })
235
236 on('command.run', { command: REFRESH_COMMAND.name }, async $ => {
237 if (config.home === null) return { text: 'Firstmate: no home configured; nothing to read.' }
238 runtime.kick?.()
239 return { text: `Firstmate: re-reading ${config.home} (read-only).` }
240 })
241
242 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
243 if (e.props.hasSurvey) return next(e)
244 const { Box, Button, Text } = $.ui.resolve(e)
245 const now = await $.clock.now()
246 const view: FleetView = fleetViewOf(config.home, await lanesOf($), now)
247 const width = Math.max(20, e.props.bodyColumns)
248 // The first row keeps room for the button, so a link or a name is never truncated to fit it.
249 const lines = bandLines(view, width - OPEN_LABEL.length - 1, width, now)
250 const below = await next(e)
251 // The rule is the band's first row to go when the slot is short (an inline pane open).
252 const hasRule = e.props.maxRows > lines.length
253 return (
254 <Box flexDirection="column">
255 {hasRule && (
256 <Text color="green" dimColor>
257 {'─'.repeat(width)}
258 </Text>
259 )}
260 {lines.map((l, i) =>
261 i === 0 ? (
262 <Box flexDirection="row" flexWrap="wrap">
263 {lineOf(Text, l)}
264 <Button key="open-fleet" label={OPEN_LABEL} plain dimColor onPress={() => openPane($)} />
265 </Box>
266 ) : (
267 lineOf(Text, l)
268 ),
269 )}
270 {below}
271 </Box>
272 )
273 })
274
275 on('ui.render', { component: 'Pane', requestId: PANE_ID }, async ($, e) => {
276 const { Box, Button, Text } = $.ui.resolve(e)
277 const now = await $.clock.now()
278 const view = fleetViewOf(config.home, await lanesOf($), now)
279 const width = Math.max(24, e.props.bodyColumns)
280 const items = paneItems(view, width, now)
281 const itemOf = (item: PaneItem) => {
282 switch (item.kind) {
283 case 'line':
284 return item.indent === undefined ? lineOf(Text, item.line) : <Box paddingLeft={item.indent}>{lineOf(Text, item.line)}</Box>
285 case 'gap':
286 return <Text> </Text>
287 case 'header':
288 return (
289 <Box flexDirection="row" flexWrap="wrap" columnGap={2}>
290 <Text {...COLORS.brand}>FIRSTMATE</Text>
291 <Button key="refresh" label="refresh" hotkey="r" plain dimColor onPress={() => runtime.kick?.()} />
292 <Text dimColor>Esc closes</Text>
293 </Box>
294 )
295 case 'discuss':
296 return (
297 <Box flexDirection="row" paddingLeft={2}>
298 <Button
299 key={`discuss-${item.call.key}`}
300 label="Discuss with Firstmate"
301 onPress={() => $.prompt.fill({ text: discussDraft(item.call), mode: 'insert' })}
302 />
303 </Box>
304 )
305 }
306 }
307 return <Box flexDirection="column">{items.map(itemOf)}</Box>
308 })
309}
310hooks/band.ts 126 lines1// The band above the prompt: two quiet rows. The first says what needs the
2// captain, or that nothing does; a ready merge shows its full PR link. The
3// second names every worker in plain words with how long it has been in its
4// current state, worst first. Narrow widths drop the times, then end the row
5// in "+N more"; a link or a name is never cut.
6
7import { type CrewRow, type FleetView, type Group, GROUP_LABELS, GROUPS, type NeedItem } from './fleet'
8import { clip, daysText, durationText, type Line, packRow, seg, type Seg } from './text'
9
10const GAP = ' '
11const ITEM_SEP = ' · '
12const GROUP_GAP = ' '
13
14/** The least a clipped call summary is cut to, however narrow the row. */
15const MIN_SUMMARY = 12
16
17const brand = () => seg('FIRSTMATE', 'brand', 'FM')
18
19const callText = (item: Extract<NeedItem, { kind: 'call' }>) => item.call.summary ?? item.call.key
20
21/** The band's first row, before packing: what needs the captain. */
22export function needsSegs(view: FleetView, width: number): Seg[] {
23 switch (view.health) {
24 case 'not-configured':
25 return [brand(), seg('no home configured: set the firstmate-mod "home" option', 'muted', 'no home configured')]
26 case 'reading':
27 return [brand(), seg('reading Firstmate…', 'muted')]
28 case 'unreadable':
29 return [brand(), seg("can't read Firstmate's list", 'warn')]
30 case 'ok':
31 break
32 }
33 const [first] = view.needs
34 if (first === undefined) return [brand(), seg('Nothing needs you')]
35 const more = view.needs.length - 1
36 if (first.kind === 'merge') {
37 const segs = [brand(), seg(`Merge? ${first.name}`), seg(first.url)]
38 if (more > 0) segs.push(seg(`${ITEM_SEP.trim()} ${more} more need you`))
39 return segs
40 }
41 const calls = view.needs.filter((item): item is Extract<NeedItem, { kind: 'call' }> => item.kind === 'call')
42 const oldest = calls[0]!
43 const head = view.needs.length === 1 ? `1 needs you${ITEM_SEP}` : `${view.needs.length} need you${ITEM_SEP}oldest: `
44 const age = oldest.call.holdAgeDays === null ? '' : `, ${daysText(oldest.call.holdAgeDays)}`
45 const room = Math.max(MIN_SUMMARY, width - 'FIRSTMATE'.length - GAP.length - head.length - age.length)
46 return [brand(), seg(`${head}${clip(callText(oldest), room)}${age}`)]
47}
48
49function itemSegs(row: CrewRow, nowMs: number, isTimed: boolean): Seg[] {
50 const segs: Seg[] = [seg(row.name)]
51 if (row.why !== null) segs.push(seg(`: ${row.why}`, row.group === 'stuck' ? 'warn' : 'muted'))
52 if (isTimed) segs.push(seg(` ${durationText(nowMs - row.sinceMs)}`, 'muted'))
53 return segs
54}
55
56/** The crew as one line: each group's label, then its workers, `shown` of them per group. */
57function crewLine(byGroup: Map<Group, CrewRow[]>, shown: Map<Group, number>, nowMs: number, isTimed: boolean, hidden: number): Line {
58 const line: Line = []
59 for (const group of GROUPS) {
60 const rows = (byGroup.get(group) ?? []).slice(0, shown.get(group) ?? 0)
61 if (rows.length === 0) continue
62 if (line.length > 0) line.push(seg(GROUP_GAP, 'muted'))
63 line.push(seg(GROUP_LABELS[group], group === 'stuck' ? 'warn' : 'head'), seg(GAP))
64 rows.forEach((row, i) => {
65 if (i > 0) line.push(seg(ITEM_SEP, 'muted'))
66 line.push(...itemSegs(row, nowMs, isTimed))
67 })
68 }
69 if (hidden > 0) line.push(seg(`${line.length > 0 ? GROUP_GAP : ''}+${hidden} more`, 'muted'))
70 return line
71}
72
73const widthOf = (line: Line) => line.reduce((n, s) => n + s.text.length, 0)
74
75/**
76 * The band's second row: the crew in one line no wider than `width`. When it
77 * is too long the times go first. Then workers are cut from the end of the
78 * lowest group, down to one per group, and last whole groups from the end, so
79 * the worst news is the last thing cut. What is cut is counted as "+N more".
80 */
81export function crewRow(view: FleetView, width: number, nowMs: number): Line {
82 if (view.crewHealth === 'reading') return [seg('reading Herdr…', 'muted')]
83 if (view.crewHealth === 'unreadable') return [seg("can't read Herdr", 'warn')]
84 if (view.crew.length === 0) return [seg('No workers running', 'muted')]
85 const byGroup = new Map<Group, CrewRow[]>(GROUPS.map(group => [group, view.crew.filter(row => row.group === group)]))
86 const shown = new Map<Group, number>(GROUPS.map(group => [group, byGroup.get(group)!.length]))
87 const total = view.crew.length
88 const fits = (isTimed: boolean) => {
89 const count = [...shown.values()].reduce((n, c) => n + c, 0)
90 const line = crewLine(byGroup, shown, nowMs, isTimed, total - count)
91 return widthOf(line) <= width ? line : null
92 }
93 const full = fits(true) ?? fits(false)
94 if (full !== null) return full
95 for (;;) {
96 const trimmable = [...GROUPS].reverse().find(group => (shown.get(group) ?? 0) > 1)
97 const last = [...GROUPS].reverse().find(group => (shown.get(group) ?? 0) > 0)
98 const group = trimmable ?? last
99 if (group === undefined) return [seg(`+${total} more`, 'muted')]
100 shown.set(group, (shown.get(group) ?? 0) - 1)
101 const line = fits(false)
102 if (line !== null) return line
103 }
104}
105
106/**
107 * The band as lines. The first row leaves room for the button beside it
108 * (`firstWidth`); the second may use the whole `width`.
109 */
110export function bandLines(view: FleetView, firstWidth: number, width: number, nowMs: number): Line[] {
111 const first = packRow(needsSegs(view, firstWidth), firstWidth, GAP)
112 if (view.health === 'not-configured') return first
113 return [...first, crewRow(view, width, nowMs)]
114}
115
116/** The exceptions the status line carries: reads that failed, in the words of the failure. */
117export function statusText(view: FleetView): string | undefined {
118 if (view.health === 'not-configured') return undefined
119 const failures: string[] = []
120 if (view.summaryError !== null) {
121 failures.push(`Firstmate summary ${view.health === 'unreadable' ? 'unreadable' : 'read failed'}: ${view.summaryError}`)
122 }
123 if (view.crewError !== null) failures.push(`Herdr read failed: ${view.crewError}`)
124 return failures.length === 0 ? undefined : failures.join(' | ')
125}
126hooks/config.ts 35 lines1// The mod's options, read once per load from its userConfig values.
2
3import type { PluginOptions } from 'claude-code'
4
5export type Config = {
6 /** The one configured Firstmate home, absolute with no trailing slash; null when unset or relative. */
7 home: string | null
8 quotaCommand: string
9 herdrCommand: string
10 herdrMs: number
11 summaryMs: number
12 headroomMs: number
13 headroomToastPercent: number
14}
15
16const numberOption = (value: unknown, fallback: number, min: number, max: number) =>
17 typeof value === 'number' && Number.isFinite(value) ? Math.min(max, Math.max(min, value)) : fallback
18
19const commandOption = (value: unknown, fallback: string) =>
20 typeof value === 'string' && value.trim() !== '' ? value.trim() : fallback
21
22export function configOf(options: PluginOptions): Config {
23 const rawHome = typeof options.home === 'string' ? options.home.trim() : ''
24 const home = rawHome.startsWith('/') ? rawHome.replace(/\/+$/, '') || '/' : null
25 return {
26 home,
27 quotaCommand: commandOption(options.quota_command, 'quota-axi'),
28 herdrCommand: commandOption(options.herdr_command, 'herdr'),
29 herdrMs: numberOption(options.herdr_seconds, 5, 2, 60) * 1000,
30 summaryMs: numberOption(options.summary_seconds, 30, 5, 600) * 1000,
31 headroomMs: numberOption(options.headroom_seconds, 900, 60, 3600) * 1000,
32 headroomToastPercent: numberOption(options.headroom_toast_percent, 10, 0, 100),
33 }
34}
35hooks/discuss.ts 12 lines1// Discuss: the text a press puts in the prompt box as the owner's own draft.
2// It quotes the recorded call so Firstmate knows which one is meant; the
3// owner edits and sends it, or clears it. Nothing is submitted for them.
4
5import type { CallRecord } from '../types'
6
7export function discussDraft(call: CallRecord): string {
8 const summary = call.summary === null ? '' : ` (${call.summary})`
9 const reason = call.reason === null ? '' : `\nRecorded reason: ${call.reason}`
10 return `Firstmate, I want to discuss the recorded owner call ${call.key}${summary}.${reason}\nMy thoughts: `
11}
12hooks/fleet.ts 218 lines1// The fleet view: one pure fold of the lanes into what the band and the pane
2// draw. Herdr supplies pane activity; the worker's own newest Firstmate status
3// line says what a quiet pane means.
4
5import type { CallRecord, CrewRecords, CrewWorker, HomeRecords, Lane, LandedRecord, MergeAsk, ProviderQuota, QuotaRecords } from '../types'
6import { nameOf, splitTitle } from './normalize'
7
8/** A worker whose status says `done` stays on the band this long. */
9export const FINISHED_MS = 60 * 60_000
10
11/** A quiet pane whose last status line still says working is stuck after this long. */
12export const STOPPED_MS = 10 * 60_000
13
14/** A provider at or below this much headroom gets a line in the pane. */
15export const HEADROOM_LINE_PERCENT = 20
16
17export type Lanes = {
18 home: Lane<HomeRecords>
19 crew: Lane<CrewRecords>
20 quota: Lane<QuotaRecords>
21}
22
23export type Group = 'stuck' | 'finished' | 'working' | 'waiting'
24
25/** Worst news first. */
26export const GROUPS: Group[] = ['stuck', 'finished', 'working', 'waiting']
27
28export const GROUP_LABELS: Record<Group, string> = {
29 stuck: 'Stuck',
30 finished: 'Just finished',
31 working: 'Working',
32 waiting: 'Waiting',
33}
34
35export type CrewRow = {
36 id: string
37 /** The plain name: the backlog title without its project prefix, cut to 30 columns. */
38 name: string
39 /** The project as the backlog title names it (`Lighthouse`), else the project directory. */
40 project: string | null
41 /** `Claude`, `Codex`, ... */
42 agent: string | null
43 group: Group
44 /** Why a stuck or waiting worker is there, in plain words; null when the group says it all. */
45 why: string | null
46 /** Epoch ms the worker has been in this group since. */
47 sinceMs: number
48}
49
50export type NeedItem =
51 | { kind: 'merge'; name: string; url: string }
52 | { kind: 'call'; call: CallRecord }
53
54export type SummaryHealth = 'not-configured' | 'reading' | 'unreadable' | 'ok'
55
56export type LowProvider = {
57 provider: string
58 percent: number
59 /** The limiting window's label, e.g. `week`. */
60 window: string | null
61 resetsAtMs: number | null
62}
63
64export type FleetView = {
65 home: string | null
66 health: SummaryHealth
67 /** Why the summary could not be read at all, or the newest failure beside a retained value. */
68 summaryError: string | null
69 /** Every item that needs the captain: merges first, then calls oldest first. */
70 needs: NeedItem[]
71 /** Calls the captain parked with a date (the published `later` bucket). */
72 parked: CallRecord[]
73 landed: LandedRecord[]
74 crew: CrewRow[]
75 /** `reading` until Herdr was read once; `unreadable` when it never was. */
76 crewHealth: 'reading' | 'unreadable' | 'ok'
77 crewError: string | null
78 /** Providers at or below the headroom line, tightest first. */
79 headroom: LowProvider[]
80 quota: QuotaRecords | null
81}
82
83const isConfigured = (home: string | null): home is string => home !== null && home.startsWith('/')
84
85const agentName = (agent: string | null): string | null =>
86 agent === null || agent === '' ? null : agent.charAt(0).toUpperCase() + agent.slice(1)
87
88/**
89 * The join: Herdr's state for the pane, and for a pane that is not working the
90 * worker's own newest status line. Returns null when the worker no longer
91 * belongs on the band (finished more than an hour ago).
92 */
93export function groupOf(worker: CrewWorker, nowMs: number): { group: Group; why: string | null; sinceMs: number } | null {
94 const status = worker.status
95 const statusMs = status?.at == null ? worker.since : Math.min(nowMs, status.at * 1000)
96 switch (worker.herdr) {
97 case 'working':
98 case 'unknown':
99 return { group: 'working', why: null, sinceMs: worker.since }
100 case 'blocked':
101 return { group: 'stuck', why: 'question on its screen', sinceMs: worker.since }
102 case 'gone':
103 if (status?.state === 'done') return nowMs - statusMs > FINISHED_MS ? null : { group: 'finished', why: null, sinceMs: statusMs }
104 return { group: 'stuck', why: 'window closed', sinceMs: worker.since }
105 case 'quiet':
106 break
107 }
108 switch (status?.state) {
109 case 'paused':
110 return { group: 'waiting', why: null, sinceMs: statusMs }
111 case 'needs-decision':
112 return { group: 'waiting', why: 'for Firstmate', sinceMs: statusMs }
113 case 'blocked':
114 return { group: 'stuck', why: 'blocked', sinceMs: statusMs }
115 case 'failed':
116 return { group: 'stuck', why: 'failed', sinceMs: statusMs }
117 case 'done':
118 return nowMs - statusMs > FINISHED_MS ? null : { group: 'finished', why: null, sinceMs: statusMs }
119 default:
120 // working, or no status line at all: a quiet pane that stays quiet has stopped without a word.
121 if (nowMs - worker.since > STOPPED_MS) return { group: 'stuck', why: 'stopped without a word', sinceMs: worker.since }
122 return { group: 'working', why: null, sinceMs: worker.since }
123 }
124}
125
126function crewOf(records: CrewRecords | null, nowMs: number): CrewRow[] {
127 if (records === null) return []
128 const rows: CrewRow[] = []
129 for (const worker of records.workers) {
130 const joined = groupOf(worker, nowMs)
131 if (joined === null) continue
132 rows.push({
133 id: worker.id,
134 name: nameOf(worker.title, worker.id),
135 project: (worker.title === null ? null : splitTitle(worker.title).project) ?? worker.project,
136 agent: agentName(worker.agent ?? worker.harness),
137 ...joined,
138 })
139 }
140 const order = (row: CrewRow) => GROUPS.indexOf(row.group)
141 return rows.sort((a, b) => order(a) - order(b) || a.sinceMs - b.sinceMs || a.name.localeCompare(b.name))
142}
143
144/** Distinct recorded calls for the captain, split by the published hold bucket; never reclassified here. */
145function callsOf(home: HomeRecords | null): { live: CallRecord[]; later: CallRecord[] } {
146 if (home === null) return { live: [], later: [] }
147 const distinct = new Map<string, CallRecord>()
148 for (const call of home.summary.decisionsOpen) {
149 if (call.verb === 'captain-hold' && !distinct.has(call.key)) distinct.set(call.key, call)
150 }
151 const all = [...distinct.values()]
152 const isLive = (call: CallRecord) => call.holdBucket === 'live' || call.holdBucket === null
153 const oldestFirst = (a: CallRecord, b: CallRecord) => (b.holdAgeDays ?? -1) - (a.holdAgeDays ?? -1)
154 return { live: all.filter(isLive).sort(oldestFirst), later: all.filter(call => !isLive(call)).sort(oldestFirst) }
155}
156
157function needsOf(home: HomeRecords | null, calls: CallRecord[], records: CrewRecords | null): NeedItem[] {
158 if (home === null) return []
159 const titles = new Map((records?.workers ?? []).map(w => [w.id, w.title]))
160 const merges: NeedItem[] = []
161 const seen = new Set<string>()
162 const settled = (ask: MergeAsk) => home.settled.some(s => s.url === ask.url && s.task === ask.task)
163 for (const ask of home.summary.merges) {
164 // A pull request held with a date is already one of the calls; one URL is one merge.
165 // One Firstmate's own records show settled is no longer the captain's, whatever the last forge poll said.
166 if (ask.hold !== null || settled(ask) || seen.has(ask.url)) continue
167 seen.add(ask.url)
168 const task = ask.task ?? ''
169 merges.push({ kind: 'merge', name: nameOf(titles.get(task) ?? null, task === '' ? 'pull request' : task), url: ask.url })
170 }
171 return [...merges, ...calls.map(call => ({ kind: 'call' as const, call }))]
172}
173
174function headroomOf(quota: QuotaRecords | null): LowProvider[] {
175 if (quota === null) return []
176 const out: LowProvider[] = []
177 for (const provider of quota.providers) {
178 const low = lowOf(provider)
179 if (low !== null) out.push(low)
180 }
181 return out.sort((a, b) => a.percent - b.percent)
182}
183
184/** The provider's tightest fresh all-model limit, when it is at or below the headroom line. */
185export function lowOf(provider: ProviderQuota): LowProvider | null {
186 const all = provider.effective.find(e => e.scope === 'all_models')
187 const percent = all?.effectivePercentRemaining ?? null
188 if (provider.isStale === true || provider.status !== 'fresh' || percent === null || percent > HEADROOM_LINE_PERCENT) return null
189 const window = provider.windows.find(w => w.id === all?.limitingWindowIds[0])
190 const resets = window?.resetsAt == null ? Number.NaN : Date.parse(window.resetsAt)
191 return { provider: provider.provider, percent, window: window?.label ?? all?.limitingWindowIds[0] ?? null, resetsAtMs: Number.isFinite(resets) ? resets : null }
192}
193
194function healthOf(home: string | null, lane: Lane<HomeRecords>): SummaryHealth {
195 if (!isConfigured(home)) return 'not-configured'
196 if (lane.value !== null) return 'ok'
197 return lane.error === null ? 'reading' : 'unreadable'
198}
199
200/** The whole fleet view for one moment. */
201export function fleetViewOf(home: string | null, lanes: Lanes, nowMs: number): FleetView {
202 const records = lanes.home.value
203 const calls = callsOf(records)
204 return {
205 home,
206 health: healthOf(home, lanes.home),
207 summaryError: lanes.home.error?.reason ?? null,
208 needs: needsOf(records, calls.live, lanes.crew.value),
209 parked: calls.later,
210 landed: records?.summary.landed ?? [],
211 crew: crewOf(lanes.crew.value, nowMs),
212 crewHealth: lanes.crew.value !== null ? 'ok' : lanes.crew.error === null ? 'reading' : 'unreadable',
213 crewError: lanes.crew.error?.reason ?? null,
214 headroom: headroomOf(lanes.quota.value),
215 quota: lanes.quota.value,
216 }
217}
218hooks/pane.ts 144 lines1// The /fm-fleet pane: one view. What needs the captain,
2// then every worker by state (stuck, working, waiting, just finished), then
3// what is parked with the captain and any provider that is low on headroom.
4// Plain terminal text; every URL is shown whole.
5
6import type { CallRecord } from '../types'
7import { type CrewRow, type FleetView, type Group, type LowProvider, type NeedItem } from './fleet'
8import { NAME_COLUMNS, splitTitle } from './normalize'
9import { clip, daysText, durationText, harnessName, type Line, packRow, pathRows, seg } from './text'
10
11export type PaneItem =
12 | { kind: 'header' }
13 | { kind: 'line'; line: Line; indent?: number }
14 | { kind: 'gap' }
15 | { kind: 'discuss'; call: CallRecord }
16
17/** At this width a worker row also shows its project and harness. */
18const WIDE = 64
19
20const PROJECT_COLUMNS = 14
21
22/** How many parked calls are named before the rest are counted. */
23const PARKED_SHOWN = 5
24
25const line = (text: string, tone: 'plain' | 'muted' | 'warn' | 'head' = 'plain', indent?: number): PaneItem =>
26 indent === undefined ? { kind: 'line', line: [seg(text, tone)] } : { kind: 'line', line: [seg(text, tone)], indent }
27
28const gap = (): PaneItem => ({ kind: 'gap' })
29
30const heading = (text: string): PaneItem => line(text, 'head')
31
32/** `2026-10-05` in the machine's own time zone, for "today". */
33function localDay(ms: number): string {
34 const d = new Date(ms)
35 const two = (n: number) => String(n).padStart(2, '0')
36 return `${d.getFullYear()}-${two(d.getMonth() + 1)}-${two(d.getDate())}`
37}
38
39function needItems(item: NeedItem, width: number): PaneItem[] {
40 if (item.kind === 'merge') {
41 return [
42 line(`Merge? ${clip(item.name, width - 9)}`, 'plain', 2),
43 ...pathRows(item.url, Math.max(8, width - 4)).map(row => line(row, 'plain', 4)),
44 ]
45 }
46 const { call } = item
47 const age = call.holdAgeDays === null ? '' : daysText(call.holdAgeDays)
48 const text = call.summary ?? call.key
49 const room = Math.max(8, width - 2 - (age === '' ? 0 : age.length + 2))
50 const row: Line = [seg(clip(text, room))]
51 if (age !== '') row.push(seg(` ${age}`, 'muted'))
52 return [{ kind: 'line', line: row, indent: 2 }, { kind: 'discuss', call }]
53}
54
55function crewItems(row: CrewRow, width: number, nowMs: number): PaneItem[] {
56 const time = durationText(nowMs - row.sinceMs)
57 const items: PaneItem[] = []
58 if (width >= WIDE) {
59 const project = clip(row.project ?? '', PROJECT_COLUMNS).padEnd(PROJECT_COLUMNS + 1)
60 items.push({
61 kind: 'line',
62 indent: 2,
63 line: [seg(row.name.padEnd(NAME_COLUMNS + 2)), seg(project, 'muted'), seg(time.padStart(4)), seg(row.agent === null ? '' : ` ${row.agent}`, 'muted')],
64 })
65 } else {
66 const room = Math.max(8, width - 2 - time.length - 2)
67 items.push({ kind: 'line', indent: 2, line: [seg(clip(row.name, room)), seg(` ${time}`, 'muted')] })
68 }
69 if (row.why !== null) items.push(line(row.why, row.group === 'stuck' ? 'warn' : 'muted', 4))
70 return items
71}
72
73function section(view: FleetView, group: Group, title: string, width: number, nowMs: number, isAlways: boolean): PaneItem[] {
74 const rows = view.crew.filter(row => row.group === group)
75 if (rows.length === 0 && !isAlways) return []
76 return [
77 gap(),
78 heading(title),
79 ...(rows.length === 0 ? [line('(none)', 'muted', 2)] : rows.flatMap(row => crewItems(row, width, nowMs))),
80 ]
81}
82
83const VERB_WORDS: Record<string, string> = { merged: 'merged', reported: 'report' }
84
85function landedItems(view: FleetView, width: number, nowMs: number): PaneItem[] {
86 const today = localDay(nowMs)
87 const crewIds = new Set(view.crew.map(row => row.id))
88 const items: PaneItem[] = []
89 for (const landed of view.landed) {
90 if (landed.date !== today || crewIds.has(landed.id)) continue
91 const verb = (landed.verb === null ? (landed.kind ?? '') : (VERB_WORDS[landed.verb] ?? landed.verb)).padEnd(8)
92 const title = splitTitle(landed.title ?? landed.id).text
93 items.push({ kind: 'line', indent: 2, line: [seg(verb, 'muted'), seg(clip(title, Math.max(8, width - 10)))] })
94 if (landed.prUrl !== null) items.push(...pathRows(landed.prUrl, Math.max(8, width - 10)).map(row => line(row, 'plain', 10)))
95 }
96 return items
97}
98
99const resetText = (low: LowProvider, nowMs: number) =>
100 low.resetsAtMs === null ? '' : `, resets in ${durationText(low.resetsAtMs - nowMs)}`
101
102/** The pane as items for `width` columns, at `nowMs`. */
103export function paneItems(view: FleetView, width: number, nowMs: number): PaneItem[] {
104 const items: PaneItem[] = [{ kind: 'header' }]
105 if (view.health === 'not-configured') {
106 return [...items, gap(), line('No home configured: set the firstmate-mod "home" option.', 'muted')]
107 }
108
109 items.push(gap(), heading(`NEEDS YOU${view.needs.length === 0 ? '' : ` (${view.needs.length})`}`))
110 if (view.health === 'unreadable') items.push(line("Can't read Firstmate's list", 'warn', 2))
111 else if (view.health === 'reading') items.push(line('reading Firstmate…', 'muted', 2))
112 else if (view.needs.length === 0) items.push(line('Nothing needs you', 'plain', 2))
113 else items.push(...view.needs.flatMap(item => needItems(item, width)))
114
115 if (view.crewHealth === 'ok') {
116 const finished = section(view, 'finished', 'JUST FINISHED', width, nowMs, false)
117 const landed = landedItems(view, width, nowMs)
118 items.push(
119 ...section(view, 'stuck', 'STUCK', width, nowMs, true),
120 ...section(view, 'working', 'WORKING', width, nowMs, false),
121 ...section(view, 'waiting', 'WAITING', width, nowMs, false),
122 )
123 if (finished.length > 0 || landed.length > 0) {
124 items.push(...(finished.length > 0 ? finished : [gap(), heading('JUST FINISHED')]), ...landed)
125 }
126 if (view.crew.length === 0) items.push(gap(), line('No workers running', 'muted'))
127 } else {
128 items.push(gap(), heading('CREW'), line(view.crewHealth === 'reading' ? 'reading Herdr…' : `Can't read Herdr${view.crewError === null ? '' : `: ${view.crewError}`}`, view.crewHealth === 'reading' ? 'muted' : 'warn', 2))
129 }
130
131 if (view.parked.length > 0) {
132 const names = view.parked.slice(0, PARKED_SHOWN).map(call => clip(splitTitle(call.summary ?? call.key).text, NAME_COLUMNS))
133 const hidden = view.parked.length - names.length
134 const segs = names.map((name, i) => seg(i === 0 ? `Parked with you: ${name}` : name))
135 if (hidden > 0) segs.push(seg(`+${hidden} more`, 'muted'))
136 items.push(gap(), ...packRow(segs, width, ' · ').map(row => ({ kind: 'line' as const, line: row })))
137 }
138 for (const low of view.headroom) {
139 const window = low.window === null ? '' : ` (${low.window}${resetText(low, nowMs)})`
140 items.push(gap(), line(`Low on headroom: ${harnessName(low.provider)} ${low.percent}% left${window}`, 'warn'))
141 }
142 return items
143}
144hooks/sources.ts 271 lines1// The read-only adapter. Every read here is a file read, a directory listing,
2// a stat, or one of two read commands run by argv (`herdr agent list` and
3// quota-axi); nothing writes, dispatches, steers, resolves or merges.
4
5import type { FsEntry, ProcessRunInit, ProcessRunResult } from 'claude-code'
6
7import type { CrewRecords, CrewWorker, HomeRecords, MergeAsk, QuotaRecords, SettledMerge, StatusLine, TaskMeta } from '../types'
8import { backlogClosedOf, backlogTitlesOf, herdrAgentsOf, herdrStateOf, metaOf, quotaOf, statusLineOf, summaryOf, summaryRefusal } from './normalize'
9
10/** What the adapter needs from the engine: reads, a listing, a stat, a run, the time. */
11export type Host = {
12 read: (path: string) => Promise<string>
13 list: (path: string) => Promise<FsEntry[]>
14 mtime: (path: string) => Promise<number | null>
15 run: (argv: readonly string[], init?: ProcessRunInit) => Promise<ProcessRunResult>
16 now: () => Promise<number>
17}
18
19/** A task id as Firstmate writes them; anything else is never put on a path. */
20export const TASK_ID = /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/
21
22export const HERDR_TIMEOUT_MS = 5_000
23export const QUOTA_TIMEOUT_MS = 20_000
24
25export const HERDR_ARGS = ['agent', 'list'] as const
26export const QUOTA_ARGS = ['--provider', 'claude,codex', '--json', '--no-credential-refresh'] as const
27
28export const summaryPath = (home: string) => `${home}/state/home-summary.json`
29export const statePath = (home: string) => `${home}/state`
30export const metaPath = (home: string, id: string) => `${home}/state/${id}.meta`
31export const statusPath = (home: string, id: string) => `${home}/state/${id}.status`
32export const backlogPath = (home: string) => `${home}/data/backlog.md`
33
34const messageOf = (error: unknown) => (error instanceof Error ? error.message : String(error))
35
36/** Backlog text shared by both lanes while a known mtime holds; an unknown mtime forces a read. */
37export type BacklogMemo = { entry: { mtimeMs: number; text: string } | null }
38
39export const newBacklogMemo = (): BacklogMemo => ({ entry: null })
40
41async function backlogText(host: Host, home: string, memo: BacklogMemo): Promise<string> {
42 const path = backlogPath(home)
43 const mtimeMs = await host.mtime(path)
44 if (mtimeMs !== null && memo.entry?.mtimeMs === mtimeMs) return memo.entry.text
45 const text = await host.read(path)
46 memo.entry = mtimeMs === null ? null : { mtimeMs, text }
47 return text
48}
49
50/**
51 * The merge asks the summary still lists that Firstmate's own records show
52 * settled: the task is closed in the backlog, or it has no live record (no
53 * metadata and no backlog row). The summary only drops the ask at the next
54 * forge poll, so the captain would keep being asked about a merge already done.
55 * An unreadable backlog proves nothing. A failed state listing prevents only
56 * the absence check; an explicitly closed backlog row still settles the ask.
57 */
58async function settledOf(host: Host, home: string, memo: BacklogMemo, asks: MergeAsk[]): Promise<SettledMerge[]> {
59 const asked = asks.filter(ask => ask.hold === null && ask.task !== null && TASK_ID.test(ask.task))
60 if (asked.length === 0) return []
61 const closed = await backlogText(host, home, memo).then(backlogClosedOf, () => null)
62 if (closed === null) return []
63 const live = await host.list(statePath(home)).then(
64 entries => new Set(entries.filter(entry => entry.name.endsWith('.meta')).map(entry => entry.name.slice(0, -'.meta'.length))),
65 () => null,
66 )
67 const settled: SettledMerge[] = []
68 for (const { task, url } of asked as { task: string; url: string }[]) {
69 if (closed.get(task) === true || (live !== null && !closed.has(task) && !live.has(task))) settled.push({ task, url })
70 }
71 return settled
72}
73
74/** The summary lane: the published summary. */
75export async function readHome(host: Host, home: string, memo: BacklogMemo = newBacklogMemo()): Promise<HomeRecords> {
76 let text: string
77 try {
78 text = await host.read(summaryPath(home))
79 } catch (error) {
80 throw new Error(`cannot read state/home-summary.json (${messageOf(error)})`)
81 }
82 let doc: unknown
83 try {
84 doc = JSON.parse(text)
85 } catch {
86 throw new Error('state/home-summary.json is not JSON')
87 }
88 const refusal = summaryRefusal(doc)
89 if (refusal !== null) throw new Error(refusal)
90 const summary = summaryOf(doc as Record<string, unknown>)
91 return { home, summary, settled: await settledOf(host, home, memo, summary.merges) }
92}
93
94/** What the crew lane remembers between reads, so each file is read only when it changed. */
95export type CrewCache = {
96 /** Each task's metadata with the mtime it was read at; read again when the listing shows a new mtime. */
97 metas: Map<string, { mtimeMs: number; meta: TaskMeta }>
98 statuses: Map<string, { mtimeMs: number; line: StatusLine | null }>
99 /** Backlog title by task id; null once the backlog was read and does not list the task. */
100 titles: Map<string, string | null>
101 /** The backlog text the titles were taken from; a different text names every worker afresh. */
102 titlesFrom: string | null
103 /** The backlog text, shared with the summary lane. */
104 backlog: BacklogMemo
105}
106
107export const newCrewCache = (): CrewCache => ({ metas: new Map(), statuses: new Map(), titles: new Map(), titlesFrom: null, backlog: newBacklogMemo() })
108
109/**
110 * Refresh titles for already-seen workers too, so a late backlog entry or
111 * rename does not leave a task id or old title cached forever. BacklogMemo
112 * avoids rereading while a known mtime holds.
113 */
114async function readTitles(host: Host, home: string, cache: CrewCache, ids: string[]) {
115 if (ids.length === 0) return
116 let text: string
117 try {
118 text = await backlogText(host, home, cache.backlog)
119 } catch {
120 // The backlog is read again at the next tick; until then names stay as they were, or the task id.
121 return
122 }
123 if (text !== cache.titlesFrom) {
124 cache.titles.clear()
125 cache.titlesFrom = text
126 }
127 const missing = ids.filter(id => !cache.titles.has(id))
128 if (missing.length === 0) return
129 const titles = backlogTitlesOf(text)
130 for (const id of missing) cache.titles.set(id, titles.get(id) ?? null)
131}
132
133/** A file's mtime from the state listing; a non-file entry (a link) is stat'ed; null when absent. */
134async function mtimeIn(host: Host, listed: Map<string, FsEntry>, path: string, name: string): Promise<number | null> {
135 const entry = listed.get(name)
136 if (entry === undefined) return null
137 return entry.kind === 'file' ? entry.mtimeMs : host.mtime(path)
138}
139
140async function statusOf(host: Host, home: string, cache: CrewCache, listed: Map<string, FsEntry>, id: string): Promise<StatusLine | null> {
141 const path = statusPath(home, id)
142 const mtimeMs = await mtimeIn(host, listed, path, `${id}.status`)
143 if (mtimeMs === null) {
144 cache.statuses.delete(id)
145 return null
146 }
147 const kept = cache.statuses.get(id)
148 if (kept !== undefined && kept.mtimeMs === mtimeMs) return kept.line
149 try {
150 const line = statusLineOf(await host.read(path))
151 cache.statuses.set(id, { mtimeMs, line })
152 return line
153 } catch {
154 return kept?.line ?? null
155 }
156}
157
158/** Metadata cached by listing mtime; a failed read keeps the cache, or returns null on first read. */
159async function metaFor(host: Host, home: string, cache: CrewCache, entry: FsEntry, id: string): Promise<TaskMeta | null> {
160 const kept = cache.metas.get(id)
161 if (kept !== undefined && kept.mtimeMs === entry.mtimeMs) return kept.meta
162 try {
163 const meta = metaOf(await host.read(metaPath(home, id)))
164 cache.metas.set(id, { mtimeMs: entry.mtimeMs, meta })
165 return meta
166 } catch {
167 // Read again at the next tick; a worker whose metadata was read before keeps it meanwhile.
168 return kept?.meta ?? null
169 }
170}
171
172/**
173 * The time a worker has been in its Herdr state: unchanged while the state
174 * holds, the time of this read when it changed, and, the first time the worker
175 * is seen, the newest status line's own time (Herdr keeps none), or the time
176 * it was spawned when it has not written a status line yet.
177 */
178function sinceOf(prev: CrewWorker | undefined, herdr: CrewWorker['herdr'], status: StatusLine | null, spawnedAt: number | null, nowMs: number): number {
179 if (prev !== undefined) return prev.herdr === herdr ? prev.since : nowMs
180 const seed = status?.at ?? spawnedAt
181 return seed == null ? nowMs : Math.min(nowMs, seed * 1000)
182}
183
184/**
185 * The crew lane: one `herdr agent list` read, joined by tab id with each
186 * worker's task metadata and newest status line. Workers are the tasks whose
187 * state/<id>.meta names a Herdr tab; a worker whose tab holds no listed agent
188 * is `gone`. Nothing here reads a screen.
189 */
190export async function readCrew(
191 host: Host,
192 home: string,
193 herdrCommand: string,
194 cache: CrewCache,
195 prev: CrewRecords | null,
196 nowMs: number,
197): Promise<CrewRecords> {
198 const ran = await host.run([herdrCommand, ...HERDR_ARGS], { timeoutMs: HERDR_TIMEOUT_MS })
199 if (ran.exitCode !== 0) {
200 throw new Error(`${herdrCommand} agent list exited ${ran.exitCode}${ran.stderr.trim() === '' ? '' : `: ${ran.stderr.trim().split('\n')[0]}`}`)
201 }
202 let agents: ReturnType<typeof herdrAgentsOf>
203 try {
204 agents = herdrAgentsOf(ran.stdout)
205 } catch (error) {
206 throw new Error(`${herdrCommand} agent list output unreadable (${messageOf(error)})`)
207 }
208 const byTab = new Map<string, (typeof agents)[number]>()
209 const rank = (status: string) => (status === 'blocked' ? 0 : status === 'working' ? 1 : 2)
210 for (const agent of agents) {
211 const kept = byTab.get(agent.tabId)
212 if (kept === undefined || rank(agent.status) < rank(kept.status)) byTab.set(agent.tabId, agent)
213 }
214
215 // Regular-file mtimes come from one listing, avoiding a stat per worker.
216 const entries = await host.list(statePath(home))
217 const listed = new Map(entries.map(entry => [entry.name, entry]))
218 const metaEntries = entries
219 .filter(entry => entry.kind === 'file' && entry.name.endsWith('.meta'))
220 .map(entry => ({ entry, id: entry.name.slice(0, -'.meta'.length) }))
221 .filter(({ id }) => TASK_ID.test(id))
222 .sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0))
223 const ids = new Set(metaEntries.map(({ id }) => id))
224 const metas = await Promise.all(metaEntries.map(({ entry, id }) => metaFor(host, home, cache, entry, id)))
225 const tracked: { id: string; meta: TaskMeta }[] = []
226 metaEntries.forEach(({ id }, i) => {
227 const meta = metas[i]
228 if (meta != null && meta.herdrTabId !== null) tracked.push({ id, meta })
229 })
230 const live = new Set(tracked.map(t => t.id))
231 for (const id of [...cache.metas.keys()]) if (!ids.has(id)) cache.metas.delete(id)
232 for (const id of [...cache.statuses.keys()]) if (!live.has(id)) cache.statuses.delete(id)
233 for (const id of [...cache.titles.keys()]) if (!live.has(id)) cache.titles.delete(id)
234 await readTitles(host, home, cache, tracked.map(t => t.id))
235
236 const before = new Map((prev?.workers ?? []).map(w => [w.id, w]))
237 const statuses = await Promise.all(tracked.map(({ id }) => statusOf(host, home, cache, listed, id)))
238 const workers: CrewWorker[] = tracked.map(({ id, meta }, i) => {
239 const agent = byTab.get(meta.herdrTabId!)
240 const herdr = agent === undefined ? 'gone' : herdrStateOf(agent.status)
241 const status = statuses[i] ?? null
242 return {
243 id,
244 project: meta.project,
245 harness: meta.harness,
246 agent: agent?.agent ?? null,
247 title: cache.titles.get(id) ?? null,
248 herdr,
249 status,
250 since: sinceOf(before.get(id), herdr, status, meta.spawnedAt, nowMs),
251 }
252 })
253 return { workers }
254}
255
256/** The quota lane: one quota-axi JSON read, credentials never refreshed. */
257export async function readQuota(host: Host, command: string, cwd: string | undefined): Promise<QuotaRecords> {
258 const ran = await host.run([command, ...QUOTA_ARGS], {
259 ...(cwd === undefined ? {} : { cwd }),
260 timeoutMs: QUOTA_TIMEOUT_MS,
261 })
262 if (ran.exitCode !== 0 && ran.stdout.trim() === '') {
263 throw new Error(`${command} exited ${ran.exitCode}${ran.stderr.trim() === '' ? '' : `: ${ran.stderr.trim().split('\n')[0]}`}`)
264 }
265 try {
266 return quotaOf(ran.stdout)
267 } catch (error) {
268 throw new Error(`${command} output unreadable (${messageOf(error)})`)
269 }
270}
271hooks/text.ts 102 lines1// Terminal text: segments with a tone, packed into rows that fit a width.
2// The tones map to the terminal's own ANSI palette at draw time, and every
3// qualifier is a word, so nothing depends on color alone.
4
5export type Tone = 'brand' | 'head' | 'plain' | 'muted' | 'warn'
6
7export type Seg = {
8 text: string
9 tone: Tone
10 /** A shorter spelling for narrow widths; the qualifier words stay. */
11 short?: string
12}
13
14export type Line = Seg[]
15
16export const seg = (text: string, tone: Tone = 'plain', short?: string): Seg =>
17 short === undefined ? { text, tone } : { text, tone, short }
18
19const widthOf = (segs: Seg[], sep: string, isShort: boolean) =>
20 segs.reduce((n, s, i) => n + (i === 0 ? 0 : sep.length) + (isShort ? (s.short ?? s.text) : s.text).length, 0)
21
22/**
23 * Packs one logical row's segments into lines no wider than `width`: the full
24 * spelling when the row fits, else the short spelling, then wrapping between
25 * segments. A segment longer than the width keeps its own line and wraps there.
26 */
27export function packRow(segs: Seg[], width: number, sep: string, sepTone: Tone = 'muted'): Line[] {
28 if (segs.length === 0) return []
29 const isShort = widthOf(segs, sep, false) > width
30 const lines: Line[] = []
31 let line: Line = []
32 let used = 0
33 for (const s of segs) {
34 const text = isShort ? (s.short ?? s.text) : s.text
35 const piece: Seg = { text, tone: s.tone }
36 const need = line.length === 0 ? text.length : sep.length + text.length
37 if (line.length > 0 && used + need > width) {
38 lines.push(line)
39 line = []
40 used = 0
41 }
42 if (line.length > 0) {
43 line.push({ text: sep, tone: sepTone })
44 used += sep.length
45 }
46 line.push(piece)
47 used += text.length
48 }
49 if (line.length > 0) lines.push(line)
50 return lines
51}
52
53/**
54 * How long something has lasted, in the plain units a glance needs: under a
55 * minute reads `<1m`, then `12m`, `3h`, `2d`. Minutes are the finest grain
56 * because the reads that stamp the time run every few seconds.
57 */
58export function durationText(ms: number): string {
59 const minutes = Math.floor(Math.max(0, ms) / 60_000)
60 if (minutes < 1) return '<1m'
61 if (minutes < 60) return `${minutes}m`
62 const hours = Math.floor(minutes / 60)
63 if (hours < 48) return `${hours}h`
64 return `${Math.floor(hours / 24)}d`
65}
66
67/** A number of days the way a person says it: `today`, `1 day`, `10 days`. */
68export function daysText(days: number): string {
69 if (days < 1) return 'today'
70 return `${days} ${days === 1 ? 'day' : 'days'}`
71}
72
73/** A harness id as people say it: codex as Codex. */
74export function harnessName(harness: string | null): string {
75 if (harness === null || harness === '') return 'unknown harness'
76 return harness.charAt(0).toUpperCase() + harness.slice(1)
77}
78
79/** Cuts text to `width` with an ellipsis, for a one-row caption. */
80export function clip(text: string, width: number): string {
81 if (width <= 1) return text.slice(0, Math.max(0, width))
82 return text.length <= width ? text : `${text.slice(0, width - 1)}…`
83}
84
85/**
86 * A full URL or path as rows no wider than `width`, broken after a `/` where
87 * one fits so each piece stays readable; never shortened.
88 */
89export function pathRows(text: string, width: number): string[] {
90 if (text.length <= width || width < 8) return [text]
91 const rows: string[] = []
92 let rest = text
93 while (rest.length > width) {
94 const cut = rest.lastIndexOf('/', width - 1)
95 const at = cut > width / 3 ? cut + 1 : width
96 rows.push(rest.slice(0, at))
97 rest = rest.slice(at)
98 }
99 if (rest !== '') rows.push(rest)
100 return rows
101}
102hooks/toasts.ts 99 lines1// Toasts for changes in the joined view: something that newly needs the captain, a
2// newly merged landing, a worker that newly got stuck, a provider entering the
3// headroom threshold. Each kind's first sighting is a silent baseline, so history never
4// replays on startup, after /clear or after a reload; a key toasts once.
5
6import type { ToastLedger } from '../types'
7import type { FleetView } from './fleet'
8import { clip, harnessName } from './text'
9
10export const SEEN_LIMIT = 600
11export const TOASTS_PER_READ = 3
12
13export type Candidate = { key: string; text: string }
14
15export type ToastKind = 'needs' | 'landed' | 'stuck' | 'headroom'
16
17export const EMPTY_LEDGER: ToastLedger = { baselined: [], seen: [] }
18
19/** The candidates of one kind, or null while that kind's records are not trustworthy enough to baseline. */
20export function candidatesOf(kind: ToastKind, view: FleetView, thresholdPercent: number): Candidate[] | null {
21 switch (kind) {
22 case 'needs':
23 if (view.health !== 'ok') return null
24 return view.needs.map(item =>
25 item.kind === 'merge'
26 ? { key: `merge:${item.url}`, text: `Firstmate: merge? ${clip(item.name, 50)} ${item.url}` }
27 : { key: `call:${item.call.key}`, text: `Firstmate: needs you: ${clip(item.call.summary ?? item.call.key, 80)}` },
28 )
29 case 'landed':
30 if (view.health !== 'ok') return null
31 return view.landed
32 .filter(item => item.verb === 'merged' && item.prUrl !== null)
33 .map(item => ({
34 key: `landed:${item.id}:${item.prUrl}`,
35 text: `Firstmate: merged: ${clip(item.title ?? item.id, 70)} ${item.prUrl}`,
36 }))
37 case 'stuck':
38 if (view.crewHealth !== 'ok') return null
39 return view.crew
40 .filter(row => row.group === 'stuck')
41 .map(row => ({ key: `stuck:${row.id}:${row.why}`, text: `Firstmate: ${row.name} is stuck${row.why === null ? '' : `: ${row.why}`}` }))
42 case 'headroom': {
43 const quota = view.quota
44 if (quota === null || thresholdPercent <= 0) return null
45 return quota.providers.flatMap(p => {
46 const all = p.effective.find(e => e.scope === 'all_models')
47 const pct = all?.effectivePercentRemaining ?? null
48 if (p.isStale === true || p.status !== 'fresh' || pct === null || pct > thresholdPercent) return []
49 const windowId = all?.limitingWindowIds[0] ?? 'window'
50 const window = p.windows.find(w => w.id === windowId)
51 return [
52 {
53 // The hour of the reset names the window; its sub-second spelling moves between reads.
54 key: `headroom:${p.provider}:${windowId}:${window?.resetsAt?.slice(0, 13) ?? ''}`,
55 text: `Firstmate: ${harnessName(p.provider)} headroom ${pct}% (${window?.label ?? windowId} window)`,
56 },
57 ]
58 })
59 }
60 }
61}
62
63/** Folds one kind's candidates into the ledger: what to toast now, and the ledger after. */
64export function transition(
65 ledger: ToastLedger,
66 kind: ToastKind,
67 candidates: Candidate[] | null,
68): { ledger: ToastLedger; toasts: string[] } {
69 if (candidates === null) return { ledger, toasts: [] }
70 // A worker that got unstuck may get stuck again later: that is a new transition.
71 const current = new Set(candidates.map(c => c.key))
72 const kept = kind === 'stuck' ? ledger.seen.filter(key => !key.startsWith('stuck:') || current.has(key)) : ledger.seen
73 const seen = new Set(kept)
74 const fresh = candidates.filter(c => !seen.has(c.key))
75 const isBaseline = !ledger.baselined.includes(kind)
76 // Nothing new and nothing dropped: the same ledger, so the caller can skip writing it.
77 if (!isBaseline && fresh.length === 0 && kept.length === ledger.seen.length) return { ledger, toasts: [] }
78 const next: ToastLedger = {
79 baselined: isBaseline ? [...ledger.baselined, kind] : ledger.baselined,
80 seen: [...kept, ...fresh.map(c => c.key)].slice(-SEEN_LIMIT),
81 }
82 if (isBaseline || fresh.length === 0) return { ledger: next, toasts: [] }
83 const shown = fresh.slice(0, TOASTS_PER_READ).map(c => c.text)
84 if (fresh.length > TOASTS_PER_READ) shown.push(`Firstmate: ${fresh.length - TOASTS_PER_READ} more new; see /fm-fleet`)
85 return { ledger: next, toasts: shown }
86}
87
88/** All kinds at once, in a fixed order. */
89export function transitions(ledger: ToastLedger, view: FleetView, thresholdPercent: number, kinds: ToastKind[]) {
90 let current = ledger
91 const toasts: string[] = []
92 for (const kind of kinds) {
93 const step = transition(current, kind, candidatesOf(kind, view, thresholdPercent))
94 current = step.ledger
95 toasts.push(...step.toasts)
96 }
97 return { ledger: current, toasts }
98}
99hooks/normalize.ts 264 lines1// Pure readers from the records the mod uses to its typed shapes.
2//
3// These normalize transport only: a field of the wrong type becomes null, an
4// unknown word stays the word, a bounded list stays bounded. They never grade,
5// infer or rewrite what Herdr or Firstmate recorded.
6
7import type {
8 CallRecord,
9 HerdrAgent,
10 HerdrState,
11 LandedRecord,
12 MergeAsk,
13 ProviderQuota,
14 QuotaRecords,
15 StatusLine,
16 StatusState,
17 SummaryRecord,
18 TaskMeta,
19} from '../types'
20import { clip } from './text'
21
22export const SUMMARY_SCHEMA = 'fm-secondmate-home-summary.v1'
23
24type Json = Record<string, unknown>
25
26const isObject = (value: unknown): value is Json =>
27 typeof value === 'object' && value !== null && !Array.isArray(value)
28
29const str = (value: unknown): string | null =>
30 typeof value === 'string' ? value : null
31
32const num = (value: unknown): number | null =>
33 typeof value === 'number' && Number.isFinite(value) ? value : null
34
35const bool = (value: unknown): boolean | null =>
36 typeof value === 'boolean' ? value : null
37
38const list = (value: unknown): unknown[] => (Array.isArray(value) ? value : [])
39
40const strings = (value: unknown): string[] =>
41 list(value).filter((item): item is string => typeof item === 'string')
42
43const present = <T>(items: (T | null)[]): T[] =>
44 items.filter((item): item is T => item !== null)
45
46/** Why a summary document cannot be used, or null when it can. */
47export function summaryRefusal(doc: unknown): string | null {
48 if (!isObject(doc)) return 'home summary is not a JSON object'
49 if (doc.schema !== SUMMARY_SCHEMA) {
50 return `home summary schema ${JSON.stringify(doc.schema ?? null)} is not ${SUMMARY_SCHEMA}`
51 }
52 return null
53}
54
55function callOf(item: unknown): CallRecord | null {
56 if (!isObject(item) || typeof item.id !== 'string') return null
57 return {
58 id: item.id,
59 key: str(item.key) ?? item.id,
60 verb: str(item.verb),
61 summary: str(item.summary),
62 reason: str(item.reason),
63 holdBucket: str(item.hold_bucket),
64 holdAgeDays: num(item.hold_age_days),
65 }
66}
67
68function landedOf(item: unknown): LandedRecord | null {
69 if (!isObject(item) || typeof item.id !== 'string') return null
70 const completion = isObject(item.completion) ? item.completion : {}
71 return {
72 id: item.id,
73 title: str(item.title),
74 kind: str(item.kind),
75 prUrl: str(item.pr_url),
76 verb: str(completion.verb),
77 date: str(completion.date),
78 }
79}
80
81function mergeOf(item: unknown): MergeAsk | null {
82 if (!isObject(item)) return null
83 const url = str(item.url)
84 if (url === null) return null
85 return { task: str(item.task), url, hold: str(item.hold) }
86}
87
88/** The published summary document, normalized; call summaryRefusal first. */
89export function summaryOf(doc: Json): SummaryRecord {
90 const contributions = isObject(doc.contributions) ? doc.contributions : {}
91 return {
92 schema: SUMMARY_SCHEMA,
93 generated: str(doc.generated),
94 generatedEpoch: num(doc.generated_epoch),
95 decisionsOpen: present(list(doc.decisions_open).map(callOf)),
96 landed: present(list(doc.landed).map(landedOf)),
97 merges: present(list(contributions.captain).map(mergeOf)),
98 }
99}
100
101/** `spawn_gen=s1791139797.6818.17640` as epoch seconds. */
102const spawnedAtOf = (gen: string | null): number | null => {
103 const match = gen === null ? null : /^s(\d+)\./.exec(gen)
104 return match === null ? null : Number(match[1])
105}
106
107/** state/<id>.meta: `key=value` lines; the fields the mod reads, project by its basename. */
108export function metaOf(text: string): TaskMeta {
109 const fields = new Map<string, string>()
110 for (const line of text.split('\n')) {
111 const at = line.indexOf('=')
112 if (at > 0) fields.set(line.slice(0, at).trim(), line.slice(at + 1).trim())
113 }
114 const field = (name: string) => {
115 const value = fields.get(name)
116 return value === undefined || value === '' ? null : value
117 }
118 const project = field('project')
119 return {
120 project: project === null ? null : (project.split('/').filter(Boolean).pop() ?? null),
121 harness: field('harness'),
122 herdrTabId: field('herdr_tab_id'),
123 spawnedAt: spawnedAtOf(field('spawn_gen')),
124 }
125}
126
127/** `herdr agent list` JSON, normalized; throws when it is not the expected document. */
128export function herdrAgentsOf(text: string): HerdrAgent[] {
129 const doc: unknown = JSON.parse(text)
130 const result = isObject(doc) && isObject(doc.result) ? doc.result : null
131 if (result === null || !Array.isArray(result.agents)) throw new Error('no agents list')
132 return present(
133 result.agents.map(item =>
134 isObject(item) && typeof item.tab_id === 'string'
135 ? { tabId: item.tab_id, paneId: str(item.pane_id), agent: str(item.agent), status: str(item.agent_status) ?? 'unknown' }
136 : null,
137 ),
138 )
139}
140
141/** Herdr's status word as a state: idle and done are both a pane that is not working. */
142export function herdrStateOf(status: string): HerdrState {
143 switch (status) {
144 case 'working':
145 return 'working'
146 case 'blocked':
147 return 'blocked'
148 case 'idle':
149 case 'done':
150 return 'quiet'
151 default:
152 return 'unknown'
153 }
154}
155
156const STATUS_LINE = /^(working|needs-decision|blocked|paused|done|failed|resolved)\b(?:[^\n]*?\[at=(\d+)\])?/
157
158/**
159 * The newest typed line of a status file. A `resolved` line means the worker
160 * went on, so it reads as working; lines of any other shape are ignored.
161 */
162export function statusLineOf(text: string): StatusLine | null {
163 const lines = text.split('\n')
164 for (let i = lines.length - 1; i >= 0; i -= 1) {
165 const match = STATUS_LINE.exec(lines[i]!.trim())
166 if (match === null) continue
167 const word = match[1]!
168 return { state: (word === 'resolved' ? 'working' : word) as StatusState, at: match[2] === undefined ? null : Number(match[2]) }
169 }
170 return null
171}
172
173/** The backlog's task titles by id: every `- [ ] <id> - <title>` or `- [x] <id> - <title>` line. */
174export function backlogTitlesOf(text: string): Map<string, string> {
175 const titles = new Map<string, string>()
176 for (const line of text.split('\n')) {
177 const match = /^- \[[ xX]\] (\S+) - (.*)$/.exec(line)
178 if (match !== null && !titles.has(match[1]!)) titles.set(match[1]!, match[2]!.slice(0, 300))
179 }
180 return titles
181}
182
183/** Whether the backlog row of each task is closed (`- [x]`) or open; the first row for an id wins. */
184export function backlogClosedOf(text: string): Map<string, boolean> {
185 const closed = new Map<string, boolean>()
186 for (const line of text.split('\n')) {
187 const match = /^- \[([ xX])\] (\S+) - /.exec(line)
188 if (match !== null && !closed.has(match[2]!)) closed.set(match[2]!, match[1] !== ' ')
189 }
190 return closed
191}
192
193/** A title split into its project prefix (`Lighthouse: …`) and the rest. */
194export function splitTitle(title: string): { project: string | null; text: string } {
195 const match = /^([^:;,(]{1,30}):\s+(\S[\s\S]*)$/.exec(title)
196 return match === null ? { project: null, text: title } : { project: match[1]!.trim(), text: match[2]! }
197}
198
199export const NAME_COLUMNS = 30
200
201/**
202 * A worker's plain name: the backlog title without its project prefix, cut at
203 * the first `;`, `,` or `(`, then to 30 columns. The task id when there is no title.
204 */
205export function nameOf(title: string | null, id: string): string {
206 if (title === null) return clip(id, NAME_COLUMNS)
207 const cut = splitTitle(title).text.split(/[;,(]/)[0]!.trim()
208 return clip(cut === '' ? id : cut, NAME_COLUMNS)
209}
210
211function providerOf(item: unknown): ProviderQuota | null {
212 if (!isObject(item) || typeof item.provider !== 'string') return null
213 const state = isObject(item.state) ? item.state : {}
214 const semantics = isObject(item.quotaSemantics) ? item.quotaSemantics : {}
215 return {
216 provider: item.provider,
217 plan: str(item.plan),
218 status: str(state.status),
219 isStale: bool(state.stale),
220 windows: list(item.windows)
221 .filter(isObject)
222 .flatMap(w =>
223 typeof w.id === 'string'
224 ? [
225 {
226 id: w.id,
227 label: str(w.label),
228 kind: str(w.kind),
229 resetsAt: str(w.resetsAt),
230 percentRemaining: num(w.percentRemaining),
231 },
232 ]
233 : [],
234 ),
235 effective: list(semantics.effectiveAvailability)
236 .filter(isObject)
237 .flatMap(row =>
238 typeof row.scope === 'string'
239 ? [
240 {
241 scope: row.scope,
242 status: str(row.status),
243 effectivePercentRemaining: num(row.effectivePercentRemaining),
244 limitingWindowIds: strings(row.limitingWindowIds),
245 },
246 ]
247 : [],
248 ),
249 error: str(item.error) ?? (isObject(item.error) ? str(item.error.message) : null),
250 }
251}
252
253/** quota-axi --json output, normalized; throws when it is not the expected document. */
254export function quotaOf(text: string): QuotaRecords {
255 const doc: unknown = JSON.parse(text)
256 if (!isObject(doc) || !Array.isArray(doc.providers)) {
257 throw new Error('quota output has no providers list')
258 }
259 return {
260 generatedAt: str(doc.generatedAt),
261 providers: present(doc.providers.map(providerOf)),
262 }
263}
264types/index.d.ts 172 lines1// The firstmate-mod state contract: what the mod keeps in $.state, and the
2// read-only records it normalizes.
3//
4// These are normalized source records; the joined view is derived separately
5// in hooks/fleet.ts.
6
7/** One lane's latest read: the value it last read, when, and the last failure. */
8export type Lane<T> = {
9 /** The last value read successfully, or null before the first one. */
10 value: T | null
11 /** Epoch milliseconds of that successful read, or null. */
12 readAt: number | null
13 /** The newest failure, kept beside an older good value; null once a read succeeds. */
14 error: { reason: string; at: number } | null
15}
16
17/** state/<id>.meta, the fields the mod reads. */
18export type TaskMeta = {
19 /** The project directory's basename. */
20 project: string | null
21 harness: string | null
22 /** The Herdr tab this worker runs in; the join key to `herdr agent list`. */
23 herdrTabId: string | null
24 /** Epoch seconds the task was spawned, from `spawn_gen`; null when absent. */
25 spawnedAt: number | null
26}
27
28/** home-summary.json decisions_open[]: one recorded call for the captain. */
29export type CallRecord = {
30 id: string
31 key: string
32 verb: string | null
33 summary: string | null
34 reason: string | null
35 holdBucket: string | null
36 holdAgeDays: number | null
37}
38
39/** home-summary.json landed[]: one bounded recent completion. */
40export type LandedRecord = {
41 id: string
42 title: string | null
43 kind: string | null
44 prUrl: string | null
45 verb: string | null
46 date: string | null
47}
48
49/** home-summary.json contributions.captain[]: a pull request that is the captain's to merge. */
50export type MergeAsk = {
51 task: string | null
52 url: string
53 /** The Firstmate hold that already records this as a call, or null for a plain merge. */
54 hold: string | null
55}
56
57/** state/home-summary.json (fm-secondmate-home-summary.v1), the parts the band and pane use. */
58export type SummaryRecord = {
59 schema: string
60 generated: string | null
61 generatedEpoch: number | null
62 decisionsOpen: CallRecord[]
63 landed: LandedRecord[]
64 merges: MergeAsk[]
65}
66
67/** A merge ask the summary still lists although the home's own records show it settled. */
68export type SettledMerge = {
69 task: string
70 url: string
71}
72
73/** The summary lane: the home it read and what it found there. */
74export type HomeRecords = {
75 home: string
76 summary: SummaryRecord
77 /** Merge asks in the summary that the task's own records show settled; the summary only catches up at the next forge poll. */
78 settled: SettledMerge[]
79}
80
81/** One record of `herdr agent list`. */
82export type HerdrAgent = {
83 tabId: string
84 paneId: string | null
85 /** claude, codex, ... */
86 agent: string | null
87 /** Herdr's own word: idle, working, blocked, done or unknown. */
88 status: string
89}
90
91/** What Herdr says about one worker's pane. `gone` is a pane missing from the list. */
92export type HerdrState = 'working' | 'blocked' | 'quiet' | 'unknown' | 'gone'
93
94export type StatusState = 'working' | 'needs-decision' | 'blocked' | 'paused' | 'done' | 'failed'
95
96/** The newest typed line of state/<id>.status. */
97export type StatusLine = {
98 state: StatusState
99 /** Epoch seconds from `[at=…]`, or null when the line has none. */
100 at: number | null
101}
102
103/** One worker: its Herdr state joined with its newest status line and its name. */
104export type CrewWorker = {
105 id: string
106 project: string | null
107 harness: string | null
108 /** The Herdr agent kind of its pane, when the pane is listed. */
109 agent: string | null
110 /** The backlog title as written, or null when the backlog does not list the task. */
111 title: string | null
112 herdr: HerdrState
113 status: StatusLine | null
114 /** Epoch ms of the observed Herdr transition, initially seeded from status or spawn time. */
115 since: number
116}
117
118/** The crew lane: every worker whose task metadata names a Herdr tab. */
119export type CrewRecords = {
120 workers: CrewWorker[]
121}
122
123export type QuotaWindow = {
124 id: string
125 label: string | null
126 kind: string | null
127 resetsAt: string | null
128 percentRemaining: number | null
129}
130
131export type QuotaEffective = {
132 scope: string
133 status: string | null
134 effectivePercentRemaining: number | null
135 limitingWindowIds: string[]
136}
137
138export type ProviderQuota = {
139 provider: string
140 plan: string | null
141 status: string | null
142 isStale: boolean | null
143 windows: QuotaWindow[]
144 effective: QuotaEffective[]
145 error: string | null
146}
147
148/** quota-axi --json, normalized. */
149export type QuotaRecords = {
150 generatedAt: string | null
151 providers: ProviderQuota[]
152}
153
154/** What has already been announced; deduplication is owned by hooks/toasts.ts. */
155export type ToastLedger = {
156 /** Kinds whose first baseline has been taken: nothing in a baseline toasts. */
157 baselined: string[]
158 /** Dedupe keys already seen, newest last, bounded. */
159 seen: string[]
160}
161
162declare module 'claude-code' {
163 interface PluginState {
164 'firstmate-mod': {
165 home: Lane<HomeRecords>
166 crew: Lane<CrewRecords>
167 quota: Lane<QuotaRecords>
168 ledger: ToastLedger
169 }
170 }
171}
172