Chief of staff: launches visible worker Claude sessions in herdr worktree spaces from Linear tickets and supervises them to merge-ready PRs.

<img src="assets/logo-lockup.svg" alt="athena" width="360">
A chief of staff for Claude Code in herdr. You hand athena Linear tickets. For each one it launches a visible worker Claude in its own git worktree and herdr space, shows the workers as columns beside itself, and supervises them to merge-ready PRs. It asks you only when a decision is yours, and you can type into any worker at any time.
/usr/bin/python3.athena init offers to install it).claude, 2.1.286 or later for the watch mod.gh auth login), and a git identity: workers push branches and open PRs.git clone https://github.com/Delitefully/athena.git
mkdir -p ~/.local/bin && ln -sf "$PWD/athena/bin/athena" ~/.local/bin/athena # ~/.local/bin on your PATH
claude plugin marketplace add "$PWD/athena"
claude plugin install athena@athena
athena init # checks what athena needs, asks for your folders, merge rules and model
athena # starts herdr if needed, opens the athena space, attaches
Then tell athena what to start: "start abc-101 and abc-102". Detach with ctrl+b q; everything keeps running, and athena brings you back.
prefix+shift+c (or athena hq): open or focus athena's space.prefix+shift+b (or athena board toggle): show or hide the worker columns. To select text in a column, hold Shift while you drag (the column's herdr terminal attach takes the mouse; Ghostty and most terminals let Shift through to their own selection).prefix+shift+h (or athena hq restart): update Claude Code and the plugins, then restart athena in place.athena hq rotate: replace a long HQ conversation with a fresh one that starts from a handoff written from athena's state, instead of compacting it again.athena dash open: the dashboard in your browser: what needs you, what is in progress, what is done.remoteControlAtStartup says in ~/.claude/settings.json. A worker that was already running keeps its connection until it next starts.logs/watch-suppressed.log).athena command and flag.athena init, config.json, environment variables, state files.hooks/watch-mod/register.tsx 422 lines1// athena-watch: in the HQ session alone (ATHENA_ROLE=hq), runs `athena watch --tagged` for the session's life,
2// draws a one-line status band above the prompt, and wakes HQ with one short prompt only for lines that need it,
3// each event once in ten minutes. A wake draws as athena's own compact row, without the engine's plugin frame.
4// It also hides any Monitor rows left in HQ's transcript (the call, its result and its notifications). In a worker
5// (ATHENA_WORKER set, or its cwd in a live worker's worktree), it draws the ATHENA-REPORT line as a completion block. Every other session sees no change.
6import type { EngineInterface, Register } from 'claude-code'
7
8import type { Board, Part, Segment } from './core'
9import {
10 boardHref,
11 clockText,
12 eventParts,
13 fitStatus,
14 hyperlinks,
15 isMonitorNotice,
16 LINK_ENV,
17 linkParts,
18 parseWake,
19 readBoard,
20 readRecords,
21 repoSlug,
22 reportView,
23 restartDelay,
24 splitReport,
25 Waker,
26} from './core'
27
28/** A run shorter than this counts as a quick exit, and the next start waits longer. */
29const STEADY_MS = 60000
30
31/** athena's colour (docs/brand.md): lit madder, which reads on a dark ground and a light one. */
32const MADDER = '#E0573D'
33/** athena's mark in a terminal: one continuous looped thread, in any font. */
34const MARK = '∞'
35/** The store key for what was told in the last ten minutes, kept across a reload or an HQ restart; one per athena
36 * state dir, since every session running the plugin shares its store. */
37async function toldKey($: EngineInterface): Promise<string> {
38 return `told:${(await $.env.get('ATHENA_STATE_DIR')) || 'default'}`
39}
40
41let role: Promise<boolean> | undefined
42let isWatching = false
43let waker: Waker | undefined
44/** What the band shows: the board from the last status record, or a note while the watch starts or restarts. */
45let board: Board | undefined
46let note: string | undefined
47/** The last wake, drawn as athena's styled row at the top of the band until the person next types a prompt. */
48let lastWake: { time: string | undefined; events: string[] } | undefined
49
50/** The `env` of the settings files, or nothing where they cannot be read. */
51async function settingsEnv($: EngineInterface): Promise<Record<string, unknown>> {
52 const env = await $.settings.read().then(s => s.env as Record<string, unknown> | undefined, () => undefined)
53 return env ?? {}
54}
55
56// HQ only: ATHENA_ROLE=hq, and never a worker, even one that inherited HQ's environment.
57async function readRole($: EngineInterface): Promise<boolean> {
58 const role = (await $.env.get('ATHENA_ROLE')) ?? (await settingsEnv($)).ATHENA_ROLE
59 return role === 'hq' && !(await readWorker($))
60}
61
62let worker: Promise<boolean> | undefined
63
64// A worker session: ATHENA_WORKER set, in its environment or its settings; else `athena whoami` finds it by its
65// worktree in the ledger, as the status hook does. herdr's bare `claude --resume` after a restart drops both.
66async function readWorker($: EngineInterface): Promise<boolean> {
67 if ((await $.env.get('ATHENA_WORKER')) ?? (await settingsEnv($)).ATHENA_WORKER) return true
68 const found = await $.process.run([`${$.plugin.root}/bin/athena`, 'whoami']).catch(() => undefined)
69 return found?.exitCode === 0 && found.stdout.trim() !== ''
70}
71
72function isWorker($: EngineInterface): Promise<boolean> {
73 worker ??= readWorker($).catch(() => false)
74 return worker
75}
76
77// Read lazily and once: a render on --resume can come before session.start settles.
78function isHq($: EngineInterface): Promise<boolean> {
79 role ??= readRole($).catch(() => false)
80 return role
81}
82
83let linking: Promise<boolean> | undefined
84
85// Whether a Link draws as a masked OSC 8 span here, read once from the variables Claude Code decides it by.
86async function readLinking($: EngineInterface): Promise<boolean> {
87 // Each name a literal, so validate lists what the mod reads.
88 const read: Record<(typeof LINK_ENV)[number], string | undefined> = {
89 FORCE_HYPERLINK: await $.env.get('FORCE_HYPERLINK'),
90 TERM_PROGRAM: await $.env.get('TERM_PROGRAM'),
91 TERM_PROGRAM_VERSION: await $.env.get('TERM_PROGRAM_VERSION'),
92 LC_TERMINAL: await $.env.get('LC_TERMINAL'),
93 TERM: await $.env.get('TERM'),
94 TERMINAL_EMULATOR: await $.env.get('TERMINAL_EMULATOR'),
95 WT_SESSION: await $.env.get('WT_SESSION'),
96 TMUX: await $.env.get('TMUX'),
97 VTE_VERSION: await $.env.get('VTE_VERSION'),
98 }
99 // A settings file's env (HQ's sets FORCE_HYPERLINK) counts as the engine's own.
100 const settings = await settingsEnv($)
101 const env: Record<string, string | undefined> = {}
102 for (const key of LINK_ENV) {
103 const value = read[key] ?? settings[key]
104 env[key] = typeof value === 'string' ? value : undefined
105 }
106 return hyperlinks(env)
107}
108
109function isLinking($: EngineInterface): Promise<boolean> {
110 linking ??= readLinking($).catch(() => false)
111 return linking
112}
113
114let workerRepo: Promise<string | undefined> | undefined
115
116// A worker's own repo, `owner/repo` from its worktree's origin remote: what its report's PR links into.
117function readWorkerRepo($: EngineInterface): Promise<string | undefined> {
118 workerRepo ??= $.process
119 .run(['git', 'remote', 'get-url', 'origin'])
120 .then(r => (r.exitCode === 0 ? repoSlug(r.stdout) : undefined))
121 .catch(() => undefined)
122 return workerRepo
123}
124
125type Elements = ReturnType<EngineInterface['ui']['resolve']>
126
127/**
128 * Drawn parts: each one with an `href` a masked link where the terminal draws one, plain text otherwise (never the
129 * engine's `label URL` fallback). Display only: the text a wake submits never carries a link.
130 */
131function drawParts(ui: Elements, parts: readonly Part[], isOn: boolean, style: { color?: string; dimColor?: boolean } = {}) {
132 const { Text, Link } = ui
133 return parts.map(part =>
134 part.href && isOn ? (
135 <Link href={part.href}>
136 <Text {...style}>{part.text}</Text>
137 </Link>
138 ) : (
139 <Text {...style}>{part.text}</Text>
140 ),
141 )
142}
143
144function show(next: { board?: Board; note?: string }, $: EngineInterface): void {
145 board = next.board ?? board
146 note = next.note
147 $.ui.invalidate('ui.render')
148}
149
150/** Minutes east of UTC: the module's own clock reads UTC, so ask the system once. */
151async function utcOffset($: EngineInterface): Promise<number> {
152 try {
153 const m = /^([+-])(\d\d)(\d\d)$/.exec((await $.process.run(['date', '+%z'])).stdout.trim())
154 if (m) return (m[1] === '-' ? -1 : 1) * (Number(m[2]) * 60 + Number(m[3]))
155 } catch {
156 // fall through
157 }
158 return -new Date().getTimezoneOffset()
159}
160
161async function told($: EngineInterface, key: string): Promise<Record<string, number>> {
162 const value = await $.store.get(key).catch(() => undefined)
163 if (!value || typeof value !== 'object') return {}
164 return Object.fromEntries(Object.entries(value).filter((kv): kv is [string, number] => typeof kv[1] === 'number'))
165}
166
167async function watch($: EngineInterface): Promise<void> {
168 $.ui.status(undefined) // the band replaces the engine's status line
169 const offset = await utcOffset($)
170 const key = await toldKey($)
171 const w = (waker = new Waker({
172 // asUser: stored bare, so the row shows the `athena watch HH:MM:` prompt without the engine's plugin frame and
173 // its "This is how Claude Code surfaces a prompt..." sentence. The header names the mod; hq.md and the skill tell the
174 // model it is the mod's notice, not the human.
175 submit: text => {
176 lastWake = parseWake(text)
177 $.ui.invalidate('ui.render')
178 return $.prompt.submit({ text, asUser: true })
179 },
180 after: (ms, fn) => $.clock.after(ms, fn),
181 now: () => Date.now(),
182 stamp: () => clockText(Date.now(), offset),
183 sent: await told($, key),
184 remember: sent => void $.store.set(key, sent).catch(() => undefined),
185 }))
186 const command = (await $.env.get('ATHENA_WATCH_CMD')) || `${$.plugin.root}/bin/athena`
187 let quickExits = 0
188 for (;;) {
189 show({ note: 'watch starting' }, $)
190 const startedAt = Date.now()
191 let rest = ''
192 let errors = ''
193 let how = ''
194 try {
195 const child = $.process.spawn({ argv: [command, 'watch', '--tagged'] })
196 for (;;) {
197 const step = await child.next()
198 if (step.done) {
199 how = step.value.signal ? `signal ${step.value.signal}` : `code ${step.value.code}`
200 break
201 }
202 const { stream, text } = step.value
203 if (stream === 'stderr') {
204 errors = (errors + text).slice(-2000)
205 continue
206 }
207 const read = readRecords(rest, text)
208 rest = read.rest
209 for (const r of read.records) {
210 if ('status' in r) show({ board: readBoard(r.board), note: readBoard(r.board) ? undefined : r.status }, $)
211 else if (r.wake) w.add(r.line)
212 else w.settle(r.line)
213 }
214 }
215 } catch (err) {
216 how = `could not start ${command}: ${err instanceof Error ? err.message : String(err)}`
217 }
218 quickExits = Date.now() - startedAt < STEADY_MS ? quickExits + 1 : 0
219 const delay = restartDelay(Math.max(quickExits, 1))
220 const lastError = errors.trim().split('\n').pop()
221 // Told once per run of quick exits, so a watch that cannot start does not wake HQ every minute.
222 if (quickExits <= 1) w.add(`watch error: exited (${how})${lastError ? `: ${lastError}` : ''}; restarting`)
223 show({ note: `watch exited (${how}); restarting in ${Math.round(delay / 1000)}s` }, $)
224 await new Promise<void>(resolve => $.clock.after(delay, resolve))
225 }
226}
227
228async function hidden($: EngineInterface, e: Parameters<EngineInterface['ui']['resolve']>[0]) {
229 const { Box } = $.ui.resolve(e)
230 return <Box display="none" />
231}
232
233const TONE: Record<Segment['tone'], string | undefined> = { needs: 'warning', active: undefined, done: 'success', dash: undefined }
234
235export const register: Register = on => {
236 on('session.start', async ($, e, next) => {
237 const started = await next(e)
238 if (!isWatching && (await isHq($))) {
239 isWatching = true
240 void watch($)
241 }
242 return started
243 })
244
245 // The person typed: they have seen the last wake, so its row leaves the band.
246 on('prompt.submit', async ($, e, next) => {
247 if (e.origin.kind === 'composer' && lastWake) {
248 lastWake = undefined
249 $.ui.invalidate('ui.render')
250 }
251 return next(e)
252 }).catch(($, e, next) => next(e)) // never in the way of a prompt
253
254 on('turn.start', async ($, e, next) => {
255 waker?.turnStarted()
256 return next(e)
257 })
258
259 on('turn.complete', async ($, e, next) => {
260 if (e.agentId === undefined) waker?.turnEnded()
261 return next(e)
262 })
263
264 // A wake drawn as athena's own compact row: its mark, a dim time, one event per line (every event, so ctrl+o shows
265 // the same; isExpanded is also true under a speaker label, which this row has). Claude Code 2.1.296 raises no ui.render for a plugin's prompt row (its own `› Prompt from the
266 // athena plugin` block), so this waits for a build that does; until then `asUser` keeps the frame off the row.
267 on('ui.render', { component: 'UserMessage', props: { origin: { kind: 'plugin', name: 'athena' } } }, async ($, e, next) => {
268 const wake = parseWake(e.props.text)
269 if (!wake || !(await isHq($))) return next(e)
270 const ui = $.ui.resolve(e)
271 const { Box, Text } = ui
272 const isOn = await isLinking($)
273 return (
274 <Box flexDirection="row" columnGap={1}>
275 <Text color={MADDER} bold>
276 {`${MARK} athena`}
277 </Text>
278 {wake.time ? <Text dimColor>{wake.time}</Text> : null}
279 <Box flexDirection="column">
280 {wake.events.map(event => (
281 <Text>{drawParts(ui, eventParts(event, board), isOn)}</Text>
282 ))}
283 </Box>
284 </Box>
285 )
286 })
287
288 // The band above the prompt. On top, the last wake as athena's own row: its mark, a dim time, one event per line
289 // (the transcript's row of it is the engine's to draw). Then the status: what needs the human (warning), the
290 // active workers, and the done ones (success), folded to fit one line. Another plugin's band still draws under it.
291 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
292 if (e.props.hasSurvey || !(board || note || lastWake) || !(await isHq($))) return next(e)
293 const ui = $.ui.resolve(e)
294 const { Box, Text } = ui
295 const isOn = await isLinking($)
296 const head = `${MARK} athena`
297 const indent = ' '.repeat(head.length + 1)
298 const segments = board && !note ? fitStatus(board, e.props.bodyColumns - head.length - 1) : []
299 const status = note ? (
300 <Text dimColor>{note}</Text>
301 ) : (
302 segments.map((s, i) => (
303 <Text>
304 {i ? <Text dimColor>{' · '}</Text> : ''}
305 {s.tone === 'dash'
306 ? drawParts(ui, [{ text: s.text, href: s.href }], isOn, { dimColor: true })
307 : drawParts(ui, linkParts(s.text, boardHref(board)), isOn, { color: TONE[s.tone] })}
308 </Text>
309 ))
310 )
311 const theirs = await next(e)
312 return (
313 <Box flexDirection="column">
314 {lastWake ? (
315 <Box flexDirection="row" columnGap={1}>
316 <Box flexShrink={0}>
317 <Text color={MADDER} bold>
318 {head}
319 </Text>
320 </Box>
321 {lastWake.time ? (
322 <Box flexShrink={0}>
323 <Text dimColor>{lastWake.time}</Text>
324 </Box>
325 ) : null}
326 <Box flexDirection="column" flexShrink={1}>
327 {lastWake.events.map(event => (
328 <Text wrap="truncate-end">{drawParts(ui, eventParts(event, board), isOn)}</Text>
329 ))}
330 </Box>
331 </Box>
332 ) : null}
333 {board || note ? (
334 <Box flexDirection="row">
335 <Text wrap="truncate-end">
336 {lastWake ? (
337 <Text>{indent}</Text>
338 ) : (
339 <Text color={MADDER} bold>
340 {head}
341 </Text>
342 )}
343 {lastWake ? '' : ' '}
344 {status}
345 </Text>
346 </Box>
347 ) : null}
348 {theirs}
349 </Box>
350 )
351 })
352
353 // In a worker, the ATHENA-REPORT line its final message ends with draws as a completion block: the status in
354 // its colour, the PR and head, the verify line, the decisions and the concerns, each one line. Drawing only: the stored message,
355 // what the hooks parse and what athena reads are unchanged. A line that does not parse keeps the engine's drawing.
356 on('ui.render', { component: 'AssistantMessage' }, async ($, e, next) => {
357 if (e.props.isSummary || !e.props.text.includes('ATHENA-REPORT')) return next(e)
358 const split = splitReport(e.props.text)
359 if (!split || !(await isWorker($))) return next(e)
360 const v = reportView(split.report, await readWorkerRepo($))
361 const ui = $.ui.resolve(e)
362 const { Box, Text } = ui
363 const isOn = await isLinking($)
364 const rest = [split.before, split.after].filter(Boolean).join('\n\n')
365 const drawn = rest ? await next({ ...e, props: { ...e.props, text: rest } }) : null
366 const row = (label: string, value: ReturnType<typeof Text>) => (
367 <Box flexDirection="row">
368 <Box width={10} flexShrink={0}>
369 <Text dimColor>{label}</Text>
370 </Box>
371 <Box flexDirection="column" flexShrink={1}>
372 {value}
373 </Box>
374 </Box>
375 )
376 const bullets = (items: string[], color?: string) => (
377 <Box flexDirection="column">
378 {items.map(item => (
379 <Text color={color} wrap="truncate-end">{`• ${item}`}</Text>
380 ))}
381 </Box>
382 )
383 return (
384 <Box flexDirection="column">
385 {drawn}
386 <Box flexDirection="column" borderStyle="round" borderColor={MADDER} paddingX={1} marginTop={rest ? 1 : 0}>
387 <Box flexDirection="row" columnGap={1}>
388 <Text color={MADDER} bold>
389 {`${MARK} athena report`}
390 </Text>
391 <Text color={v.status.color} bold>
392 {v.status.text}
393 </Text>
394 </Box>
395 {v.pr ? row('PR', <Text>{drawParts(ui, [{ text: v.pr.label, href: v.pr.href }], isOn)}</Text>) : null}
396 {v.head ? row('head', <Text dimColor>{v.head}</Text>) : null}
397 {v.verify ? row('verify', <Text>{v.verify}</Text>) : null}
398 {v.decisions.length ? row('decisions', bullets(v.decisions)) : null}
399 {v.concerns.length ? row('concerns', bullets(v.concerns, 'warning')) : null}
400 </Box>
401 </Box>
402 )
403 })
404
405 // Monitor rows from before the mod (or from the manual fallback) are drawn as nothing in HQ.
406 on('ui.render', { component: 'ToolUse', props: { tool: 'Monitor' } }, async ($, e, next) =>
407 (await isHq($)) ? hidden($, e) : next(e),
408 )
409
410 on('ui.render', { component: 'ToolResult', props: { tool: 'Monitor' } }, async ($, e, next) =>
411 (await isHq($)) ? hidden($, e) : next(e),
412 )
413
414 // A Monitor's events, end and expiry notices are task-notification rows. Drawing changes the row alone, never
415 // what the model read; under ctrl+o (isExpanded) they draw in full.
416 on('ui.render', { component: 'UserMessage', props: { origin: { kind: 'task-notification' } } }, async ($, e, next) =>
417 // The task carries no tool name (2.1.296: id, status, type, toolUseId; a Monitor's event rows only an id), so
418 // a Monitor's rows are told by the engine's own text for them.
419 !e.props.isExpanded && e.props.task && isMonitorNotice(e.props.text) && (await isHq($)) ? hidden($, e) : next(e),
420 )
421}
422hooks/watch-mod/core.ts 691 lines1// The athena-watch mod's logic, free of the engine so it is tested on its own: reading `athena watch --tagged`
2// output, and turning its actionable lines into as few HQ prompts as possible.
3
4export type WatchRecord = { line: string; wake: boolean } | { status: string; board?: unknown }
5
6/** Splits what the child wrote into whole lines; a line that is not a record of ours is an actionable line as is. */
7export function readRecords(rest: string, text: string): { records: WatchRecord[]; rest: string } {
8 const parts = (rest + text).split('\n')
9 const tail = parts.pop() ?? ''
10 const records: WatchRecord[] = []
11 for (const raw of parts) {
12 const line = raw.trim()
13 if (!line) continue
14 records.push(parseRecord(line))
15 }
16 return { records, rest: tail }
17}
18
19function parseRecord(line: string): WatchRecord {
20 try {
21 const value: unknown = JSON.parse(line)
22 if (value && typeof value === 'object') {
23 const v = value as Record<string, unknown>
24 if (typeof v.status === 'string') return { status: v.status, board: v.board }
25 if (typeof v.line === 'string') return { line: v.line, wake: v.wake === true }
26 }
27 } catch {
28 // not JSON: fall through
29 }
30 return { line, wake: true }
31}
32
33export const MAX_LINES = 8
34const MAX_LINE = 200
35
36/**
37 * One prompt for a batch: a stamped header, then one event per line. The model reads it as is; the HQ transcript
38 * draws it as athena's own row (parseWake reads it back).
39 *
40 * athena watch 22:19:
41 * abc-101 report DONE
42 * pr47 checks pending->failure
43 */
44export function promptText(lines: readonly string[], at?: string): string {
45 // A doorbell's ring is never cut: the watch does not ring for it again, and athena status does not show it.
46 const isRing = (l: string) => DOORBELL.test(l) || SLACK.test(l)
47 const rings = lines.filter(isRing)
48 const others = lines.filter(l => !isRing(l))
49 const kept = others.slice(0, Math.max(MAX_LINES - rings.length, 0))
50 const shown = [...rings, ...kept.map(l => (l.length > MAX_LINE ? l.slice(0, MAX_LINE - 1) + '…' : l))]
51 const more = others.length - kept.length
52 if (more > 0) shown.push(`+${more} more (athena status)`)
53 return `athena watch${at ? ` ${at}` : ''}:\n${shown.join('\n')}`
54}
55
56const HEADER = /^athena watch(?: (\d{1,2}:\d{2}))?:\s*(.*)$/
57const FRAME = /^(The \S+ plugin sent a message|This is how Claude Code surfaces)/
58
59/** A wake prompt read back from a transcript row: its stamp and events. Tolerates the engine's frame around it and
60 * the one-line `athena watch: a · b` prompts sent before the stamp. Undefined for anything else. */
61export function parseWake(text: string): { time: string | undefined; events: string[] } | undefined {
62 const lines = text.split('\n')
63 const at = lines.findIndex(l => HEADER.test(l.trim()))
64 if (at < 0) return undefined
65 const [, time, rest] = HEADER.exec(lines[at]!.trim())!
66 const events = rest ? rest.split(' · ') : []
67 for (const raw of lines.slice(at + 1)) {
68 const line = raw.trim()
69 if (FRAME.test(line)) break
70 if (line) events.push(line)
71 }
72 return events.length ? { time, events } : undefined
73}
74
75/** `HH:MM` of a moment, in the zone `offsetMinutes` east of UTC (the module's clock reads UTC). */
76export function clockText(ms: number, offsetMinutes: number): string {
77 const d = new Date(ms + offsetMinutes * 60000)
78 return `${String(d.getUTCHours()).padStart(2, '0')}:${String(d.getUTCMinutes()).padStart(2, '0')}`
79}
80
81// The words a wake may carry. HQ reads a wake as the person's own words (it is submitted asUser), so no text a
82// worker, GitHub or a PR controls may reach it: every line is rebuilt from these, and anything else is held back.
83const NAME = '[a-z][a-z0-9_-]{0,31}' // as paths.NAME_RE
84const STATE = '(?:working|idle|done|blocked|exited|starting|unknown)'
85const REPORT = '(?:DONE|DONE_WITH_CONCERNS|BLOCKED|NEEDS_CONTEXT)'
86const CHECK = '(?:none|pending|success|failure)'
87const EXIT = '(?:code -?\\d{1,3}|signal SIG[A-Z0-9]{1,10})'
88
89/**
90 * The dashboard's doorbell: the human answered there, and `athena ask queue` holds the answers up to this seq. It
91 * carries no text of theirs, and `dash-queue` is no worker's name (athena spawn refuses it).
92 */
93const DOORBELL_RE = 'dash-queue \\d{1,9}'
94const DOORBELL = new RegExp(`^${DOORBELL_RE}$`)
95
96/**
97 * The Slack doorbell: new messages in a channel or DM the watch polls, by its id and a count (` dm` for a DM or group
98 * DM), never their text or who wrote them. `slack-new` is no worker's name either (athena spawn refuses it).
99 */
100const SLACK_RE = 'slack-new ([A-Z][A-Z0-9]{5,20}) (\\d{1,6})( dm)?'
101const SLACK = new RegExp(`^${SLACK_RE}$`)
102const SLACK_MAX = 999999
103
104/** A CI workflow's name in the words a wake may carry: runs of anything else fold to `-`, at most 32 characters. */
105export function safeWorkflow(name: string): string {
106 const safe = name.replace(/[^A-Za-z0-9._-]+/g, '-').replace(/^-+/, '').slice(0, 32).replace(/-+$/, '')
107 return safe || 'workflow'
108}
109
110/** Lines allowed as they are, each regex anchored. */
111const EXACT = [
112 `${NAME} report ${REPORT}`,
113 `${NAME} state ${STATE}->${STATE}(?: \\(startup prompt\\))?`,
114 `${NAME} pr (?:merged|closed|open)`,
115 `${NAME} pr conflicts #\\d{1,7}`,
116 `${NAME} checks ${CHECK}->${CHECK}`,
117 `${NAME} review (?:CHANGES_REQUESTED|APPROVED|REVIEW_REQUIRED)`,
118 `${NAME} review comments \\+\\d{1,4}`,
119 `[A-Za-z0-9._-]{1,64} main moved [0-9a-f]{7}\\.\\.[0-9a-f]{7}`,
120 `${NAME} gone from the ledger`,
121 `${NAME} new worker \\(${STATE}(?:, startup prompt)?\\)`,
122 `${NAME} goal sent`,
123 `watch error: exited \\(${EXIT}\\); restarting`,
124 DOORBELL_RE,
125 SLACK_RE,
126].map(r => new RegExp(`^${r}$`))
127
128/** Lines that carry free text, reduced to their allowlisted part. */
129const REDUCED: [RegExp, (m: RegExpExecArray) => string][] = [
130 [new RegExp(`^(${NAME}) pr opened https://github\\.com/[\\w.-]+/[\\w.-]+/pull/(\\d{1,7})$`), m => `${m[1]} pr opened #${m[2]}`],
131 [
132 /^stack [\w.-]+\/[\w.-]+#(\d{1,7}): \S.* -> (OPEN|MERGED|CLOSED) base=\S+(?: DRAFT)? (MERGEABLE|CONFLICTING|UNKNOWN)$/,
133 m => (m[2] === 'OPEN' ? `stack #${m[1]}: OPEN ${m[3]}` : `stack #${m[1]}: ${m[2]}`),
134 ],
135 [
136 /^([A-Za-z0-9._-]{1,64}) main ci failed (.{1,200}?) (\d{1,12})( \(attempt \d{1,3}\))?$/,
137 m => `${m[1]} main ci failed ${safeWorkflow(m[2]!)} ${m[3]}${m[4] ?? ''}`,
138 ],
139 [new RegExp(`^(${NAME}) goal not sent: .+$`), m => `${m[1]} goal not sent`],
140 [
141 new RegExp(`^(${NAME}) goal unconfirmed \\(.*\\): check its pane, do not resend blindly$`),
142 m => `${m[1]} goal unconfirmed: check its pane, do not resend blindly`,
143 ],
144 [new RegExp(`^watch error: exited \\((${EXIT})\\): .*; restarting$`), m => `watch error: exited (${m[1]}); restarting`],
145 [/^watch error\b.*$/, () => 'watch error (athena status)'],
146]
147
148/** What a wake says in place of a line it may not carry, so HQ still looks. */
149export const HELD = 'a watch line not shown here (athena status)'
150
151/** A watch line in the words a wake may carry, or undefined when it holds anything else. */
152export function allowLine(line: string): string | undefined {
153 if (/[\r\n]/.test(line)) return undefined
154 if (EXACT.some(r => r.test(line))) return line
155 for (const [r, rebuild] of REDUCED) {
156 const m = r.exec(line)
157 if (m) return rebuild(m)
158 }
159 return undefined
160}
161
162export type Timer = { cancel: () => void }
163
164export type WakerOptions = {
165 /** Submits one prompt; resolves once its turn has started (or it was refused). */
166 submit: (text: string) => Promise<unknown>
167 after: (ms: number, fn: () => void) => Timer
168 now: () => number
169 /** The `HH:MM` a prompt is stamped with. */
170 stamp?: () => string
171 /** Lines arriving within this window go out as one prompt. */
172 batchMs?: number
173 /** A line told within this window is not told again: the same worker and event wakes HQ once. */
174 repeatMs?: number
175 /** A Slack channel (not a DM) wakes HQ at most once in this window; its rings meanwhile add up into the next. */
176 slackMs?: number
177 /** What was told, and when, from before a reload or an HQ restart. */
178 sent?: Readonly<Record<string, number>>
179 /** Called with what was told in the window each time it changes, to keep it across a reload. */
180 remember?: (sent: Record<string, number>) => void
181}
182
183/**
184 * Holds actionable lines and submits them as one prompt: after a short quiet window, never while HQ runs a turn
185 * (they wait for its end, then go out together), and never while an earlier submit is still on its way. Each line
186 * is rebuilt from allowLine's words first (one it cannot rebuild goes out as HELD); a line already told in the last
187 * ten minutes is dropped, so a worker that ends blocked twice wakes HQ once.
188 */
189export class Waker {
190 private pending: string[] = []
191 /** What each pending line is remembered by: the line, or a held line's own raw text. */
192 private keys: string[] = []
193 private timer: Timer | undefined
194 private isBusy = false
195 private isSending = false
196 private told: Map<string, number>
197 /** Per Slack channel waiting out its window: the count rung meanwhile, and the timer that lets it go. */
198 private held = new Map<string, { count: number; timer: Timer }>()
199 private readonly batchMs: number
200 private readonly repeatMs: number
201 private readonly slackMs: number
202
203 constructor(private readonly opts: WakerOptions) {
204 this.batchMs = opts.batchMs ?? 3000
205 this.repeatMs = opts.repeatMs ?? 10 * 60 * 1000
206 this.slackMs = opts.slackMs ?? 5 * 60 * 1000
207 this.told = new Map(Object.entries(opts.sent ?? {}))
208 }
209
210 add(raw: string): void {
211 const safe = allowLine(raw)
212 if (safe && (DOORBELL.test(safe) || SLACK.test(safe))) {
213 // A doorbell's ring is always new (the dashboard's seq only grows; Slack's are new messages, and the watch
214 // keeps what it counted across restarts), so the ten-minute memory never drops one. A DM wakes HQ at once; a
215 // channel at most once in slackMs, the rest of its count held for its next wake.
216 const slack = SLACK.exec(safe)
217 if (slack && !slack[3]) {
218 const [, channel, count] = slack
219 const last = this.told.get(`slack ${channel}`)
220 const wait = last === undefined ? 0 : last + this.slackMs - this.opts.now()
221 if (wait > 0) {
222 this.hold(channel!, Number(count), wait)
223 return
224 }
225 if (!this.keys.includes(`slack ${channel}`)) this.keys.push(`slack ${channel}`)
226 }
227 this.place(safe)
228 this.schedule()
229 return
230 }
231 const line = safe ?? HELD
232 // A held line is remembered by its raw text (kept here, never sent), so one unknown line is told once too.
233 const key = safe ?? `held:${raw.slice(0, 500)}`
234 const last = this.told.get(key)
235 if (last !== undefined && this.opts.now() - last < this.repeatMs) return
236 if (!this.keys.includes(key)) this.keys.push(key)
237 this.place(line)
238 this.schedule()
239 }
240
241 private hold(channel: string, count: number, wait: number): void {
242 const held = this.held.get(channel)
243 if (held) {
244 held.count = Math.min(held.count + count, SLACK_MAX)
245 return
246 }
247 const timer = this.opts.after(wait, () => {
248 const done = this.held.get(channel)
249 this.held.delete(channel)
250 if (done) this.add(`slack-new ${channel} ${done.count}`)
251 })
252 this.held.set(channel, { count, timer })
253 }
254
255 /**
256 * Puts a line in the pending batch. The dashboard's ring goes first and the newest stands for the rest, since HQ
257 * reads the whole queue; Slack's rings go right after it, one per channel, their counts added up; any other line
258 * goes at the end, once.
259 */
260 private place(line: string): void {
261 if (DOORBELL.test(line)) {
262 this.pending = [line, ...this.pending.filter(l => !DOORBELL.test(l))]
263 return
264 }
265 const slack = SLACK.exec(line)
266 if (slack) {
267 const [, channel, count, dm] = slack
268 const same = this.pending.find(l => SLACK.exec(l)?.[1] === channel)
269 const total = Math.min(Number(count) + (same ? Number(SLACK.exec(same)![2]) : 0), SLACK_MAX)
270 const merged = `slack-new ${channel} ${total}${dm ?? ''}`
271 if (same) {
272 this.pending = this.pending.map(l => (l === same ? merged : l))
273 return
274 }
275 const at = this.pending.findIndex(l => !DOORBELL.test(l) && !SLACK.test(l))
276 this.pending = at < 0 ? [...this.pending, merged] : [...this.pending.slice(0, at), merged, ...this.pending.slice(at)]
277 return
278 }
279 if (!this.pending.includes(line)) this.pending.push(line)
280 }
281
282 /**
283 * A routine line: the worker (or stacked PR) it names moved on, so what was told about it may be told again. A
284 * check that fails, goes pending after a fix and fails again wakes HQ twice; a replay with no change between, once.
285 */
286 settle(raw: string): void {
287 const name = new RegExp(`^(${NAME}) `).exec(raw)?.[1]
288 const pr = /^stack [\w.-]+\/[\w.-]+#(\d{1,7}):/.exec(raw)?.[1]
289 const prefix = name ? `${name} ` : pr ? `stack #${pr}:` : undefined
290 if (!prefix) return
291 const before = this.told.size
292 for (const key of [...this.told.keys()]) if (key.startsWith(prefix)) this.told.delete(key)
293 if (this.told.size !== before) this.opts.remember?.(Object.fromEntries(this.told))
294 }
295
296 turnStarted(): void {
297 this.isBusy = true
298 }
299
300 turnEnded(): void {
301 this.isBusy = false
302 if (this.pending.length) this.schedule()
303 }
304
305 get waiting(): readonly string[] {
306 return this.pending
307 }
308
309 private schedule(): void {
310 if (this.timer) return
311 this.timer = this.opts.after(this.batchMs, () => {
312 this.timer = undefined
313 this.flush()
314 })
315 }
316
317 private mark(keys: readonly string[], at: number | undefined): void {
318 const now = this.opts.now()
319 for (const [key, when] of this.told) if (now - when >= this.repeatMs) this.told.delete(key)
320 for (const key of keys) {
321 if (at === undefined) this.told.delete(key)
322 else this.told.set(key, at)
323 }
324 this.opts.remember?.(Object.fromEntries(this.told))
325 }
326
327 private flush(): void {
328 if (this.isBusy || this.isSending || !this.pending.length) return
329 const lines = this.pending
330 const keys = this.keys
331 this.pending = []
332 this.keys = []
333 this.isSending = true
334 // Told from the moment it is sent: a repeat arriving while the submit waits for its turn is dropped too.
335 this.mark(keys, this.opts.now())
336 this.opts
337 .submit(promptText(lines, this.opts.stamp?.()))
338 .catch(() => {
339 this.mark(keys, undefined)
340 const later = this.pending
341 this.pending = []
342 for (const l of [...lines, ...later]) this.place(l)
343 // Not told after all: what a channel held back since goes with it now.
344 for (const key of keys) {
345 const channel = key.startsWith('slack ') ? key.slice(6) : undefined
346 const held = channel ? this.held.get(channel) : undefined
347 if (!held) continue
348 held.timer.cancel()
349 this.held.delete(channel!)
350 this.place(`slack-new ${channel} ${held.count}`)
351 }
352 this.keys = [...keys, ...this.keys.filter(k => !keys.includes(k))]
353 })
354 .finally(() => {
355 this.isSending = false
356 if (this.pending.length) this.schedule()
357 })
358 }
359}
360
361/** The status line's parts as `athena watch --tagged` reports them, most urgent first. */
362export type Board = {
363 /** `<worker> blocked`, `<worker> needs context`, `<worker> exited` */
364 needs: string[]
365 /** `#n` per PR waiting on the human */
366 prs: string[]
367 /** [state, workers] per active state: working, idle, starting, in review */
368 active: [string, string[]][]
369 done: string[]
370 /** `owner/repo` per PR number the watch knows from the ledger: a live worker's repo, or a stack's. */
371 links?: Record<string, string>
372 /** `owner/repo` per live worker, from its repo's origin remote. */
373 repos?: Record<string, string>
374 /** The local dashboard's URL while it runs, as `athena dash show` reads it. */
375 dash?: string
376}
377
378export type Segment = { text: string; tone: 'needs' | 'active' | 'done' | 'dash'; href?: string }
379
380/** One run of drawn text: plain, or a masked link to `href`. */
381export type Part = { text: string; href?: string }
382
383const SLUG = /^[A-Za-z0-9_.-]{1,100}\/[A-Za-z0-9_.-]{1,100}$/
384
385/**
386 * A PR's page, built only from an `owner/repo` the watch read from the ledger (never from worker, GitHub or PR
387 * text) and a number. Undefined when either is not one, so the PR draws as plain `#n`.
388 */
389export function prUrl(repo: string | undefined, n: string | number): string | undefined {
390 const num = String(n)
391 if (!repo || !SLUG.test(repo) || !/^\d{1,7}$/.test(num)) return undefined
392 return `https://github.com/${repo}/pull/${num}`
393}
394
395/** `owner/repo` of a GitHub remote, https or ssh; undefined for any other remote. */
396export function repoSlug(remote: string): string | undefined {
397 const m = /^(?:https:\/\/github\.com\/|git@github\.com:|ssh:\/\/git@github\.com\/)([\w.-]+\/[\w.-]+?)(?:\.git)?\/?$/.exec(remote.trim())
398 return m && SLUG.test(m[1]!) ? m[1] : undefined
399}
400
401/** A drawn text split around each `#n`, each with its link where `href` knows one. */
402export function linkParts(text: string, href: (n: string) => string | undefined): Part[] {
403 const parts: Part[] = []
404 let at = 0
405 for (const m of text.matchAll(/#(\d{1,7})\b/g)) {
406 if (m.index! > at) parts.push({ text: text.slice(at, m.index) })
407 const url = href(m[1]!)
408 parts.push(url ? { text: m[0], href: url } : { text: m[0] })
409 at = m.index! + m[0].length
410 }
411 if (at < text.length || !parts.length) parts.push({ text: text.slice(at) })
412 return parts
413}
414
415/** A PR number's link on the band: by the board's links, plain when it knows none. */
416export function boardHref(b: Board | undefined): (n: string) => string | undefined {
417 return n => prUrl(b?.links?.[n], n)
418}
419
420/** A wake event's parts: a PR it names links by the worker the event is about, else by the board's links. */
421export function eventParts(event: string, b: Board | undefined): Part[] {
422 // `stack #n:` and `watch error` lines name no worker, whatever workers are called.
423 const worker = /^(?!stack #|watch error)([a-z][a-z0-9_-]{0,31}) /.exec(event)?.[1]
424 const repo = worker ? b?.repos?.[worker] : undefined
425 return linkParts(event, n => prUrl(repo ?? b?.links?.[n], n))
426}
427
428/** FORCE_HYPERLINK, then the terminals Claude Code 2.1.296 draws OSC 8 links for (else it adds the raw URL). */
429const LINKING = ['ghostty', 'Hyper', 'kitty', 'alacritty', 'iTerm.app', 'iTerm2', 'WarpTerminal', 'WezTerm', 'vscode']
430
431/**
432 * Whether the engine draws a `Link` as a masked OSC 8 span: a mirror of Claude Code's own check, read from the same
433 * variables. Where it does not (herdr unless FORCE_HYPERLINK is set), the mod draws plain text instead of the
434 * engine's `label URL` fallback. A wrong answer costs little: false draws plain text, true the engine's fallback.
435 */
436export function hyperlinks(env: Readonly<Record<string, string | undefined>>): boolean {
437 const force = env.FORCE_HYPERLINK
438 if (force) return parseInt(force, 10) !== 0 // empty counts as unset, as in the engine
439 const program = env.TERM_PROGRAM
440 if (program && LINKING.includes(program)) return true
441 if (env.LC_TERMINAL && LINKING.includes(env.LC_TERMINAL)) return true
442 if (env.TERM?.includes('kitty') || env.TERM === 'alacritty') return true
443 if (env.TERMINAL_EMULATOR === 'JetBrains-JediTerm') return true
444 if (env.WT_SESSION && program !== 'tmux' && !env.TMUX) return true
445 if (program === 'tmux') {
446 const [major, minor] = (env.TERM_PROGRAM_VERSION ?? '').split('.').map(x => parseInt(x, 10))
447 if (major! > 3 || (major === 3 && minor! >= 4)) return true
448 }
449 if (env.VTE_VERSION && env.VTE_VERSION !== '0.50.0') {
450 const v = env.VTE_VERSION
451 const minor = /^\d{3,4}$/.test(v) ? parseInt(v.slice(0, -2), 10) : parseInt(v.split('.')[1] ?? '', 10)
452 const major = /^\d{3,4}$/.test(v) ? 0 : parseInt(v.split('.')[0] ?? '', 10)
453 if (major > 0 || minor >= 50) return true
454 }
455 return false
456}
457
458/** The variables `hyperlinks` reads. */
459export const LINK_ENV = [
460 'FORCE_HYPERLINK',
461 'TERM_PROGRAM',
462 'TERM_PROGRAM_VERSION',
463 'LC_TERMINAL',
464 'TERM',
465 'TERMINAL_EMULATOR',
466 'WT_SESSION',
467 'TMUX',
468 'VTE_VERSION',
469] as const
470
471export const DASH_LINK = 'open dash →'
472
473/** Width of the ` · ` between segments. */
474export const GAP = 3
475
476function needsCounted(b: Board): string[] {
477 const kinds = new Map<string, number>()
478 for (const item of b.needs) {
479 const kind = item.slice(item.indexOf(' ') + 1)
480 kinds.set(kind, (kinds.get(kind) ?? 0) + 1)
481 }
482 return [...kinds].map(([kind, n]) => `${n} ${kind}`)
483}
484
485function prsNamed(b: Board): string[] {
486 return b.prs.length ? [`${b.prs.join(' ')} ${b.prs.length === 1 ? 'needs' : 'need'} you`] : []
487}
488
489function prsCounted(b: Board): string[] {
490 return b.prs.length ? [b.prs.length === 1 ? '1 PR needs you' : `${b.prs.length} PRs need you`] : []
491}
492
493/**
494 * The status line, laid out for `columns`: what needs the human, then the active workers, then the done ones,
495 * each named while the line fits, then the dash link. As it narrows, the dash link goes first, then the done ones
496 * fold to a count, then the active ones, then the PRs, then the needs; whatever is still too wide is cut by the drawing.
497 */
498export function fitStatus(b: Board, columns: number): Segment[] {
499 const dash: Segment[] = b.dash ? [{ text: DASH_LINK, tone: 'dash', href: b.dash }] : []
500 if (!b.needs.length && !b.prs.length && !b.active.length && !b.done.length) {
501 return [{ text: 'no live workers', tone: 'active' }, ...dash]
502 }
503 const activeNamed = b.active.map(([state, names]) => `${names.join(', ')} ${state}`)
504 const activeCounted = b.active.map(([state, names]) => `${names.length} ${state}`)
505 const doneNamed = b.done.length ? [`✓ ${b.done.join(', ')} done`] : []
506 const doneCounted = b.done.length ? [`✓ ${b.done.length} done`] : []
507 const levels: [string[], string[], string[], string[]][] = [
508 [b.needs, prsNamed(b), activeNamed, doneNamed],
509 [b.needs, prsNamed(b), activeNamed, doneCounted],
510 [b.needs, prsNamed(b), activeCounted, doneCounted],
511 [b.needs, prsCounted(b), activeCounted, doneCounted],
512 [needsCounted(b), prsCounted(b), activeCounted, doneCounted],
513 ]
514 // The dash link always ends the line.
515 const segments = ([needs, prs, active, done]: [string[], string[], string[], string[]]): Segment[] => {
516 const urgent = [...needs, ...prs].map(text => ({ text, tone: 'needs' as const }))
517 const rest = [...active.map(text => ({ text, tone: 'active' as const })), ...done.map(text => ({ text, tone: 'done' as const }))]
518 return [...urgent, ...rest, ...dash]
519 }
520 const width = (s: Segment[]) => s.reduce((n, x) => n + x.text.length, 0) + GAP * (s.length - 1)
521 // Names and `#n` outrank the dash link: each fold is tried with it, then without it, before folding further.
522 for (const level of levels) {
523 const s = segments(level)
524 if (width(s) <= columns) return s
525 const bare = s.filter(x => x.tone !== 'dash')
526 if (bare.length < s.length && width(bare) <= columns) return bare
527 }
528 return segments(levels[levels.length - 1]!).filter(s => s.tone !== 'dash')
529}
530
531/** Reads a `board` record, or undefined when it is not one. */
532export function readBoard(value: unknown): Board | undefined {
533 if (!value || typeof value !== 'object') return undefined
534 const v = value as Record<string, unknown>
535 const strings = (x: unknown) => (Array.isArray(x) ? x.filter((s): s is string => typeof s === 'string') : [])
536 const active = Array.isArray(v.active)
537 ? v.active.flatMap(g => (Array.isArray(g) && typeof g[0] === 'string' ? [[g[0], strings(g[1])] as [string, string[]]] : []))
538 : []
539 const map = (x: unknown, key: RegExp) =>
540 x && typeof x === 'object' && !Array.isArray(x)
541 ? Object.fromEntries(Object.entries(x).filter((kv): kv is [string, string] => key.test(kv[0]) && typeof kv[1] === 'string' && SLUG.test(kv[1])))
542 : {}
543 const dash = typeof v.dash === 'string' && /^http:\/\/(?:127\.0\.0\.1|localhost):\d{1,5}\/$/.test(v.dash) ? v.dash : undefined
544 return {
545 needs: strings(v.needs),
546 prs: strings(v.prs),
547 active,
548 done: strings(v.done),
549 links: map(v.links, /^\d{1,7}$/),
550 repos: map(v.repos, /^[a-z][a-z0-9_-]{0,31}$/),
551 dash,
552 }
553}
554
555/** How long to wait before starting the watch again: doubles per quick exit, from 5 s up to a minute. */
556export function restartDelay(quickExits: number): number {
557 return Math.min(5000 * 2 ** Math.max(0, quickExits - 1), 60000)
558}
559
560/** A Monitor task's notification row: `Monitor event: ...`, `Monitor "<name>" stream ended`, its expiry. */
561export function isMonitorNotice(text: string): boolean {
562 return /^\[?Monitor (event:|")/.test(text) || /^\[?Monitor expired\b/.test(text)
563}
564
565/** A worker's ATHENA-REPORT, as its final message carries it. */
566export type Report = {
567 status: string
568 pr?: string
569 head?: string
570 verify?: string
571 decisions: string[]
572 concerns: string[]
573}
574
575const REPORT_LINE = /^`?ATHENA-REPORT\s+(\{.*\})`?$/
576const FENCE = /^```\w*$/
577
578/**
579 * The ATHENA-REPORT line of a message, parsed, with the text before and after it (a code fence around the line
580 * goes with it). Undefined when there is none or its JSON does not parse to an object with a status.
581 */
582export function splitReport(text: string): { before: string; after: string; report: Report } | undefined {
583 const lines = text.split('\n')
584 for (let i = lines.length - 1; i >= 0; i--) {
585 const m = REPORT_LINE.exec(lines[i]!.trim())
586 if (!m) continue
587 let value: unknown
588 try {
589 value = JSON.parse(m[1]!)
590 } catch {
591 return undefined
592 }
593 if (!value || typeof value !== 'object' || Array.isArray(value)) return undefined
594 const v = value as Record<string, unknown>
595 if (typeof v.status !== 'string') return undefined
596 const str = (x: unknown) => (typeof x === 'string' ? x : undefined)
597 const list = (x: unknown) => (Array.isArray(x) ? x.filter((s): s is string => typeof s === 'string') : [])
598 const fenced = i > 0 && i < lines.length - 1 && FENCE.test(lines[i - 1]!.trim()) && lines[i + 1]!.trim() === '```'
599 const from = fenced ? i - 1 : i
600 const to = fenced ? i + 2 : i + 1
601 return {
602 before: lines.slice(0, from).join('\n').trim(),
603 after: lines.slice(to).join('\n').trim(),
604 report: {
605 status: v.status,
606 pr: str(v.pr),
607 head: str(v.head),
608 verify: str(v.verify),
609 decisions: list(v.decisions),
610 concerns: list(v.concerns),
611 },
612 }
613 }
614 return undefined
615}
616
617/** Needs context: an orange between the theme's warning and error. */
618export const ORANGE = '#E8913A'
619
620const LOOKS: Record<string, { text: string; color: string }> = {
621 DONE: { text: 'Done', color: 'success' },
622 DONE_WITH_CONCERNS: { text: 'Done with concerns', color: 'warning' },
623 BLOCKED: { text: 'Blocked', color: 'error' },
624 NEEDS_CONTEXT: { text: 'Needs context', color: ORANGE },
625}
626
627export type ReportView = {
628 status: { text: string; color: string | undefined }
629 pr: { href: string | undefined; label: string } | undefined
630 head: string | undefined
631 verify: string | undefined
632 decisions: string[]
633 concerns: string[]
634}
635
636/** C0 and C1 controls, and the bidi and zero-width characters that can make a line say one thing and show another. */
637const UNSAFE = /[\u0000-\u001f\u007f-\u009f\u200b-\u200f\u202a-\u202e\u2066-\u2069\ufeff]/g
638
639/** A report field as it may be drawn: controls, bidi and zero-width characters removed. */
640export function clean(text: string): string {
641 return text.replace(UNSAFE, '')
642}
643
644/** The most a report's decision or concern draws, as worker.md asks of each entry. */
645export const REPORT_ITEM_MAX = 80
646
647/**
648 * A decision or concern as one line, so an old verbose report still draws compactly: line breaks and runs of
649 * spaces fold to one space, and past REPORT_ITEM_MAX characters it is cut, the last an ellipsis.
650 */
651export function oneLine(text: string, max = REPORT_ITEM_MAX): string {
652 const s = clean(text.replace(/[\t\n\v\f\r ]+/g, ' ')).trim()
653 const chars = [...s]
654 return chars.length <= max ? s : chars.slice(0, max - 1).join('').trimEnd() + '…'
655}
656
657/**
658 * What the completion block shows: the status in its word and colour, the PR as `#n` with its head, the rest. The
659 * PR links to its page in `repo`, the worker's own repo from its origin remote: the report's URL gives only the
660 * number, and a URL into another repo draws as plain `owner/repo#n`.
661 */
662export function reportView(raw: Report, repo?: string): ReportView {
663 const r: Report = {
664 status: clean(raw.status),
665 pr: raw.pr === undefined ? undefined : clean(raw.pr),
666 head: raw.head === undefined ? undefined : clean(raw.head),
667 verify: raw.verify === undefined ? undefined : clean(raw.verify),
668 decisions: raw.decisions.map(s => oneLine(s)),
669 concerns: raw.concerns.map(s => oneLine(s)),
670 }
671 const look = LOOKS[r.status.toUpperCase()] ?? { text: r.status, color: undefined }
672 return {
673 status: look,
674 pr: r.pr ? prView(r.pr, repo) : undefined,
675 head: r.head ? r.head.slice(0, 7) : undefined,
676 verify: r.verify || undefined,
677 decisions: r.decisions,
678 concerns: r.concerns,
679 }
680}
681
682function prView(pr: string, repo: string | undefined): { href: string | undefined; label: string } {
683 const url = /^https:\/\/github\.com\/([\w.-]+\/[\w.-]+)\/pull\/(\d{1,7})\/?$/.exec(pr)
684 const n = url?.[2] ?? /^#?(\d{1,7})$/.exec(pr)?.[1]
685 if (!n) return { href: undefined, label: pr }
686 if (url && url[1]!.toLowerCase() !== repo?.toLowerCase()) {
687 return { href: undefined, label: repo ? `${url[1]}#${n}` : `#${n}` }
688 }
689 return { href: prUrl(repo, n), label: `#${n}` }
690}
691