SLOPSHOPPER

firstmate-mod

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…

newpanebandcommandtoaststatus
v0.3.0MITupdated 2026-10-07dctmfoo/firstmate-mod
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · firstmate-mod
│ ┃ Firstmate ✕ › fix the failing auth test and add an audit log call │ ┃ FIRSTMATE r: refresh Esc closes │ ┃ ⏺ Read(src/auth.ts) │ ┃ No home configured: set the firstmate-mod ⎿ Read 6 lines │ ┃ "home" option. ⏺ 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 │ │ › /fm-fleet │ ⎿ firstmate-mod: Firstmate pane opened, read-only. │ │ ──────────────────────────────────────────────────────────────────────────────────────────────────── FIRSTMATE no home configured: set the firstmate-mod "home" option /fm-fleet ⟨Claude Code's own drawing⟩ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
──────────────────────────────────────────────────────────────────────────────────────────────────── FIRSTMATE no home configured: set the firstmate-mod "home" option /fm-fleet ⟨Claude Code's own drawing⟩
Pane · Firstmate
FIRSTMATE r: refresh Esc closes No home configured: set the firstmate-mod "home" option.
README

firstmate-mod

CI License: MIT

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.

Discuss: open the pane, click Discuss, the draft lands in the prompt, add a few words, send

<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.

Requirements

  • Claude Code with plugin mods (early access; tested with 2.1.293). The mods API changes between releases, so a newer Claude Code may need a newer version of this plugin.
  • A Firstmate home: the directory with Firstmate's state/ and data/ folders.
  • Herdr, reachable from the Claude session as herdr agent list, for live worker state. Run Claude Code inside Herdr, or point the herdr_command option at it.
  • quota-axi (optional), for the low-headroom line and toast. Without it, everything else works and the headroom line stays hidden.

Install

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.

Options

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.

Try it without Firstmate

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"
      }
    }
  }
}

The band

The band above the prompt

Row 1 is yours. It says Nothing needs you, or what does:

  • A pull request that is yours to merge shows its full link: Merge? <name> <url> · 4 more need you.
  • Otherwise it counts the recorded calls waiting on you and names the oldest with its age: 3 need you · oldest: <call>, 10 days.
  • Calls you parked with a date are not counted. If Firstmate's summary has never been readable, the row says 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:

GroupMeaning
StuckA 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 finishedA quiet or closed worker that said done. It leaves the band after an hour.
WorkingHerdr says the pane is working, reports an unknown state, or a quiet pane is still inside the 10-minute grace period.
WaitingThe 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.

The pane

/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.

The /fm-fleet pane docked beside the conversation

  • Needs you lists merges first, then calls oldest first. Each call has a Discuss with Firstmate button.
  • Stuck, Working, Waiting list each worker with its project, time in state and harness. A stuck worker's reason sits under its name.
  • Just finished lists workers that said done in the last hour, then what landed today (merged pull requests with their full link, scout reports).
  • Parked with you names calls parked with a date.
  • A Low on headroom line appears only when a provider's tightest fresh limit is at or below 20%.

Discuss

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:

The draft in the prompt box after Discuss

Other commands and signals

  • /fm-refresh re-reads everything now. It only reads; it runs no session start, reconcile or supervision.
  • The status line (⚠ firstmate-mod: …) carries summary or crew read failures, in the words of the failure. Quota failures have no visible error indicator.
  • Toasts fire for changes after a silent first read: something that newly needs you, a newly merged pull request, a worker that newly got stuck, or a provider entering the headroom threshold. /clear starts a new silent baseline; reload and resume keep what was already announced.

How worker state is worked out

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:

HerdrNewest status lineShown asTime since
workinganythingWorkingHerdr turned working
unknown or unrecognizedanythingWorkingHerdr turned unknown
blockedanythingStuck: question on its screenHerdr turned blocked
idle or donepausedWaitingthat line
idle or doneneeds-decisionWaiting: for Firstmatethat line
idle or doneblocked, failedStuckthat line
idle or donedoneJust finished (one hour)that line
idle or doneworking, resolved or noneWorking; after 10 minutes Stuck: stopped without a wordHerdr turned quiet
no pane listeddoneJust finished (one hour)that line
no pane listedanything elseStuck: window closedHerdr 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.

What it reads

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.

LaneReads
Crewherdr 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.
Summarystate/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.
Headroomquota-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.

Development

See CONTRIBUTING.md for setup, checks, text previews and fixture generation, and docs/design.md for the design rationale.

Scope

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.

License

MIT

Source 11 files
hooks/register.tsx 310 lines
1// 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}
310
hooks/band.ts 126 lines
1// 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}
126
hooks/config.ts 35 lines
1// 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}
35
hooks/discuss.ts 12 lines
1// 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}
12
hooks/fleet.ts 218 lines
1// 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}
218
hooks/pane.ts 144 lines
1// 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}
144
hooks/sources.ts 271 lines
1// 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}
271
hooks/text.ts 102 lines
1// 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}
102
hooks/toasts.ts 99 lines
1// 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}
99
hooks/normalize.ts 264 lines
1// 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}
264
types/index.d.ts 172 lines
1// 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