A lamp above the prompt: red when the firstmate fleet needs you, green when a PR is ready.

<img width="2306" height="1937" alt="image" src="https://github.com/user-attachments/assets/b7c507c9-6256-40af-9023-78929b30ab83" />
Claude Code mods for firstmate fleets, published as a Claude Code plugin marketplace.
| Mod | What it does |
|---|---|
| fleet-lamp | A lamp above the prompt: red when the fleet needs you, green when a PR is ready. |
| pr-weather | The CI weather of the fleet's open PRs above the prompt: one glyph per PR. |
claude plugin marketplace add jayjongcheolpark/firstmate-mods
claude plugin install fleet-lamp@firstmate-mods
claude plugin install pr-weather@firstmate-mods
To try a local checkout without installing it, start a session with the plugin folder loaded:
claude --plugin-dir /path/to/firstmate-mods/plugins/fleet-lamp
claude --plugin-dir /path/to/firstmate-mods/plugins/pr-weather
A band above the prompt that tells you when the fleet needs a human. You don't need a lamp.
A task needs a decision (red), with one more open red behind it:

A PR is ready for review (green):
https://github.com/acme/webapp/pull/7 fix-login"" loading="lazy">
Both images come from a live session at 90 columns: the terminal screen the session drew, rendered with a dark palette. The band draws its dot in the theme's error color for red and its success color for green, and draws the task (or PR ready) in bold.
When nothing needs you, the band shows nothing.
[key=...], [at=...] or [corr=...] tags and the colon), so needs-decision [key=pick]: A or B? shows as needs-decision: A or B?. The newest open red comes first, and +N more counts the others.The lamp is always one line. When it does not fit the band, the reason (or, for green, the task after the URL) is cut first, with …, down to 16 cells; then the task (or the URL), down to 12 cells; then the reason goes. The dot and +N more are never cut. The mod measures in terminal cells, so Korean and other wide text counts double, and in the terminal it leaves the last four cells for the band's [-] control.
The band reads only task.status and task.pr_ready records from firstmate's fleet activity ledger, state/fleet-ledger.jsonl (see docs/fleet-ledger.md in the firstmate repository), in the session's own firstmate home only.
task.status record whose state is needs-decision, blocked or failed. Two kinds of record are left out:ask-user findings=);Captain 2026-09-30 verbatim ...).[key=...] decision key) turns off by itself when a later resolved record carries the same task and key.task.pr_ready record, or a done record that reports a ready PR (PR https://..., child X done: PR https://..., PR ready: https://...). A done record that says the PR already landed or merged does not count. Green stays until your next prompt.These are the same rules as the lamp script that inspired this mod.
The ledger is opt-in. In your firstmate home, create the flag:
touch config/fleet-ledger
Delete the flag to turn the ledger off. While the ledger is off, the band shows nothing. The first time a session finds the home's ledger off, the mod adds one dim line to the transcript that tells you how to turn it on.
The mod looks for the firstmate home at or above the session's working directory: the nearest directory whose AGENTS.md has a # Firstmate heading line and that has a state/ folder. Start your firstmate session in its home, as usual, and the mod finds it. A claude -p run or an SDK session, where no one is at the prompt, follows no ledger.
To follow a home from somewhere else, set the plugin's home option in /config, or from a shell:
echo '{"home": "~/firstmate"}' | claude plugin configure fleet-lamp@firstmate-mods --values-stdin
The mod does not follow the ledgers of second mate homes. You do not see a second mate's decisions, so a red in a second mate's ledger is not yours yet. When the second mate passes a decision up, the main firstmate records it in the main ledger, and the band turns red then.
The mod reads only the config/fleet-ledger flag and state/fleet-ledger.jsonl in the home. It reads no other state file, makes no network calls and controls no hardware.
$.fs.read reads whole files of at most 4 MiB. So the mod reads the new bytes with tail -c +<offset>.claude plugin validate . # the marketplace
claude plugin validate plugins/fleet-lamp # the plugin and its hooks module
claude plugin test plugins/fleet-lamp # the rule, line fitting and band tests
npx -p typescript tsc -p plugins/fleet-lamp # type-check, once a session has loaded the mod
A session that loads the mod from a folder you own (--plugin-dir) writes the API types to plugins/fleet-lamp/.claude-plugin/types/, which tsconfig.json extends.
The rules are pure functions in plugins/fleet-lamp/hooks/rules.ts, and the one-line fitting is in plugins/fleet-lamp/hooks/line.ts. The hooks module that reads the ledger and draws the band is plugins/fleet-lamp/hooks/register.tsx.
A band above the prompt with the CI weather of the PRs your firstmate fleet is working on, one glyph per PR:
PRs ☂ #7 ↯ #9 ⚔ #11 ✎ #10 ☀ #12 +2 more updated 2m ago [ ↻ ] [ auto ]
| Glyph | Meaning |
|---|---|
| ↯ (magenta) | A workflow run on the PR's head commit is held for approval: a human has to approve it. |
| ☂ (red) | A check failed, was cancelled or timed out. |
| ⚔ (red) | The PR has merge conflicts with its base branch. While GitHub is still computing mergeability, the PR shows its CI glyph. |
| ☁ (yellow) | Checks are pending, queued or in progress. |
| ✎ (gray) | The PR is a draft. |
| ☀ (green) | Every check passed. |
| - (gray) | The PR has no checks. |
When more than one applies, the first in the table wins: a draft with a failed check shows ☂.
After the PRs come:
+N more when the PRs do not fit on one line;updated 2m ago, the time of the last successful refresh;(stale) when the last refresh failed, so the glyphs are from an earlier one. When the lookup of one PR fails, that PR alone keeps its last glyph, or stays out of the band until a lookup succeeds;low quota, every 12m while auto mode backs off, or rate-limited, resets in 17m while GitHub's rate limit holds every refresh;[ ↻ ] ([ … ] while a refresh runs) and the mode button [ auto ] or [ manual ].Press a PR's #N to open it, in any terminal:
#N.ctrl+x tab, move to #N with tab, and press Enter.#N.What a press does depends on where the session runs:
open <url>, which opens the PR in your default browser, and shows Opened PR #N.DISPLAY or WAYLAND_DISPLAY set) runs xdg-open <url> and shows Opened PR #N.Copied PR #N URL. That covers an SSH session (SSH_CONNECTION, SSH_TTY or SSH_CLIENT set) and a Linux server without a desktop, such as one you reach through a terminal multiplexer. In the terminal the copy goes through the terminal's clipboard (OSC 52, as /copy does), so it lands on the machine you are sitting at. Paste the URL into your browser.open exited 1; copied PR #N URL, or open failed: <error>; copied PR #N URL. If the copy fails too, the toast shows the reason and the URL itself.Each press also writes lines to the Claude Code debug log, which Claude Code writes only when you start it with claude --debug or --debug-file <path>. Find them with grep 'pr-weather press'. The first line shows that the press arrived. The second line shows the opener argv and its result:
pr-weather press #7 https://github.com/acme/webapp/pull/7 opener=none pressed
pr-weather press #7 https://github.com/acme/webapp/pull/7 opener=["open","https://github.com/acme/webapp/pull/7"] exit=0 stderr=""
In the second line, exit=<code> stderr="<text>" means the opener ran. error="<message>" means it could not run, for example a timeout. opener=none copy=no-local-browser means the session has no local browser to open.
The mod opens only https://github.com/<owner>/<repo>/pull/<n> URLs, runs the opener without a shell, and gives it 10 seconds.
The weather glyph before each #N is also a terminal hyperlink to the PR (cmd-click, handled by the terminal itself) where Claude Code draws terminal hyperlinks: Ghostty, iTerm2, WezTerm, kitty, Alacritty, Warp, Hyper and the VS Code terminal. If your terminal supports OSC 8 hyperlinks but is not on that list (Kaku, for example), set FORCE_HYPERLINK=1 in Claude Code's environment. On the desktop the glyph is always a link.
When there are no open PRs, the band shows PRs none and the buttons.
To refresh now, press [ ↻ ] or run /pr-weather refresh. That works in both modes, at most once every 30 seconds.
To switch modes, press the mode button or run /pr-weather mode auto or /pr-weather mode manual. The choice is kept across sessions until you change the mode setting itself.
To press a band button from the keyboard, focus the band with ctrl+x tab and press r (refresh) or m (mode), or move to a button with tab and press Enter. In the fullscreen layout you can also click them.
Each refresh first reads your remaining GitHub quota with gh api rate_limit, which does not count against the quota.
With the default fleet source, the PRs come from firstmate's fleet activity ledger, state/fleet-ledger.jsonl (see docs/fleet-ledger.md in the firstmate repository):
task.pr_ready record adds its PR, and a later one for the same task replaces it;done status record that reports a ready PR does the same, by fleet-lamp's green rule (PR https://..., PR ready: https://..., child X done: PR https://...);task.merged or task.cleaned_up record for that task removes it, and so does a done record that says the PR landed or merged;The ledger is opt-in: create the flag config/fleet-ledger in your firstmate home. Until the ledger exists, the band shows nothing, and the mod adds one dim line to the transcript that tells you how to turn it on.
The mod also reads the ledgers of the second mate homes registered in the home's data/secondmates.md: every entry with (home: <absolute path>; ...) whose directory exists on this machine. Remote entries (host: ...; root: ...; home: ...) are skipped, so the mod makes no SSH calls. Unlike fleet-lamp, which follows the main home alone, pr-weather shows second mate PRs. The band shows the PRs of every home, each PR once. A second mate home without a ledger is left out, with one dim line in the transcript that names it. While the main home has no ledger but a second mate does, the band shows the second mate's PRs and the mod adds the turn-it-on line once. Each refresh reads data/secondmates.md again, so a new second mate shows on the next refresh. Turn off includeSecondMates to read the main home alone.
The band shows only in a firstmate session: one whose working directory is at or under a firstmate home (a directory whose AGENTS.md has a # Firstmate heading line and that has a state/ folder). In any other session the mod draws nothing and makes no calls. With either source, a claude -p run or an SDK session, where no one is at the prompt, also draws nothing and makes no calls.
With the mine source, the band shows your own open PRs in the session's repository (gh pr list --author @me), in any session inside a git repository.
Set these in /config, or from a shell:
echo '{"mode": "manual", "refreshMinutes": 5}' | claude plugin configure pr-weather@firstmate-mods --values-stdin
| Setting | Default | What it does |
|---|---|---|
source | fleet | fleet: the fleet ledger's PRs, in firstmate sessions. mine: your open PRs in the session's repository. |
mode | auto | auto refreshes on the interval; manual refreshes only when you ask. |
refreshMinutes | 3 | How often auto mode refreshes, in minutes (at least 1). |
home | empty | The firstmate home to read (~ allowed). Empty: the nearest one at or above the session's working directory. When set, the band shows in every session. |
includeSecondMates | true | Also read the ledgers of the home's local second mate homes (fleet source). |
The GitHub CLI (gh), logged in (gh auth login). The mod reads GitHub only through gh with JSON output. If gh is missing or not logged in, the band shows nothing and the mod adds one dim line to the transcript that says what to do.
Each refresh makes one gh api rate_limit call, then two calls per PR: gh pr view --json for the checks and draft state, and gh api .../actions/runs?head_sha=... for runs held for approval.
claude plugin validate plugins/pr-weather # the plugin and its hooks module
claude plugin test plugins/pr-weather # the weather, ledger, second mate, layout, mode, opening and band tests
npx -p typescript tsc -p plugins/pr-weather # type-check, once a session has loaded the mod
The pure parts (glyphs, check classification, the ledger, the band layout, quota and backoff) are in plugins/pr-weather/hooks/weather.ts, the URL check and opener choice are in plugins/pr-weather/hooks/open.ts, and the data/secondmates.md parser is plugins/pr-weather/hooks/secondmates.ts. The hooks module that calls gh, schedules refreshes and draws the band is plugins/pr-weather/hooks/register.tsx.
hooks/register.tsx 181 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import { fitLine } from './line'
5import type { LampLine } from './line'
6import { applyLines, EMPTY, lampOf } from './rules'
7
8const latch = atom({ plugin: 'fleet-lamp', key: 'latch' } as const, EMPTY)
9const offsets = atom({ plugin: 'fleet-lamp', key: 'offsets' } as const, {})
10const followed = atom({ plugin: 'fleet-lamp', key: 'followed' } as const, null as string | null)
11
12const POLL_MS = 2000
13// The captain's own prompt, typed or sent from a phone; never a notification, peer, schedule or plugin.
14const CAPTAIN_ORIGINS = new Set(['composer', 'bridge'])
15
16export const register: Register = (on, options) => {
17 let isPolling = false
18
19 on('session.start', async ($, e, next) => {
20 const result = await next(e)
21 // No one is at the prompt to see the lamp: follow nothing.
22 if (!e.isInteractive) {
23 return result
24 }
25 const home = await findHome($, String(options.home ?? ''))
26 if (home === null) {
27 await update($, followed, () => null)
28 $.ui.log('no firstmate home above this session; set the plugin\'s "home" option to follow one', { to: 'debug' })
29 return result
30 }
31
32 // Whether this session has logged the ledger-off hint: once per session.
33 let isHinted = false
34 const tick = async () => {
35 if (isPolling) {
36 return
37 }
38 isPolling = true
39 try {
40 const isOn = await $.fs.exists(`${home}/config/fleet-ledger`)
41 if (!isOn && !isHinted) {
42 isHinted = true
43 $.ui.log(`the fleet ledger is off in ${home}; turn it on with: touch ${home}/config/fleet-ledger`)
44 }
45 const live = isOn ? home : null
46 if (live !== (await read($, followed))) {
47 await update($, followed, () => live)
48 }
49 if (isOn) {
50 await poll($, home)
51 }
52 } catch (error) {
53 $.ui.log(`ledger read skipped: ${String(error)}`, { to: 'debug' })
54 } finally {
55 isPolling = false
56 }
57 }
58 await tick()
59 $.clock.every(POLL_MS, () => void tick())
60 return result
61 })
62
63 // The lamp's clear-hook: the captain has seen it.
64 on('prompt.submit', async ($, e, next) => {
65 if (CAPTAIN_ORIGINS.has(e.origin.kind)) {
66 await update($, latch, () => EMPTY)
67 }
68 return next(e)
69 })
70
71 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
72 const lamp = lampOf(await read($, latch))
73 if (e.props.hasSurvey || (await read($, followed)) === null || lamp === null) {
74 return next(e)
75 }
76
77 const { Box, Text } = $.ui.resolve(e)
78 // What the plugins beneath draw stays, above the lamp: the band is shared.
79 const below = await next(e)
80 const more = lamp.count > 1 ? ` +${lamp.count - 1} more` : ''
81 const isRed = lamp.color === 'red'
82 const head = isRed ? '' : 'PR ready '
83 const parts: LampLine = isRed
84 ? { lead: '● ', main: lamp.signal.task, tail: ` ${lamp.signal.state}${lamp.signal.reason === '' ? '' : `: ${lamp.signal.reason}`}`, more }
85 : {
86 lead: `● ${head}`,
87 main: lamp.signal.pr ?? lamp.signal.task,
88 tail: lamp.signal.pr === null ? '' : ` ${lamp.signal.task}`,
89 more,
90 }
91 // The terminal draws its [-] collapse control in the row's last three cells; keep one more of air.
92 const columns = e.props.bodyColumns - (e.surface === 'terminal' ? 4 : 0)
93 const { main, tail } = fitLine(parts, columns)
94
95 // One Text, fitted beforehand: the line never wraps, and no part's spacing is squeezed away.
96 return (
97 <Box flexDirection="column">
98 {below}
99 <Box key="lamp" flexDirection="row">
100 <Text wrap="truncate-end">
101 <Text color={isRed ? 'error' : 'success'}>●</Text>{' '}
102 {/* A red reads `task state: reason`; a green `PR ready <url> task`, its task dim. */}
103 <Text bold>{isRed ? main : head}</Text>
104 {isRed ? tail : main}
105 <Text dimColor>{isRed ? '' : tail}</Text>
106 <Text dimColor>{more}</Text>
107 </Text>
108 </Box>
109 </Box>
110 )
111 })
112}
113
114/**
115 * The firstmate home: the configured one, else the nearest directory at or above the session's
116 * working directory whose AGENTS.md has a `# Firstmate` heading line and which holds state/.
117 */
118async function findHome($: EngineInterface, configured: string): Promise<string | null> {
119 if (configured.trim() !== '') {
120 return trimSlash(await expandTilde($, configured.trim()))
121 }
122 const found = await $.fs.ancestors({ names: ['AGENTS.md'] })
123 for (const { dir, content } of [...found].reverse()) {
124 if (/^# Firstmate\s*$/m.test(content) && (await isDir($, `${dir}/state`))) {
125 return trimSlash(dir)
126 }
127 }
128 return null
129}
130
131/** Reads the lines appended to the home's ledger since its saved offset and folds them into the latch. */
132async function poll($: EngineInterface, home: string): Promise<void> {
133 const ledger = `${home}/state/fleet-ledger.jsonl`
134 const stat = await $.fs.stat(ledger).catch(() => undefined)
135 const size = stat?.kind === 'file' ? stat.size : 0
136 const saved = (await read($, offsets))[home]
137 const save = (to: number) => update($, offsets, current => ({ ...current, [home]: to }))
138 // First look: start at the end, so old history does not flash red.
139 if (saved === undefined) {
140 await save(size)
141 return
142 }
143 // The ledger was truncated (docs/fleet-ledger.md): later records start from the top.
144 const start = size < saved ? 0 : saved
145 if (size === start) {
146 if (start !== saved) {
147 await save(start)
148 }
149 return
150 }
151
152 // $.fs.read takes whole files of at most 4 MiB, and the ledger never rotates: tail reads the
153 // appended bytes alone.
154 const { exitCode, stdout } = await $.process.run(['tail', '-c', `+${start + 1}`, ledger])
155 if (exitCode !== 0) {
156 return
157 }
158 // A partial last record waits for the next write.
159 const complete = stdout.slice(0, stdout.lastIndexOf('\n') + 1)
160 if (complete === '') {
161 return
162 }
163 await update($, latch, current => applyLines(current, complete))
164 await save(start + new TextEncoder().encode(complete).length)
165}
166
167async function isDir($: EngineInterface, path: string): Promise<boolean> {
168 const stat = await $.fs.stat(path).catch(() => undefined)
169 return stat?.kind === 'dir'
170}
171
172async function expandTilde($: EngineInterface, path: string): Promise<string> {
173 if (path !== '~' && !path.startsWith('~/')) {
174 return path
175 }
176 const home = (await $.env.get('HOME')) ?? ''
177 return home + path.slice(1)
178}
179
180const trimSlash = (path: string): string => (path.length > 1 ? path.replace(/\/+$/, '') : path)
181hooks/line.ts 80 lines1// The lamp's one line, fitted to the band in terminal cells. Pure: the tests hold it without a surface.
2
3// Code points a terminal draws two cells wide: Hangul, CJK, fullwidth forms, wide emoji.
4const WIDE =
5 /[ᄀ-ᅟ⺀-〾ぁ-㏿㐀-䶿一-鿿ꀀ-ꥠ-가-힣豈-︰-﹏-⦆¢-₩\u{1F300}-\u{1F64F}\u{1F680}-\u{1F6FF}\u{1F900}-\u{1F9FF}\u{1FA70}-\u{1FAFF}\u{20000}-\u{3FFFD}]/u
6// Code points that take no cell of their own: combining marks, joiners, variation selectors.
7const ZERO = /[\p{Mn}\p{Me}-︀-️]/u
8
9const ELLIPSIS = '…'
10
11function cellsOf(char: string): number {
12 if (ZERO.test(char)) return 0
13 return WIDE.test(char) ? 2 : 1
14}
15
16/** How many terminal cells `text` takes. */
17export function cellWidth(text: string): number {
18 let width = 0
19 for (const char of text) width += cellsOf(char)
20 return width
21}
22
23/** `text` cut to at most `max` cells, an ellipsis marking the cut; empty when `max` is below one. */
24export function truncateCells(text: string, max: number): string {
25 if (cellWidth(text) <= max) return text
26 if (max < 1) return ''
27 let kept = ''
28 let width = 0
29 for (const char of text) {
30 const cells = cellsOf(char)
31 if (width + cells > max - 1) break
32 kept += char
33 width += cells
34 }
35 return kept + ELLIPSIS
36}
37
38/**
39 * The lamp's line in parts, drawn in this order:
40 * `lead` (the dot, and for a green `PR ready`, kept), `main` (the task or the PR), `tail` (the state and
41 * reason, or the task beside a PR), then `more` (the `+N more` count, kept whole).
42 */
43export type LampLine = { lead: string; main: string; tail: string; more: string }
44
45// The cells the tail keeps before the main part starts to give way.
46export const MIN_TAIL = 16
47// The cells the main part keeps before the tail gives way entirely.
48export const MIN_MAIN = 12
49// The fewest cells of tail worth drawing once it has given way.
50const MIN_STUB = 4
51
52/**
53 * The line fitted to `columns` cells: the tail shrinks first, down to MIN_TAIL; then the main
54 * part, down to MIN_MAIN; then the tail to a stub or nothing, then the main part to nothing. The lead
55 * and the count are never cut here; a band too narrow even for them is the surface's to cut.
56 */
57export function fitLine(line: LampLine, columns: number): LampLine {
58 const fixed = cellWidth(line.lead) + cellWidth(line.more)
59 let room = columns - fixed
60 const mainWidth = cellWidth(line.main)
61 const tailWidth = cellWidth(line.tail)
62 if (mainWidth + tailWidth <= room) return line
63
64 // The tail gives way first, keeping MIN_TAIL cells while the main part fits beside it.
65 const tailKept = Math.min(tailWidth, MIN_TAIL)
66 if (mainWidth + tailKept <= room) return { ...line, tail: truncateCells(line.tail, room - mainWidth) }
67
68 // Then the main part, keeping MIN_MAIN cells beside the shortened tail.
69 const mainKept = Math.min(mainWidth, MIN_MAIN)
70 if (mainKept + tailKept <= room) {
71 return { ...line, main: truncateCells(line.main, room - tailKept), tail: truncateCells(line.tail, tailKept) }
72 }
73
74 // Then the tail goes, but for a stub of it where a few cells are left, and the main part takes the rest.
75 room = Math.max(0, room)
76 const main = truncateCells(line.main, room)
77 const left = room - cellWidth(main)
78 return { ...line, main, tail: left >= MIN_STUB ? truncateCells(line.tail, left) : '' }
79}
80hooks/rules.ts 110 lines1// The captain's lamp rules over firstmate's fleet activity ledger (docs/fleet-ledger.md).
2// Pure: no `$`, so the tests hold them without a file system.
3
4import type { GreenSignal, Latch, RedSignal } from '../types'
5
6export const EMPTY: Latch = { reds: [], greens: [] }
7
8const RED_STATES = new Set(['needs-decision', 'blocked', 'failed'])
9// a held decision re-recorded with the captain's own answer is not a new question
10const CAPTAIN_ANSWER = /\bCaptain(?: answer)? \d{4}-\d{2}-\d{2},? verbatim/
11const GREEN_PATTERNS = [/PR ready: https:\/\//, /^\s*PR https:\/\//, /child \S+ done: PR https:\/\//]
12const LANDED = /\b(landed|merged)\b/
13const URL = /https:\/\/\S+/
14
15type LedgerRecord = Readonly<Record<string, unknown>>
16
17const text = (value: unknown): string => (typeof value === 'string' ? value : '')
18
19// A status line's own lead, `<state> [key=...] [at=...] [corr=...]:`, which the band already says.
20const STATUS_TAGS = /^(?:\s*\[[^\]]*\])*\s*:\s*/
21
22/** The one-line reason a red shows: the status text without its status-line lead, whitespace folded. */
23function reasonOf(value: string, state: string): string {
24 const folded = value.replace(/\s+/g, ' ').trim()
25 if (!folded.startsWith(state)) {
26 return folded
27 }
28 const tags = STATUS_TAGS.exec(folded.slice(state.length))
29 return tags === null ? folded : folded.slice(state.length + tags[0].length).trim()
30}
31
32/** Folds one ledger record into the latch; records it does not recognize leave it as it was. */
33export function apply(latch: Latch, record: LedgerRecord): Latch {
34 const task = text(record.task)
35 const ts = typeof record.ts === 'number' ? record.ts : 0
36
37 if (record.event === 'task.pr_ready') {
38 return addGreen(latch, { task, pr: text(record.pr) || null, ts })
39 }
40 if (record.event !== 'task.status') {
41 return latch
42 }
43
44 const state = text(record.state)
45 const key = text(record.key) || null
46 const body = text(record.text)
47
48 if (state === 'resolved') {
49 return key === null
50 ? latch
51 : { ...latch, reds: latch.reds.filter(red => !(red.task === task && red.key === key)) }
52 }
53 if (RED_STATES.has(state) && !body.includes('ask-user findings=') && !CAPTAIN_ANSWER.test(body)) {
54 return addRed(latch, { task, key, state, reason: reasonOf(body, state), ts })
55 }
56 if (state === 'done' && GREEN_PATTERNS.some(pattern => pattern.test(body)) && !LANDED.test(body)) {
57 return addGreen(latch, { task, pr: body.match(URL)?.[0] ?? null, ts })
58 }
59 return latch
60}
61
62/** Parses text appended to the ledger, complete lines only, and folds each record in order. */
63export function applyLines(latch: Latch, lines: string): Latch {
64 return lines.split('\n').reduce((next, line) => {
65 const record = parse(line)
66 return record === null ? next : apply(next, record)
67 }, latch)
68}
69
70function parse(line: string): LedgerRecord | null {
71 if (line.trim() === '') {
72 return null
73 }
74 try {
75 const value: unknown = JSON.parse(line)
76 return typeof value === 'object' && value !== null && !Array.isArray(value) ? (value as LedgerRecord) : null
77 } catch {
78 return null
79 }
80}
81
82// The ledger delivers at least once, so a repeated record replaces its twin rather than stacking.
83function addRed(latch: Latch, red: RedSignal): Latch {
84 const isSame = (other: RedSignal) =>
85 other.task === red.task &&
86 (red.key === null ? other.key === null && other.reason === red.reason : other.key === red.key)
87 return { ...latch, reds: [...latch.reds.filter(other => !isSame(other)), red] }
88}
89
90function addGreen(latch: Latch, green: GreenSignal): Latch {
91 const isSame = (other: GreenSignal) =>
92 green.pr === null ? other.pr === null && other.task === green.task : other.pr === green.pr
93 return { ...latch, greens: [...latch.greens.filter(other => !isSame(other)), green] }
94}
95
96/** What the band shows: the newest open red, else the newest ready PR, else nothing. */
97export type Lamp =
98 | { color: 'red'; signal: RedSignal; count: number }
99 | { color: 'green'; signal: GreenSignal; count: number }
100 | null
101
102export function lampOf(latch: Latch): Lamp {
103 const red = latch.reds.at(-1)
104 if (red) {
105 return { color: 'red', signal: red, count: latch.reds.length }
106 }
107 const green = latch.greens.at(-1)
108 return green ? { color: 'green', signal: green, count: latch.greens.length } : null
109}
110types/index.d.ts 32 lines1/** A captain-facing question or failure, newest last in `Latch.reds`. */
2export type RedSignal = {
3 task: string
4 /** The record's `[key=...]` decision key; a keyed red closes on its `resolved` record. */
5 key: string | null
6 state: string
7 reason: string
8 ts: number
9}
10
11/** A ready PR, newest last in `Latch.greens`. */
12export type GreenSignal = {
13 task: string
14 pr: string | null
15 ts: number
16}
17
18/** Everything the band shows: red outranks green, and both clear on the captain's next prompt. */
19export type Latch = { reds: RedSignal[]; greens: GreenSignal[] }
20
21declare module 'claude-code' {
22 interface PluginState {
23 'fleet-lamp': {
24 latch: Latch
25 /** Byte offset into the home's state/fleet-ledger.jsonl read so far, by home; absent until the first look. */
26 offsets: Readonly<Record<string, number>>
27 /** The firstmate home whose ledger is followed right now; the band is off while it is null. */
28 followed: string | null
29 }
30 }
31}
32