phone-a-friend, made legible: every review as a thread of rounds with its verdict and findings, live progress while a reviewer works, and the agent sessions…

A Claude Code mod. When Claude asks another model for a second opinion through phone-a-friend, this shows what was asked, what the reviewer is doing, and what it said, in the reviewer's own words.
It is a separate, optional plugin in the phone-a-friend marketplace:
/plugin marketplace add freibergergarcia/phone-a-friend # skip if already added
/plugin install paf-tasks@phone-a-friend-marketplace
/reload-plugins
Requirements:
| Needs | Why |
|---|---|
| Claude Code 2.1.287 or newer | mods are on by default from that version (claude --version) |
phone-a-friend 4.5.0 or newer on PATH | the panel reads phone-a-friend task list --json --repo <worktree> |
| herdr (optional) | the Agents tab lists herdr's agent sessions; without herdr the tab is hidden |
On a Claude Code without mods, this plugin fails to load (Claude Code reports an invalid manifest) and the phone-a-friend plugin keeps working. A phone-a-friend CLI that is missing or too old is named in the panel, with the command that fixes it.
Update: /plugin marketplace update phone-a-friend-marketplace, then /plugin update paf-tasks@phone-a-friend-marketplace. Its version follows phone-a-friend releases. Remove: /plugin uninstall paf-tasks@phone-a-friend-marketplace, or disable it in /plugin (Installed tab). If /plugin install cannot find paf-tasks, the marketplace is probably registered from an old folder: Claude Code keeps the first source of a marketplace name. phone-a-friend doctor says so, and phone-a-friend plugin update --claude (add --force-marketplace-sync when doctor asks for it) repoints it and reinstalls what you had.
When phone-a-friend plugin install --claude registered the marketplace, phone-a-friend plugin uninstall --claude removes the panel with it; a marketplace you added yourself is left alone, panel included, unless you add --purge-marketplace.
In the conversation. A phone-a-friend call is drawn as a call instead of "Ran 1 shell command":
⠹ codex · Round 3 · 00:42 · usually 1m 54s · git show 4f2a91c ✗ codex · Round 3 · 1 blocker · 1 important · 1m 52s blocker README.md:600 The recovery note is wrong for pi. ... important src/cli.ts:344 installPath() still documents copy mode ...
ctrl+o still shows the command and its raw output, with the same line under it. While the turn waits, its spinner says Waiting on codex….
In the panel (/paf). Everything asked about one branch is one thread, and the thread for the branch checked out in this worktree comes first (on a branch with no reviews yet, the panel says so):
✗ ✓ ✗ ✗ ✓): whether the review is converging;+N more. w adds the repository's other worktrees.2 switches to Agents (with herdr): the sessions herdr runs, grouped by what needs you, with what each one's reviewer last said. Enter jumps to a session.
Above the prompt, one line while a reviewer works and the panel is not on screen. A toast when a round finishes.
The panel opens by itself when a call in this worktree is running or has just finished, not for older reviews, and stays closed once you close it, until you type /paf.
/paf, a click, or ctrl+x tab)| Key | Does |
|---|---|
j / k | older / newer round |
m | more / fewer rounds |
f | full answer / less |
s | send the round's findings to Claude |
c | copy the reviewer's answer |
b | next branch |
w | all worktrees / this worktree only (the default) |
r | refresh now |
1 / 2 | Reviews / Agents |
/paf agents, /paf reviews, /paf close.
| Mark | Means |
|---|---|
✓ green | ship: the reviewer said so, or had nothing to fix |
✗ red | findings, at least one blocker |
✗ yellow | findings, none a blocker |
· gray | an answer that is not a review |
! red | the call failed (the row says why, the round says what to do) |
| spinner | still running |
A verdict the reviewer declared ("VERDICT: ship", "Ship.") is shown in its word. One read off the answer is shown as the facts: 3 findings, no findings.
/plugin → paf-tasks, or /config)| Option | Values | Default |
|---|---|---|
panel | both, calls, agents | both |
auto_open | open the panel once there are reviews to show | true |
band | auto (only while the panel is hidden), always, never | auto |
transcript | draw calls in the conversation | true |
git worktree list for the session's repository, git branch --show-current for this worktree, then phone-a-friend task list --json --repo <worktree> for this worktree, and for the others while the Agents tab or the all-worktrees view needs them (up to 16; the panel says when more are left out), and task show <id> --json for a running task. It never lists the whole task store, so another repository's records are not read. Before and after each listing, git rev-parse --git-common-dir confirms the path still belongs to this repository; outside git, or when that cannot be confirmed, nothing is listed and the panel is cleared.herdr agent list and herdr workspace list for the Agents tab (once a minute when herdr is not installed). Session titles are drawn on screen only; nothing from them is sent to Claude.It writes nothing outside Claude Code's own state, and "Send to Claude" is the only thing that puts text into the conversation.
claude --plugin-dir mods/paf-tasks # load this checkout's copy for one session
claude plugin validate --strict mods/paf-tasks
claude plugin test mods/paf-tasks
Loading the folder makes Claude Code write its type declarations to .claude-plugin/types/ here (ignored by git); tsc -p mods/paf-tasks then type-checks the mod. CI runs the validate and test commands with a pinned Claude Code version.
hooks/register.tsx 1588 lines1import { atom, read, update } from 'claude-code'
2import type { ElementTable, Register } from 'claude-code'
3
4import type { HerdrAgent, PafCallStart, PafScope, PafTask, PafView, TimelineEntry } from '../types'
5import {
6 AGENT_LOOK,
7 SEVERITY_COLOR,
8 age,
9 agentGlyph,
10 cleanActivity,
11 clock,
12 durationOf,
13 fit,
14 lede,
15 lookOf,
16 oneLine,
17 parseRelay,
18 parseWorktrees,
19 plain,
20 rootOf,
21 relayKey,
22 roundOfCall,
23 scopeText,
24 slim,
25 slimAgent,
26 slimWorkspace,
27 spin,
28 threadSpan,
29 took,
30 verdictText,
31} from './format'
32import type { Binding, CallState, HerdrWorkspace, RelayCall } from './format'
33import { handoff, isActive, isSelf, parseTime, placeOf, questionOf, roundOf, threadKeyOf, toAgentCards, toThreads, usualMs } from './model'
34import type { AgentCard, AgentState, Here, Round, Thread } from './model'
35
36const PANE = 'paf-tasks'
37const TITLE = 'phone-a-friend'
38const TICK_MS = 2_000
39// While nothing runs, poll PaF on every 8th tick (16 s) so a call started from
40// another terminal still shows up. herdr is polled every tick (two ~6 ms calls).
41const IDLE_EVERY = 8
42// After this session runs a phone-a-friend command, poll every tick for a while.
43const HOT_MS = 90_000
44// How long a finished call stays in the band.
45const RECENT_MS = 3 * 60_000
46const KEPT_TASKS = 80
47const KEPT_RESULT_CHARS = 12_000
48const WORKTREES_TTL_MS = 60_000
49const MAX_WORKTREES = 16
50// Another branch with nothing newer than this is folded under "+N more".
51const QUIET_BRANCH_MS = 7 * 24 * 60 * 60_000
52// How often another worktree of this repository is asked about: one with a
53// recent answer every 15 s, a quiet one once a minute. (One with a reviewer
54// at work is asked on every tick, like this one.) A few at a time, each given
55// 8 s, so a slow one holds up neither the others nor this worktree.
56const WARM_EVERY_MS = 15_000
57const COLD_EVERY_MS = 60_000
58const WARM_FOR_MS = 60 * 60_000
59const OTHERS_AT_ONCE = 4
60const OTHERS_TIMEOUT_MS = 8_000
61const HERDR_RETRY_MS = 60_000
62const TIMELINE_STEPS = 5
63const FOLDED_ROUNDS = 7
64const FOLDED_FINDINGS = 6
65const FOLDED_BRANCHES = 8
66const FOLDED_PROSE_CHARS = 900
67const FULL_PROSE_CHARS = 9_000
68// In the conversation, a round shows this many findings, one line each.
69const TRANSCRIPT_FINDINGS = 6
70const TRANSCRIPT_PROSE_CHARS = 320
71const KEPT_CALL_STARTS = 100
72
73const tasksAtom = atom({ plugin: 'paf-tasks', key: 'tasks' } as const, [])
74const timelineAtom = atom({ plugin: 'paf-tasks', key: 'timeline' } as const, {})
75const frameAtom = atom({ plugin: 'paf-tasks', key: 'frame' } as const, 0)
76const polledAtom = atom({ plugin: 'paf-tasks', key: 'polledAt' } as const, 0)
77const problemAtom = atom({ plugin: 'paf-tasks', key: 'problem' } as const, null)
78const threadAtom = atom({ plugin: 'paf-tasks', key: 'thread' } as const, null)
79const roundAtom = atom({ plugin: 'paf-tasks', key: 'round' } as const, null)
80const fullAtom = atom({ plugin: 'paf-tasks', key: 'isFull' } as const, false)
81const allRoundsAtom = atom({ plugin: 'paf-tasks', key: 'isAllRounds' } as const, false)
82const allBranchesAtom = atom({ plugin: 'paf-tasks', key: 'isAllBranches' } as const, false)
83const scopeAtom = atom({ plugin: 'paf-tasks', key: 'scope' } as const, 'repo')
84const agentsAtom = atom({ plugin: 'paf-tasks', key: 'agents' } as const, [])
85const sinceAtom = atom({ plugin: 'paf-tasks', key: 'agentsSince' } as const, {})
86const agentsProblemAtom = atom({ plugin: 'paf-tasks', key: 'agentsProblem' } as const, null)
87const viewAtom = atom({ plugin: 'paf-tasks', key: 'view' } as const, 'calls')
88const waitingAtom = atom({ plugin: 'paf-tasks', key: 'waiting' } as const, [])
89const callStartsAtom = atom({ plugin: 'paf-tasks', key: 'callStarts' } as const, {})
90const hereAtom = atom({ plugin: 'paf-tasks', key: 'here' } as const, null)
91const rootsAtom = atom({ plugin: 'paf-tasks', key: 'roots' } as const, [])
92const skippedAtom = atom({ plugin: 'paf-tasks', key: 'skippedWorktrees' } as const, 0)
93const branchAtom = atom({ plugin: 'paf-tasks', key: 'branch' } as const, null)
94const paneAtom = atom({ plugin: 'paf-tasks', key: 'pane' } as const, null)
95const onScreenAtom = atom({ plugin: 'paf-tasks', key: 'isOnScreen' } as const, false)
96
97type Panel = 'both' | 'calls' | 'agents'
98type Band = 'auto' | 'always' | 'never'
99
100// Bookkeeping of the polling itself. Nothing here is drawn from: what a
101// drawing needs is in $.state, which a hot reload keeps and these do not
102// survive (the cost is one silent refresh before toasts resume).
103let pollNow: ((isWaited?: boolean) => Promise<void>) | null = null
104let inFlight = false
105let othersInFlight = false
106let agentsInFlight = false
107let herdrMissingAt: number | null = null
108let stepsWanted: readonly PafTask[] | null = null
109let stepsLoop: Promise<void> | null = null
110let stepping: Promise<void> = Promise.resolve()
111let commits: Promise<void> = Promise.resolve()
112let ticks = 0
113let hotUntil = 0
114let anyActive = false
115let anyWorking = false
116let waitingCalls = 0
117let primedAt: number | null = null
118let isClosedByPerson = false
119let hasRevealed = false
120let lastTasks = ''
121let lastSteps = ''
122let lastAgents = ''
123let lastOnScreen: boolean | null = null
124const seen = new Map<string, string>()
125const agentStatus = new Map<string, string>()
126// This repository's worktrees (and the git directory they share), and each
127// one's tasks as last listed.
128// `isRepo`: git listed worktrees here; `common` is their shared git directory, null when it could not be read.
129// `skipped`: worktrees past MAX_WORKTREES, which are not asked about.
130type Place = { at: number; cwd: string; here: string; roots: string[]; skipped: number; common: string | null; isRepo: boolean }
131let worktrees: Place | null = null
132const byRoot = new Map<string, PafTask[]>()
133const askedAt = new Map<string, number>()
134let selfPane: string | null = null
135
136// What a refresh needs from the engine. The engine wants every mods API call
137// written out where it happens, so each hook that refreshes builds one of
138// these from its own `$` instead of passing `$` along.
139type Io = {
140 run: (argv: string[], timeoutMs: number) => Promise<{ exitCode: number; stdout: string; stderr: string; isStdoutTruncated?: boolean }>
141 cwd: () => Promise<string>
142 now: () => Promise<number>
143 toast: (text: string) => void
144 scope: () => Promise<PafScope>
145 // Whether the other worktrees are read: for the all-worktrees view, or for
146 // the Agents view, which says what each session's reviewer last did.
147 wantsOthers: () => Promise<boolean>
148 problem: (text: string | null) => Promise<unknown>
149 // `null` leaves a value as it is, so an unchanged list redraws nothing.
150 save: (tasks: PafTask[] | null, timeline: Record<string, TimelineEntry[]> | null, now: number) => Promise<unknown>
151 savePlace: (here: string, roots: string[], skipped: number) => Promise<unknown>
152 // The branch checked out here, null on a detached HEAD.
153 saveBranch: (branch: string | null) => Promise<unknown>
154 // A new call started: show it instead of whatever was selected.
155 follow: () => Promise<unknown>
156 since: () => Promise<Record<string, number>>
157 saveAgents: (agents: HerdrAgent[], since: Record<string, number>, problem: string | null) => Promise<unknown>
158 // Whether the panel is on screen, as the engine has it, and where that is kept.
159 onScreen: () => Promise<boolean>
160 saveOnScreen: (value: boolean) => Promise<unknown>
161 // Opens the panel unasked, where the person's settings allow it.
162 reveal: () => Promise<unknown>
163}
164
165// What changed since the last look: tasks that finished (toasted) and whether
166// a new one started in this worktree. The first look only records.
167function compare(tasks: PafTask[], here: string): { finished: PafTask[]; started: boolean } {
168 const finished: PafTask[] = []
169 let started = false
170 for (const task of tasks) {
171 const before = seen.get(task.id)
172 seen.set(task.id, task.status)
173 if (primedAt === null || before === task.status) continue
174 if (isActive(task)) {
175 // A call in another worktree does not take the panel away from this one.
176 if (before === undefined && task.repoPath === here) started = true
177 continue
178 }
179 const end = parseTime(task.finishedAt)
180 const wasActive = before === 'running' || before === 'queued'
181 if (wasActive || (before === undefined && end !== null && end >= primedAt)) finished.push(task)
182 }
183 // Tasks that left the list long ago need no remembering.
184 if (seen.size > 400) {
185 const listed = new Set(tasks.map(task => task.id))
186 for (const id of seen.keys()) if (!listed.has(id)) seen.delete(id)
187 }
188
189 return { finished, started }
190}
191
192// The last steps of a running task, as seconds since it started.
193function toTimeline(task: PafTask, events: readonly { ts?: string; type?: string; message?: string | null }[]): TimelineEntry[] {
194 const started = parseTime(task.startedAt) ?? 0
195 return events
196 .filter(event => event.message && event.type !== 'started' && event.type !== 'session_linked' && !/^Finished\b/i.test(event.message))
197 .map(event => ({
198 at: Math.max(0, Math.round(((parseTime(event.ts ?? null) ?? started) - started) / 1000)),
199 text: cleanActivity(event.message ?? ''),
200 }))
201 .slice(-TIMELINE_STEPS)
202}
203
204// "✗ codex: 1 blocker · 2 important · Round 3 for #400", and where when it is another worktree.
205function toastOf(task: PafTask): string {
206 const round = roundOf(task)
207 const here = worktrees?.here ?? null
208 const elsewhere = here !== null && task.repoPath !== null && task.repoPath !== here ? ` (${placeOf(task.repoPath).worktree ?? placeOf(task.repoPath).repo})` : ''
209 return `${lookOf(round, 0).glyph} ${task.backend}${elsewhere}: ${verdictText(round)} · ${round.title}`
210}
211
212const reasonOf = (err: unknown): string => oneLine(err instanceof Error ? err.message : String(err))
213// "$.process.run(x) failed to start: ENOENT" is the command not being installed.
214const isNotInstalled = (reason: string): boolean => /failed to start|ENOENT|not found|no such file/i.test(reason)
215
216// This repository's worktrees, as git lists them. PaF is asked about each one
217// by path and never for its whole store, which holds every repository's
218// records: another repository's prompts and answers are not read at all.
219async function worktreesOf(io: Io, cwd: string, now: number): Promise<Place> {
220 if (worktrees === null || worktrees.cwd !== cwd || now - worktrees.at >= WORKTREES_TTL_MS) {
221 let all: string[] = []
222 let roots: string[] = []
223 let common: string | null = null
224 let isListed = false
225 try {
226 const [listed, dir] = await Promise.all([
227 io.run(['git', '-C', cwd, 'worktree', 'list', '--porcelain'], 5_000),
228 io.run(['git', '-C', cwd, 'rev-parse', '--path-format=absolute', '--git-common-dir'], 5_000),
229 ])
230 if (listed.exitCode === 0) {
231 all = parseWorktrees(listed.stdout)
232 roots = all.slice(0, MAX_WORKTREES)
233 isListed = true
234 }
235 if (dir.exitCode === 0) common = dir.stdout.trim() || null
236 } catch {
237 // No git, or not a repository: this directory alone.
238 }
239 const here = rootOf(cwd, roots) ?? cwd
240 if (!roots.includes(here)) roots.unshift(here)
241 const skipped = all.filter(root => !roots.includes(root)).length
242 if (isListed) {
243 // What was listed for a worktree that is gone is let go.
244 for (const root of [...byRoot.keys()]) {
245 if (!roots.includes(root)) {
246 byRoot.delete(root)
247 askedAt.delete(root)
248 }
249 }
250 }
251 // A listing that failed, or a git directory that could not be read, is asked for again in a few seconds.
252 worktrees = { at: isListed && common !== null ? now : now - WORKTREES_TTL_MS + 5_000, cwd, here, roots, skipped, common, isRepo: isListed }
253 }
254 // Written only when it differs from what $.state holds.
255 await io.savePlace(worktrees.here, worktrees.roots, worktrees.skipped)
256 return worktrees
257}
258
259// The branch checked out in this worktree, asked on every refresh so a switch
260// shows up at once. null on a detached HEAD; undefined when git cannot say.
261async function branchOf(io: Io, root: string): Promise<string | null | undefined> {
262 try {
263 const found = await io.run(['git', '-C', root, 'branch', '--show-current'], 5_000)
264 if (found.exitCode !== 0) return undefined
265 return found.stdout.trim() || null
266 } catch {
267 return undefined
268 }
269}
270
271// Whether a listed path is, right now, a worktree of this repository: the
272// list is a minute old at most, and in that minute a worktree can be removed
273// and another repository put in its place. Asked just before PaF is; when it
274// cannot be told, the path is left alone.
275async function isOurs(io: Io, root: string, common: string | null): Promise<boolean> {
276 if (common === null) return false
277 try {
278 const found = await io.run(['git', '-C', root, 'rev-parse', '--path-format=absolute', '--git-common-dir'], 5_000)
279 return found.exitCode === 0 && found.stdout.trim() === common
280 } catch {
281 return false
282 }
283}
284
285// One worktree's tasks, newest first; a string when PaF refused.
286async function listTasks(io: Io, root: string, limit: number, timeoutMs: number): Promise<PafTask[] | string> {
287 const listed = await io.run(['phone-a-friend', 'task', 'list', '--json', '--limit', String(limit), '--repo', root], timeoutMs)
288 if (listed.exitCode !== 0) {
289 // Before 4.5.0 there is no `task` command: the relay reads it as stray arguments.
290 if (/unknown (?:command|option)|too many arguments/i.test(listed.stderr)) return TOO_OLD
291 return fit(oneLine(listed.stderr) || `phone-a-friend exited ${listed.exitCode}`, 240)
292 }
293 if (listed.isStdoutTruncated) return 'The task list was too long to read. Narrow it with: phone-a-friend task prune --older-than 30'
294 try {
295 return (JSON.parse(listed.stdout) as Record<string, unknown>[]).map(slim)
296 } catch {
297 // phone-a-friend before the fix cut its piped output at 64 KiB.
298 return 'phone-a-friend cut its task list short, so it could not be read. Update phone-a-friend: npm install -g @freibergergarcia/phone-a-friend'
299 }
300}
301
302const TOO_OLD = 'This phone-a-friend is too old for the panel, which needs 4.5.0 or newer. Update it: npm install -g @freibergergarcia/phone-a-friend'
303
304const startOf = (task: PafTask): number => parseTime(task.startedAt) ?? parseTime(task.createdAt) ?? 0
305
306// How long to leave another worktree alone before asking again.
307function beatOf(list: PafTask[] | undefined, now: number): number {
308 if (list === undefined || list.some(isActive)) return 0
309 const latest = Math.max(0, ...list.map(task => parseTime(task.finishedAt) ?? 0))
310 return now - latest < WARM_FOR_MS ? WARM_EVERY_MS : COLD_EVERY_MS
311}
312
313async function eachLimited<T>(items: readonly T[], atOnce: number, work: (item: T) => Promise<void>): Promise<void> {
314 let next = 0
315 const lane = async (): Promise<void> => {
316 while (next < items.length) {
317 const item = items[next] as T
318 next += 1
319 await work(item)
320 }
321 }
322 await Promise.all(Array.from({ length: Math.min(atOnce, items.length) }, lane))
323}
324
325// Puts what is known on screen: the worktrees' lists merged, newest first,
326// with the steps of whatever is running. One at a time, in the order asked,
327// so an older view never lands on top of a newer one.
328function commit(io: Io): Promise<void> {
329 const run = async (): Promise<void> => {
330 const place = worktrees
331 if (place === null) return
332 const scope = await io.scope()
333 const tasks = ((await io.wantsOthers()) ? place.roots : [place.here])
334 .flatMap(root => byRoot.get(root) ?? [])
335 .sort((a, b) => startOf(b) - startOf(a))
336 .slice(0, KEPT_TASKS)
337 .map(task => (task.result !== null && task.result.length > KEPT_RESULT_CHARS ? { ...task, result: task.result.slice(0, KEPT_RESULT_CHARS) } : task))
338
339 const now = await io.now()
340 // Toasts and following a new call keep to what the panel shows.
341 const { finished, started } = compare(scope === 'project' ? tasks : tasks.filter(task => task.repoPath === place.here), place.here)
342 if (primedAt === null) primedAt = now
343 for (const task of finished) io.toast(toastOf(task))
344 if (started) {
345 hotUntil = now + HOT_MS
346 await io.follow()
347 }
348 anyActive = tasks.some(isActive)
349
350 const stamp = tasks.map(task => `${task.id}:${task.status}:${task.finishedAt ?? ''}`).join('|')
351 await io.save(stamp === lastTasks ? null : tasks, null, now)
352 lastTasks = stamp
353 // The steps follow on their own: a slow `task show` holds up no list.
354 stepping = refreshSteps(io, tasks)
355 // The panel opens by itself only for a call in this worktree that is
356 // running or just finished, never for history.
357 const isFresh = (task: PafTask): boolean => isActive(task) || now - (parseTime(task.finishedAt) ?? 0) < RECENT_MS
358 if (!hasRevealed && tasks.some(task => task.repoPath === place.here && isFresh(task))) {
359 hasRevealed = true
360 await io.reveal()
361 await syncOnScreen(io)
362 }
363 }
364 commits = commits.then(run, run)
365 return commits
366}
367
368// What each running call is doing, read from its event log, a few at once.
369// Works on the newest task set asked for: one that arrives while a fetch runs
370// is fetched next, and the older fetch's result is dropped, never written.
371function refreshSteps(io: Io, tasks: readonly PafTask[]): Promise<void> {
372 stepsWanted = tasks
373 if (stepsLoop === null) {
374 stepsLoop = runSteps(io).finally(() => {
375 stepsLoop = null
376 })
377 }
378 return stepsLoop
379}
380
381async function runSteps(io: Io): Promise<void> {
382 while (stepsWanted !== null) {
383 const wanted = stepsWanted
384 stepsWanted = null
385 const timeline: Record<string, TimelineEntry[]> = {}
386 await Promise.all(
387 wanted
388 .filter(isActive)
389 .slice(0, 3)
390 .map(async task => {
391 try {
392 const shown = await io.run(['phone-a-friend', 'task', 'show', task.id, '--json'], 8_000)
393 if (shown.exitCode !== 0) return
394 const parsed = JSON.parse(shown.stdout) as { events?: { ts?: string; type?: string; message?: string | null }[] }
395 timeline[task.id] = toTimeline(task, parsed.events ?? [])
396 } catch {
397 // The steps are a nicety: the list stands without them.
398 }
399 }),
400 )
401 if (stepsWanted !== null) continue
402 const steps = JSON.stringify(timeline)
403 if (steps !== lastSteps) {
404 lastSteps = steps
405 await io.save(null, timeline, await io.now())
406 }
407 }
408}
409
410// The worktrees this session is not in. On their own beat and on their own
411// time: this worktree's list is on screen before they are asked about.
412async function refreshOthers(io: Io): Promise<void> {
413 const place = worktrees
414 if (othersInFlight || place === null) return
415 othersInFlight = true
416 try {
417 const asked = await io.now()
418 const due = place.roots.filter(root => root !== place.here && asked - (askedAt.get(root) ?? 0) >= beatOf(byRoot.get(root), asked))
419 if (due.length === 0) return
420 await eachLimited(due, OTHERS_AT_ONCE, async root => {
421 askedAt.set(root, asked)
422 if (!(await isOurs(io, root, place.common))) {
423 byRoot.set(root, [])
424 return
425 }
426 const list = await listTasks(io, root, 40, OTHERS_TIMEOUT_MS).catch(() => null)
427 // Checked again after: a list read while the path changed hands is dropped unseen.
428 if (Array.isArray(list) && (await isOurs(io, root, place.common))) byRoot.set(root, list)
429 else if (!byRoot.has(root)) byRoot.set(root, [])
430 })
431 await commit(io)
432 } catch {
433 // The next beat asks again.
434 } finally {
435 othersInFlight = false
436 }
437}
438
439// `isWaited`: the person asked, so the other worktrees are waited for too.
440// Drops everything known and everything drawn: what was read for this folder
441// may belong to another repository now, so nothing of it stays on screen or
442// within reach of Send to Claude.
443async function forget(io: Io, problem: string): Promise<void> {
444 byRoot.clear()
445 askedAt.clear()
446 worktrees = null
447 anyActive = false
448 lastTasks = ''
449 lastSteps = ''
450 stepsWanted = null
451 await commits
452 await io.save([], {}, await io.now())
453 await io.follow()
454 await io.problem(problem)
455}
456
457async function refresh(io: Io, isWaited = false): Promise<void> {
458 if (inFlight) return
459 inFlight = true
460 let others: Promise<void> | null = null
461 try {
462 const cwd = await io.cwd()
463 let place = await worktreesOf(io, cwd, await io.now())
464 // The list may be a minute old: this worktree is asked about only while
465 // it is still this repository's. Otherwise it is listed again from git.
466 // Only a path git confirms as this repository is asked about; outside git, nothing is.
467 const isConfirmed = async (): Promise<boolean> => place.isRepo && (await isOurs(io, place.here, place.common))
468 if (!(await isConfirmed())) {
469 byRoot.clear()
470 askedAt.clear()
471 worktrees = null
472 place = await worktreesOf(io, cwd, await io.now())
473 if (!(await isConfirmed())) {
474 await forget(
475 io,
476 place.isRepo
477 ? 'This folder changed repository while it was being read. Asking again shortly.'
478 : 'Not a git repository. Reviews are shown per repository, so there is nothing to list here.',
479 )
480 return
481 }
482 }
483 const [mine, branch] = await Promise.all([listTasks(io, place.here, 60, 15_000), branchOf(io, place.here)])
484 if (branch !== undefined) await io.saveBranch(branch)
485 // Checked again after: a list read while the path changed hands is dropped unseen.
486 if (typeof mine !== 'string' && !(await isConfirmed())) {
487 await forget(io, 'This folder changed repository while it was being read. Asking again shortly.')
488 return
489 }
490 if (typeof mine === 'string') {
491 // Nothing is known to be running any more: back to the idle beat.
492 anyActive = false
493 await io.problem(mine)
494 return
495 }
496 byRoot.set(place.here, mine)
497 await commit(io)
498 if (await io.wantsOthers()) others = refreshOthers(io)
499 } catch (err) {
500 anyActive = false
501 lastTasks = ''
502 const reason = reasonOf(err)
503 await io.problem(
504 isNotInstalled(reason) ? 'phone-a-friend is not on PATH. Install it with: npm install -g @freibergergarcia/phone-a-friend' : fit(`phone-a-friend: ${reason}`, 240),
505 )
506 } finally {
507 inFlight = false
508 }
509 if (isWaited) await Promise.all([others, stepping])
510}
511
512// herdr's view of every agent pane. Drawn in the pane only: other sessions'
513// titles stay on screen and are never sent to Claude.
514async function refreshAgents(io: Io): Promise<void> {
515 // One at a time: a slow answer must not land on top of a newer one.
516 if (agentsInFlight) return
517 // Without herdr, asking every two seconds only spawns a process that fails: once a minute is enough.
518 if (herdrMissingAt !== null && (await io.now()) - herdrMissingAt < HERDR_RETRY_MS) return
519 agentsInFlight = true
520 try {
521 const [listed, spaces] = await Promise.all([io.run(['herdr', 'agent', 'list'], 5_000), io.run(['herdr', 'workspace', 'list'], 5_000)])
522 if (listed.exitCode !== 0) {
523 lastAgents = ''
524 anyWorking = false
525 // 127 is a command that could not be run at all (its interpreter is gone).
526 if (listed.exitCode === 127) herdrMissingAt = await io.now()
527 await io.saveAgents([], {}, listed.exitCode === 127 ? 'missing' : fit(oneLine(listed.stderr) || `herdr exited ${listed.exitCode}`, 240))
528 return
529 }
530 herdrMissingAt = null
531 const workspaces = new Map<string, HerdrWorkspace>()
532 if (spaces.exitCode === 0) {
533 const parsedSpaces = JSON.parse(spaces.stdout) as { result?: { workspaces?: Record<string, unknown>[] } }
534 for (const raw of parsedSpaces.result?.workspaces ?? []) workspaces.set(...slimWorkspace(raw))
535 }
536 const parsed = JSON.parse(listed.stdout) as { result?: { agents?: Record<string, unknown>[] } }
537 const agents = (parsed.result?.agents ?? []).map(raw => slimAgent(raw, workspaces))
538 anyWorking = agents.some(agent => agent.status === 'working')
539 const key = JSON.stringify(agents)
540 if (key === lastAgents) return
541
542 // When each agent entered its state, for the ones seen changing.
543 const now = await io.now()
544 const here: Here = { root: worktrees?.here ?? (await io.cwd()), pane: selfPane }
545 const since = { ...(await io.since()) }
546 const isPrimed = lastAgents !== ''
547 for (const agent of agents) {
548 const before = agentStatus.get(agent.id)
549 agentStatus.set(agent.id, agent.status)
550 if (before === undefined || before === agent.status) continue
551 since[agent.id] = now
552 if (isPrimed && !isSelf(agent, here) && (agent.status === 'blocked' || agent.status === 'done')) {
553 const topic = agent.title.replace(/^[^\p{L}\p{N}]+/u, '').trim() || `${agent.agent} in ${placeOf(agent.cwd).repo}`
554 io.toast(agent.status === 'blocked' ? `● ${fit(topic, 48)} needs you` : `✓ ${fit(topic, 48)} finished`)
555 }
556 }
557 const listedIds = new Set(agents.map(agent => agent.id))
558 for (const id of [...agentStatus.keys()]) if (!listedIds.has(id)) agentStatus.delete(id)
559 for (const id of Object.keys(since)) if (!listedIds.has(id)) delete since[id]
560 lastAgents = key
561 await io.saveAgents(agents, since, null)
562 } catch (err) {
563 lastAgents = ''
564 anyWorking = false
565 const reason = reasonOf(err)
566 if (isNotInstalled(reason)) herdrMissingAt = await io.now()
567 await io.saveAgents([], {}, isNotInstalled(reason) ? 'missing' : fit(`herdr: ${reason}`, 240))
568 } finally {
569 agentsInFlight = false
570 }
571}
572
573// The engine knows whether the panel is on screen (it places a waiting pane
574// when the terminal widens, and a reload keeps the pane up); the band and the
575// conversation rows read it from $.state, kept in step here.
576async function syncOnScreen(io: Io): Promise<void> {
577 try {
578 const value = await io.onScreen()
579 if (value === lastOnScreen) return
580 lastOnScreen = value
581 await io.saveOnScreen(value)
582 } catch {
583 // Asked again on the next tick.
584 }
585}
586
587const shows = (panel: Panel, view: PafView): boolean => panel === 'both' || panel === view
588
589// A finding reads as a sentence; one that opens with code (`installPath()` ...) is left as written.
590const capital = (text: string): string => (/^[a-z]+(?:[\s,;:]|$)/.test(text) ? text.charAt(0).toUpperCase() + text.slice(1) : text)
591
592// ---------------------------------------------------------------- the call in the conversation
593
594// The elements every surface has.
595type Ui = Pick<ElementTable<'mobile'>, 'Box' | 'Text' | 'Markdown'>
596
597type CallView = { call: RelayCall; state: CallState; task: PafTask | null; round: Round | null; startedAt: number | null }
598type Live = { frame: number; now: number; step: string | null; usual: number | null }
599
600// A call's start and its twins' (the other calls of this session that asked the same thing).
601function bindingOf(starts: Record<string, PafCallStart>, id: string | undefined, call: RelayCall): Binding | null {
602 // Read leniently: an older version of this mod kept a bare number here.
603 const own = id !== undefined ? starts[id] : undefined
604 if (own === undefined || typeof own !== 'object') return null
605 const key = relayKey(call)
606 const twins = Object.entries(starts).flatMap(([other, start]) =>
607 other !== id && typeof start === 'object' && start.key === key ? [{ id: other, at: start.at, task: start.task, isBackground: start.isBackground }] : [],
608 )
609 return { id: id as string, at: own.at, isBackground: own.isBackground, twins }
610}
611
612const isLiveView = (view: CallView): boolean => (view.round === null ? view.state.isRunning : view.round.state === 'live')
613
614// One line for a call: who was asked, about what, and how it stands.
615// "⠹ codex · Round 3 · 00:42 · usually 1m 54s · git show 4f2a91c"
616// "✗ codex · Round 3 · 1 blocker · 1 important · 1m 52s"
617function callLine(ui: Ui, view: CallView, live: Live) {
618 const { Text } = ui
619 const { call, state, round } = view
620 const look = state.isInterrupted
621 ? { glyph: '!', color: 'yellow' }
622 : round !== null
623 ? lookOf(round, live.frame)
624 : state.isRunning
625 ? { glyph: spin(live.frame), color: 'cyan' }
626 : { glyph: '·', color: 'gray' }
627 const duration = round !== null ? durationOf(round) : null
628 const tail = state.isInterrupted
629 ? 'interrupted'
630 : isLiveView(view)
631 ? [view.startedAt !== null ? clock(live.now - view.startedAt) : 'starting', live.usual !== null ? `usually ${took(live.usual)}` : null, live.step]
632 .filter(Boolean)
633 .join(' · ')
634 : round === null
635 ? 'running in the background'
636 : [verdictText(round), duration !== null ? took(duration) : null].filter(Boolean).join(' · ')
637
638 return (
639 <Text wrap="truncate-end">
640 <Text color={look.color}>{`${look.glyph} `}</Text>
641 <Text bold>{call.backend ?? 'phone-a-friend'}</Text>
642 <Text>{` · ${call.title}`}</Text>
643 <Text dimColor>{` · ${tail}`}</Text>
644 </Text>
645 )
646}
647
648// Under the line: what the reviewer said, in its own words, a line a finding.
649function callAnswer(ui: Ui, round: Round, hasPanel: boolean) {
650 const { Box, Markdown, Text } = ui
651 if (round.state === 'live') return null
652 const findings = round.answer.findings.slice(0, TRANSCRIPT_FINDINGS)
653 const more = round.answer.findings.length - findings.length
654 const summary = round.answer.summary ?? ''
655 const where = Math.min(26, Math.max(0, ...findings.map(finding => finding.location?.length ?? 0)))
656 const isCut = more > 0 || findings.length > 0 || summary.length > TRANSCRIPT_PROSE_CHARS
657
658 return (
659 <Box flexDirection="column" paddingLeft={2}>
660 {round.problem !== null && (
661 <Text wrap="wrap">
662 <Text color="red">{fit(round.problem.line, 300)}</Text>
663 {round.problem.hint !== null && <Text dimColor>{` ${round.problem.hint}`}</Text>}
664 </Text>
665 )}
666 {findings.map(finding => (
667 <Text wrap="truncate-end">
668 <Text color={SEVERITY_COLOR[finding.severity]}>{finding.severity.padEnd(10)}</Text>
669 {where > 0 && <Text dimColor>{fit(finding.location ?? '', where).padEnd(where + 2)}</Text>}
670 <Text>{plain(capital(finding.text))}</Text>
671 </Text>
672 ))}
673 {more > 0 && <Text dimColor>{`+${more} more`}</Text>}
674 {round.problem === null && findings.length === 0 && summary !== '' && <Markdown text={lede(summary, TRANSCRIPT_PROSE_CHARS)} />}
675 {round.problem === null && findings.length === 0 && summary === '' && <Text dimColor>No answer text.</Text>}
676 {isCut && !hasPanel && <Text dimColor>/paf for the whole answer</Text>}
677 </Box>
678 )
679}
680
681export const register: Register = (on, options) => {
682 const settings = options as Record<string, unknown>
683 const panel: Panel = settings.panel === 'calls' || settings.panel === 'paf' ? 'calls' : settings.panel === 'agents' ? 'agents' : 'both'
684 const band: Band = settings.band === 'always' || settings.band === 'never' ? settings.band : 'auto'
685 const isAutoOpen = settings.auto_open !== false
686 const isInTranscript = settings.transcript !== false
687
688 const poll = async (io: Io, isWaited = false): Promise<void> => {
689 await Promise.all([shows(panel, 'calls') ? refresh(io, isWaited) : null, shows(panel, 'agents') ? refreshAgents(io) : null, syncOnScreen(io)])
690 }
691
692 on('session.start', async ($, e, next) => {
693 const io: Io = {
694 run: (argv, timeoutMs) => $.process.run(argv, { timeoutMs }),
695 cwd: () => $.session.cwd(),
696 now: () => $.clock.now(),
697 toast: text => $.ui.toast(text, { timeoutMs: 8_000 }),
698 scope: async () => ((await read($, scopeAtom)) === 'repo' ? 'repo' : 'project'),
699 wantsOthers: async () => (await read($, scopeAtom)) !== 'repo' || (shows(panel, 'agents') && herdrMissingAt === null),
700 problem: text => update($, problemAtom, () => text),
701 save: async (tasks, timeline, now) => {
702 if (tasks !== null) await update($, tasksAtom, () => tasks)
703 if (timeline !== null) await update($, timelineAtom, () => timeline)
704 await update($, problemAtom, () => null)
705 await update($, polledAtom, () => now)
706 },
707 savePlace: async (here, roots, skipped) => {
708 if ((await read($, hereAtom)) !== here) await update($, hereAtom, () => here)
709 if ((await read($, rootsAtom)).join('\n') !== roots.join('\n')) await update($, rootsAtom, () => roots)
710 if ((await read($, skippedAtom)) !== skipped) await update($, skippedAtom, () => skipped)
711 },
712 saveBranch: async branch => {
713 if ((await read($, branchAtom)) !== branch) await update($, branchAtom, () => branch)
714 },
715 follow: async () => {
716 await update($, threadAtom, () => null)
717 await update($, roundAtom, () => null)
718 },
719 since: () => read($, sinceAtom),
720 saveAgents: async (agents, since, problem) => {
721 await update($, agentsAtom, () => agents)
722 await update($, sinceAtom, () => since)
723 await update($, agentsProblemAtom, () => problem)
724 },
725 onScreen: async () => (await $.ui.panes()).some(pane => pane.id === PANE && pane.isPlaced && pane.isShown),
726 saveOnScreen: value => update($, onScreenAtom, () => value),
727 reveal: async () => {
728 if (isAutoOpen && !isClosedByPerson) await $.ui.open({ id: PANE, title: TITLE, rows: 16 })
729 },
730 }
731 pollNow = isWaited => poll(io, isWaited)
732 selfPane = (await $.env.get('HERDR_PANE_ID')) ?? null
733 await update($, paneAtom, () => selfPane)
734
735 await $.command.register({
736 name: 'paf',
737 description: 'phone-a-friend panel: what your reviewers said, round by round (/paf agents, /paf close)',
738 immediate: true,
739 })
740 $.clock.every(TICK_MS, () => {
741 ticks += 1
742 void syncOnScreen(io)
743 void $.clock.now().then(now => {
744 if (shows(panel, 'calls') && (anyActive || now < hotUntil || ticks % IDLE_EVERY === 0)) void refresh(io)
745 if (shows(panel, 'agents')) void refreshAgents(io)
746 })
747 })
748 // The spinner: only while something is actually working.
749 $.clock.every(1_000, () => {
750 if (anyActive || anyWorking || waitingCalls > 0) void update($, frameAtom, frame => frame + 1)
751 })
752 // The reviews panel opens once the first look finds something to read (or
753 // at the first call); a panel that is only the agents list has it already.
754 if (isAutoOpen && panel === 'agents') {
755 hasRevealed = true
756 void $.ui.open({ id: PANE, title: TITLE, rows: 16 }).then(() => syncOnScreen(io))
757 }
758 void poll(io)
759
760 return next(e)
761 })
762
763 // /clear, /resume and /branch reset $.state and fire no session.start.
764 on('classic.SessionStart', { source: ['clear', 'resume', 'fork'] }, async ($, e, next) => {
765 lastTasks = ''
766 lastSteps = ''
767 lastAgents = ''
768 lastOnScreen = null
769 await update($, paneAtom, () => selfPane)
770 void pollNow?.()
771
772 return next(e)
773 })
774
775 on('command.run', { command: 'paf' }, async ($, e) => {
776 const wanted = e.args.trim().toLowerCase()
777 if (wanted === 'close' || wanted === 'hide') {
778 isClosedByPerson = true
779 await $.ui.close({ id: PANE })
780 return {}
781 }
782 if (wanted === 'agents' && shows(panel, 'agents')) await update($, viewAtom, () => 'agents')
783 if ((wanted === 'calls' || wanted === 'reviews') && shows(panel, 'calls')) await update($, viewAtom, () => 'calls')
784
785 isClosedByPerson = false
786 hasRevealed = true
787 if (selfPane === null) {
788 selfPane = (await $.env.get('HERDR_PANE_ID')) ?? null
789 await update($, paneAtom, () => selfPane)
790 }
791 const opened = await $.ui.open({ id: PANE, title: TITLE, focus: true, rows: 16 })
792 lastOnScreen = opened.isPlaced
793 await update($, onScreenAtom, () => opened.isPlaced)
794 // Works before or without session.start too (a test, a failed start).
795 pollNow ??= isWaited =>
796 poll({
797 run: (argv, timeoutMs) => $.process.run(argv, { timeoutMs }),
798 cwd: () => $.session.cwd(),
799 now: () => $.clock.now(),
800 toast: text => $.ui.toast(text, { timeoutMs: 8_000 }),
801 scope: async () => ((await read($, scopeAtom)) === 'repo' ? 'repo' : 'project'),
802 wantsOthers: async () => (await read($, scopeAtom)) !== 'repo' || (shows(panel, 'agents') && herdrMissingAt === null),
803 problem: text => update($, problemAtom, () => text),
804 save: async (tasks, timeline, now) => {
805 if (tasks !== null) await update($, tasksAtom, () => tasks)
806 if (timeline !== null) await update($, timelineAtom, () => timeline)
807 await update($, problemAtom, () => null)
808 await update($, polledAtom, () => now)
809 },
810 savePlace: async (here, roots, skipped) => {
811 if ((await read($, hereAtom)) !== here) await update($, hereAtom, () => here)
812 if ((await read($, rootsAtom)).join('\n') !== roots.join('\n')) await update($, rootsAtom, () => roots)
813 if ((await read($, skippedAtom)) !== skipped) await update($, skippedAtom, () => skipped)
814 },
815 saveBranch: async branch => {
816 if ((await read($, branchAtom)) !== branch) await update($, branchAtom, () => branch)
817 },
818 follow: async () => {
819 await update($, threadAtom, () => null)
820 await update($, roundAtom, () => null)
821 },
822 since: () => read($, sinceAtom),
823 saveAgents: async (agents, since, problem) => {
824 await update($, agentsAtom, () => agents)
825 await update($, sinceAtom, () => since)
826 await update($, agentsProblemAtom, () => problem)
827 },
828 onScreen: async () => (await $.ui.panes()).some(pane => pane.id === PANE && pane.isPlaced && pane.isShown),
829 saveOnScreen: value => update($, onScreenAtom, () => value),
830 reveal: async () => {
831 if (isAutoOpen && !isClosedByPerson) await $.ui.open({ id: PANE, title: TITLE, rows: 16 })
832 },
833 }, isWaited)
834 await pollNow(true)
835
836 return opened.isPlaced ? {} : { text: `phone-a-friend panel not shown: ${opened.reason}` }
837 })
838
839 // Closing the panel yourself keeps it closed until you type /paf again.
840 on('ui.close', async ($, e, next) => {
841 if (e.id !== PANE) return next(e)
842 if (e.origin.kind === 'person') isClosedByPerson = true
843 const closed = await next(e)
844 // The band and the conversation rows stand in for the panel from here on.
845 lastOnScreen = null
846 await update($, onScreenAtom, () => false)
847
848 return closed
849 })
850
851 // A phone-a-friend call from this session (main thread or a subagent) means
852 // a task is about to appear: show the panel and poll every tick until it settles.
853 on('tool.call', { tool: 'Bash' }, async ($, e, next) => {
854 if (e.tool !== 'Bash' || !e.command.includes('phone-a-friend')) return next(e)
855 const call = parseRelay(e.command)
856 // A path or a `task list` that merely names phone-a-friend starts nothing.
857 if (call === null && !/\bphone-a-friend\s+--/.test(e.command)) return next(e)
858 const began = await $.clock.now()
859 hotUntil = began + HOT_MS
860 const id = e.tool_use_id
861 if (id !== undefined) {
862 // Kept in $.state: the call's line reads its clock from here.
863 const start: PafCallStart = { at: began, key: call !== null ? relayKey(call) : '', isBackground: e.run_in_background === true }
864 await update($, callStartsAtom, starts => ({ ...Object.fromEntries(Object.entries(starts).slice(1 - KEPT_CALL_STARTS)), [id]: start }))
865 }
866 // The panel opens when the call's task shows up (commit), not on a
867 // command that may yet be denied or fail before it starts.
868 $.clock.after(1_500, () => void pollNow?.())
869 // While the turn waits on a reviewer, its spinner says so.
870 const isWaiting = call !== null && e.run_in_background !== true && e.agentId === undefined
871 const key = id ?? `call-${began}`
872 if (isWaiting) {
873 waitingCalls += 1
874 await update($, waitingAtom, list => [...list.filter(item => item.id !== key), { id: key, backend: call.backend ?? 'a reviewer', title: call.title }])
875 }
876 // What the call printed says which task it made; one with no id made none.
877 let made: string | null | undefined
878 try {
879 const result = await next(e)
880 if (e.run_in_background !== true) made = /\bTask ([0-9a-f]{8}) (?:started|completed|failed)/.exec(result.text ?? '')?.[1] ?? null
881 return result
882 } finally {
883 if (id !== undefined && made !== undefined) {
884 const task = made
885 await update($, callStartsAtom, starts => {
886 const start = starts[id]
887 return start !== undefined && typeof start === 'object' ? { ...starts, [id]: { ...start, task } } : starts
888 })
889 }
890 if (isWaiting) {
891 waitingCalls -= 1
892 await update($, waitingAtom, list => list.filter(item => item.id !== key))
893 }
894 void pollNow?.()
895 }
896 })
897
898 // "Sautéing…" says nothing about a two-minute wait on another model.
899 on('ui.render', { component: 'Spinner' }, async ($, e, next) => {
900 if (e.props.mode !== 'tool-use') return next(e)
901 const waiting = await read($, waitingAtom)
902 const backends = [...new Set(waiting.map(item => item.backend))]
903 if (backends.length === 0) return next(e)
904
905 return next({ ...e, props: { ...e.props, message: `Waiting on ${backends.join(' + ')}` } })
906 })
907
908 const relayOf = (tool: string, input: unknown): RelayCall | null => {
909 if (!isInTranscript || tool !== 'Bash') return null
910 const command = (input as { command?: unknown } | null)?.command
911 return typeof command === 'string' ? parseRelay(command) : null
912 }
913
914 // The conversation folds finished tool calls into a count ("Ran 1 shell
915 // command"), which says nothing of a review. A phone-a-friend call is drawn
916 // as what it is: who was asked, about what, and what they said. A group
917 // that is only such calls is replaced; a mixed one keeps its count line.
918 on('ui.render', { component: 'ToolGroup' }, async ($, e, next) => {
919 if (e.props.isExpanded) return next(e)
920 const relays = e.props.calls.flatMap(item => {
921 const call = relayOf(item.tool, item.input)
922 return call === null ? [] : [{ item, call }]
923 })
924 if (relays.length === 0) return next(e)
925
926 const ui = $.ui.resolve(e)
927 const { Box } = ui
928 const tasks = await read($, tasksAtom)
929 const starts = await read($, callStartsAtom)
930 const hasPanel = await read($, onScreenAtom)
931 const views: CallView[] = relays.map(({ item, call }) => {
932 const bind = bindingOf(starts, item.tool_use_id, call)
933 const found = roundOfCall(call, item, tasks, bind)
934 return { call, state: item, ...found, startedAt: found.round?.startedAt ?? bind?.at ?? null }
935 })
936 const isLive = views.some(isLiveView)
937 const frame = isLive ? await read($, frameAtom) : 0
938 const timeline = isLive ? await read($, timelineAtom) : {}
939 const now = isLive ? await $.clock.now() : 0
940 const blocks = views.map(view => (
941 <Box flexDirection="column">
942 {callLine(ui, view, {
943 frame,
944 now,
945 step: view.task !== null ? (timeline[view.task.id]?.at(-1)?.text ?? null) : null,
946 usual: view.task !== null && isActive(view.task) ? usualMs(tasks, view.task) : null,
947 })}
948 {view.round !== null && !view.state.isInterrupted && callAnswer(ui, view.round, hasPanel)}
949 </Box>
950 ))
951 const isAlone = relays.length === e.props.calls.length && relays.every(({ call }) => call.isOnlyCall)
952 if (isAlone) {
953 return (
954 <Box flexDirection="column" paddingLeft={2} marginTop={1}>
955 {blocks}
956 </Box>
957 )
958 }
959
960 return (
961 <Box flexDirection="column">
962 {await next(e)}
963 <Box flexDirection="column" paddingLeft={2}>
964 {blocks}
965 </Box>
966 </Box>
967 )
968 })
969
970 // Unfolded (ctrl+o, --verbose) the person asked for the command and its
971 // output as they are: the row stays, with the call's line under it.
972 on('ui.render', { component: 'ToolUse', props: { tool: 'Bash' } }, async ($, e, next) => {
973 const call = relayOf(e.props.tool, e.props.input)
974 if (call === null) return next(e)
975
976 const ui = $.ui.resolve(e)
977 const { Box } = ui
978 const tasks = await read($, tasksAtom)
979 const bind = bindingOf(await read($, callStartsAtom), e.props.tool_use_id, call)
980 const found = roundOfCall(call, e.props, tasks, bind)
981 const view: CallView = { call, state: e.props, ...found, startedAt: found.round?.startedAt ?? bind?.at ?? null }
982 const isLive = isLiveView(view)
983 const frame = isLive ? await read($, frameAtom) : 0
984 const timeline = isLive ? await read($, timelineAtom) : {}
985 const now = isLive ? await $.clock.now() : 0
986
987 return (
988 <Box flexDirection="column">
989 {await next(e)}
990 <Box paddingLeft={2}>
991 {callLine(ui, view, {
992 frame,
993 now,
994 step: view.task !== null ? (timeline[view.task.id]?.at(-1)?.text ?? null) : null,
995 usual: view.task !== null && isActive(view.task) ? usualMs(tasks, view.task) : null,
996 })}
997 </Box>
998 </Box>
999 )
1000 })
1001
1002 // The band: one quiet line while a reviewer works, and for a few minutes
1003 // after it answers. With the panel on screen the line would say it twice,
1004 // so by default it shows only while the panel is not.
1005 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
1006 if (e.props.hasSurvey || band === 'never' || !shows(panel, 'calls')) return next(e)
1007 if (band === 'auto' && (await read($, onScreenAtom))) return next(e)
1008
1009 const tasks = await read($, tasksAtom)
1010 const timeline = await read($, timelineAtom)
1011 const frame = await read($, frameAtom)
1012 const hereRoot = await read($, hereAtom)
1013 await read($, polledAtom) // redraw on every poll so the clocks tick
1014 const now = await $.clock.now()
1015 // The band is about this worktree: another one's reviewer is the panel's business.
1016 const mine = tasks.filter(task => hereRoot === null || task.repoPath === hereRoot)
1017 const live = mine.filter(isActive)
1018 const recent = mine.find(task => {
1019 const finished = parseTime(task.finishedAt)
1020 return !isActive(task) && finished !== null && now - finished < RECENT_MS
1021 })
1022 const shown = live[0] ?? recent
1023 if (!shown) return next(e)
1024
1025 const { Box, Text } = $.ui.resolve(e)
1026 const round = roundOf(shown)
1027 const look = lookOf(round, frame)
1028 const step = timeline[shown.id]?.at(-1)?.text
1029 const tail =
1030 round.state === 'live'
1031 ? `${clock(now - (round.startedAt ?? now))}${step ? ` · ${step}` : ''}${live.length > 1 ? ` · +${live.length - 1} more` : ''}`
1032 : `${verdictText(round)} · ${age(now - (round.finishedAt ?? now))} ago · /paf to read`
1033
1034 return (
1035 <Box paddingX={1}>
1036 <Text wrap="truncate-end">
1037 <Text color={look.color}>{`${look.glyph} `}</Text>
1038 <Text bold>{shown.backend}</Text>
1039 <Text>{` · ${round.title}`}</Text>
1040 <Text dimColor>{` · ${tail}`}</Text>
1041 </Text>
1042 </Box>
1043 )
1044 })
1045
1046 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
1047 const { Box, Button, Markdown, Text } = $.ui.resolve(e)
1048 const width = Math.max(28, e.props.bodyColumns)
1049 const isInline = e.props.placement === 'inline'
1050 const roots = await read($, rootsAtom)
1051 const here: Here = { root: (await read($, hereAtom)) ?? (await $.session.cwd()), pane: await read($, paneAtom) }
1052 // Read leniently: an older version of this mod may have left its own values here.
1053 const view: PafView = panel === 'both' ? ((await read($, viewAtom)) === 'agents' ? 'agents' : 'calls') : panel
1054 const frame = await read($, frameAtom)
1055 const tasks = await read($, tasksAtom)
1056 const agents = await read($, agentsAtom)
1057 const agentsProblem = await read($, agentsProblemAtom)
1058 await read($, polledAtom)
1059 const now = await $.clock.now()
1060 const gap = isInline ? 0 : 1
1061 // Without herdr there are no agents to list, so the view goes unless it was asked for by name.
1062 const hasAgents = shows(panel, 'agents') && (agentsProblem !== 'missing' || panel === 'agents')
1063 const hasTabs = panel === 'both' && hasAgents
1064 const shownView: PafView = hasAgents ? view : 'calls'
1065 const wanting = agents.filter(agent => !isSelf(agent, here) && (agent.status === 'blocked' || agent.status === 'done')).length
1066
1067 // The engine draws its close mark over the top right corner: keep it clear.
1068 const header = (
1069 <Box columnGap={3} rowGap={0} flexWrap="wrap" paddingRight={4}>
1070 <Text>
1071 <Text color="cyan">◇ </Text>
1072 <Text bold>phone-a-friend</Text>
1073 </Text>
1074 {hasTabs && (
1075 <Box columnGap={2}>
1076 <Button key="view-calls" plain hotkey="1" label="Reviews" dimColor={shownView !== 'calls'} onPress={() => update($, viewAtom, () => 'calls')} />
1077 <Button key="view-agents" plain hotkey="2" label="Agents" dimColor={shownView !== 'agents'} onPress={() => update($, viewAtom, () => 'agents')} />
1078 {wanting > 0 && <Text color="yellow">{`● ${wanting}`}</Text>}
1079 </Box>
1080 )}
1081 </Box>
1082 )
1083 const hint = (text: string) => (
1084 <Box marginTop={gap}>
1085 <Text dimColor wrap="truncate-end">
1086 {e.props.isFocused ? text : 'click a row · or ctrl+x tab for the keys'}
1087 </Text>
1088 </Box>
1089 )
1090
1091 if (shownView === 'agents') {
1092 const since = await read($, sinceAtom)
1093 const cards = toAgentCards(agents, since, here)
1094 // What the reviewers last said in each worktree: tasks come newest first.
1095 const liveHere = new Map<string, Round>()
1096 const lastHere = new Map<string, Round>()
1097 for (const task of tasks) {
1098 if (!task.repoPath) continue
1099 if (isActive(task)) liveHere.set(task.repoPath, roundOf(task))
1100 else if (!lastHere.has(task.repoPath)) lastHere.set(task.repoPath, roundOf(task))
1101 }
1102
1103 const card = (item: AgentCard) => {
1104 const look = AGENT_LOOK[item.state]
1105 const state = item.isHere ? 'this session' : look.word
1106 const when = item.since !== null ? ` · ${age(now - item.since)}` : ''
1107 const place = [item.agent.number != null ? `#${item.agent.number}` : null, item.agent.agent, item.worktree ? `${item.repo} / ${item.worktree}` : item.repo]
1108 .filter(Boolean)
1109 .join(' · ')
1110 // A session may sit in a folder of its worktree; the tasks are filed under the root.
1111 const root = item.agent.cwd ? (rootOf(item.agent.cwd, roots) ?? item.agent.cwd) : null
1112 const call = root ? liveHere.get(root) : undefined
1113 const last = !call && root ? lastHere.get(root) : undefined
1114 const lastLook = last ? lookOf(last, frame) : null
1115 const isQuiet = item.state === 'idle' || item.state === 'unknown'
1116 // A session that wants nothing takes one line: its topic, and where it is.
1117 const right = isQuiet && !item.isHere ? place : `${state}${when}`
1118 return (
1119 <Box flexDirection="column">
1120 <Box justifyContent="space-between" columnGap={2}>
1121 <Box columnGap={1} flexShrink={1}>
1122 <Text color={look.color}>{agentGlyph(item.state, frame)}</Text>
1123 <Button
1124 key={`agent-${item.agent.id}`}
1125 plain
1126 label={fit(item.topic, Math.max(10, width - right.length - 5))}
1127 dimColor={isQuiet}
1128 onPress={() => void $.process.run(['herdr', 'agent', 'focus', item.agent.id], { timeoutMs: 5_000 }).catch(() => undefined)}
1129 />
1130 </Box>
1131 {isQuiet && !item.isHere ? (
1132 <Text dimColor>{place}</Text>
1133 ) : (
1134 <Box flexShrink={0}>
1135 <Text color={item.isHere ? 'cyan' : look.color}>{state}</Text>
1136 <Text dimColor>{when}</Text>
1137 </Box>
1138 )}
1139 </Box>
1140 {(!isQuiet || item.isHere) && <Text dimColor wrap="truncate-end">{` ${place}`}</Text>}
1141 {call && (
1142 <Text wrap="truncate-end">
1143 <Text color="cyan">{` ${spin(frame)} `}</Text>
1144 <Text dimColor>{`waiting on ${call.task.backend} · ${call.title} · ${clock(now - (call.startedAt ?? now))}`}</Text>
1145 </Text>
1146 )}
1147 {last && lastLook && last.finishedAt !== null && now - last.finishedAt < 24 * 3_600_000 && (
1148 <Text wrap="truncate-end">
1149 <Text color={lastLook.color}>{` ${lastLook.glyph} `}</Text>
1150 <Text dimColor>{`${last.task.backend}: ${verdictText(last)} · ${age(now - last.finishedAt)} ago`}</Text>
1151 </Text>
1152 )}
1153 </Box>
1154 )
1155 }
1156 const group = (label: string, states: AgentState[]) => {
1157 const list = cards.filter(item => states.includes(item.state))
1158 return (
1159 list.length > 0 && (
1160 <Box flexDirection="column" marginTop={1}>
1161 <Text dimColor bold>
1162 {label}
1163 </Text>
1164 <Box flexDirection="column" rowGap={gap}>
1165 {list.map(card)}
1166 </Box>
1167 </Box>
1168 )
1169 )
1170 }
1171
1172 return (
1173 <Box flexDirection="column">
1174 {header}
1175 {agentsProblem !== null && agentsProblem !== 'missing' && (
1176 <Box marginTop={1}>
1177 <Text color="red" wrap="wrap">
1178 {agentsProblem}
1179 </Text>
1180 </Box>
1181 )}
1182 {agentsProblem === 'missing' && (
1183 <Box marginTop={1} flexDirection="column">
1184 <Text>herdr is not installed here.</Text>
1185 <Text dimColor wrap="wrap">
1186 This view lists the agent sessions herdr runs, so you can see which one needs you and jump to it.
1187 </Text>
1188 </Box>
1189 )}
1190 {cards.length === 0 && agentsProblem === null && (
1191 <Box marginTop={1}>
1192 <Text dimColor>No agent sessions in herdr right now.</Text>
1193 </Box>
1194 )}
1195 {group('NEEDS YOU', ['blocked', 'done'])}
1196 {group('WORKING', ['working'])}
1197 {group('IDLE', ['idle', 'unknown'])}
1198 {cards.length > 0 && hint(`enter jumps to the session${hasTabs ? ' · 1: reviews' : ''} · esc back to the prompt`)}
1199 </Box>
1200 )hooks/format.ts 488 lines1// How rounds, threads and agents are worded and colored on screen.
2
3import type { HerdrAgent, PafTask } from '../types'
4import { countsText, isActive, parseTime, roundOf, titleOf, toRound } from './model'
5import type { AgentState, Round, Severity, Thread } from './model'
6
7// 00:45, 12:03, 1:02:09
8export function clock(ms: number): string {
9 const total = Math.max(0, Math.floor(ms / 1000))
10 const h = Math.floor(total / 3600)
11 const m = Math.floor((total % 3600) / 60)
12 const s = total % 60
13 const pad = (n: number) => String(n).padStart(2, '0')
14 return h > 0 ? `${h}:${pad(m)}:${pad(s)}` : `${pad(m)}:${pad(s)}`
15}
16
17// How long ago, or how long in a state: 41s, 14m, 3h, 2d
18export function age(ms: number): string {
19 const s = Math.max(0, Math.round(ms / 1000))
20 if (s < 60) return `${s}s`
21 const m = Math.round(s / 60)
22 if (m < 60) return `${m}m`
23 const h = Math.round(m / 60)
24 if (h < 48) return `${h}h`
25 return `${Math.round(h / 24)}d`
26}
27
28// How long something took: 41s, 2m 5s, 1h 3m
29export function took(ms: number): string {
30 const s = Math.max(0, Math.round(ms / 1000))
31 if (s < 60) return `${s}s`
32 const m = Math.floor(s / 60)
33 if (m < 60) return s % 60 === 0 ? `${m}m` : `${m}m ${s % 60}s`
34 return `${Math.floor(m / 60)}h ${m % 60}m`
35}
36
37export const fit = (text: string, width: number): string =>
38 width <= 0 ? '' : text.length <= width ? text : `${text.slice(0, Math.max(0, width - 1))}…`
39
40export const oneLine = (text: string): string => text.replace(/\s+/g, ' ').trim()
41
42// Markdown as plain words, for a toast or a one-line row.
43export const plain = (text: string): string =>
44 oneLine(
45 text
46 .replace(/\*\*|__|`/g, '')
47 .replace(/\[([^\]]+)\]\([^)]+\)/g, '$1'),
48 )
49
50// The first sentence or two of a finding, for the folded view.
51export function lede(text: string, chars: number): string {
52 if (text.length <= chars) return text
53 const cut = text.slice(0, chars)
54 const stop = Math.max(cut.lastIndexOf('. '), cut.lastIndexOf('; '), cut.lastIndexOf(': '))
55 const body = stop > chars * 0.45 ? cut.slice(0, stop + 1) : cut.slice(0, Math.max(0, cut.lastIndexOf(' ')))
56 // A code span the cut left open would swallow the rest of the line.
57 const ticks = body.split('`').length - 1
58 return `${ticks % 2 === 1 ? `${body}\`` : body} …`
59}
60
61// Picks the fields this mod uses; the rest of the record (hashes, pids) stays in PaF.
62export function slim(raw: Record<string, unknown>): PafTask {
63 const text = (key: string): string | null => (typeof raw[key] === 'string' ? (raw[key] as string) : null)
64 const num = (key: string): number | null => (typeof raw[key] === 'number' ? (raw[key] as number) : null)
65 const status = text('status')
66 return {
67 id: text('id') ?? '????????',
68 kind: text('kind') ?? 'relay',
69 status:
70 status === 'queued' || status === 'running' || status === 'completed' || status === 'failed' || status === 'interrupted'
71 ? status
72 : 'failed',
73 backend: text('backend') ?? 'unknown',
74 model: text('model'),
75 sandbox: text('sandbox'),
76 repoPath: text('repoPath'),
77 branch: text('branch'),
78 reviewScope: text('reviewScope'),
79 reviewBase: text('reviewBase'),
80 diffFiles: num('diffFiles'),
81 driftDetected: typeof raw.driftDetected === 'boolean' ? raw.driftDetected : null,
82 sessionLabel: text('sessionLabel'),
83 backendSessionId: text('backendSessionId'),
84 promptPreview: text('promptPreview'),
85 result: text('result'),
86 error: text('error'),
87 createdAt: text('createdAt'),
88 startedAt: text('startedAt'),
89 finishedAt: text('finishedAt'),
90 }
91}
92
93// The worktree roots in `git worktree list --porcelain`, the main checkout
94// first. A bare entry has no files and a prunable one is gone from disk.
95export function parseWorktrees(porcelain: string): string[] {
96 const roots: string[] = []
97 for (const block of porcelain.split(/\n\s*\n/)) {
98 const lines = block.split('\n').map(line => line.trim())
99 const root = lines.find(line => line.startsWith('worktree '))?.slice('worktree '.length)
100 if (!root || lines.some(line => line === 'bare' || line.startsWith('prunable'))) continue
101 roots.push(root)
102 }
103 return roots
104}
105
106// The listed root that holds `path`: the deepest one, since a worktree can sit inside another.
107export function rootOf(path: string, roots: readonly string[]): string | null {
108 return roots.filter(root => path === root || path.startsWith(`${root}/`)).sort((a, b) => b.length - a.length)[0] ?? null
109}
110
111export type HerdrWorkspace = { number: number | null; label: string | null; repo: string | null }
112
113// One `herdr workspace list` entry, by its id.
114export function slimWorkspace(raw: Record<string, unknown>): [string, HerdrWorkspace] {
115 const worktree = (typeof raw.worktree === 'object' && raw.worktree !== null ? raw.worktree : {}) as Record<string, unknown>
116 return [
117 String(raw.workspace_id ?? ''),
118 {
119 number: typeof raw.number === 'number' ? raw.number : null,
120 label: typeof raw.label === 'string' ? raw.label : null,
121 repo: typeof worktree.repo_name === 'string' ? worktree.repo_name : null,
122 },
123 ]
124}
125
126// One `herdr agent list` entry, with the workspace it sits in.
127export function slimAgent(raw: Record<string, unknown>, workspaces: ReadonlyMap<string, HerdrWorkspace>): HerdrAgent {
128 const text = (key: string): string | null => (typeof raw[key] === 'string' ? (raw[key] as string) : null)
129 const workspace = workspaces.get(text('workspace_id') ?? '')
130 return {
131 id: text('pane_id') ?? text('terminal_id') ?? '?',
132 agent: text('agent') ?? 'agent',
133 status: text('agent_status') ?? 'unknown',
134 cwd: text('foreground_cwd') ?? text('cwd'),
135 title: oneLine(text('terminal_title_stripped') ?? text('terminal_title') ?? ''),
136 isFocused: raw.focused === true,
137 number: workspace?.number ?? null,
138 repo: workspace?.repo ?? null,
139 workspace: workspace?.label ?? null,
140 }
141}
142
143// Braille spinner, one cell wide in every terminal font.
144const SPINNER = '⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏'
145export const spin = (frame: number): string => SPINNER[frame % SPINNER.length] ?? '⠋'
146
147export type Look = { glyph: string; color: string }
148
149// The mark a round wears in the trail and in its row.
150export function lookOf(round: Round, frame: number): Look {
151 if (round.state === 'live') return { glyph: spin(frame), color: 'cyan' }
152 if (round.state === 'ship') return { glyph: '✓', color: 'green' }
153 if (round.state === 'revise') return { glyph: '✗', color: round.worst === 'blocker' ? 'red' : 'yellow' }
154 if (round.state === 'failed') return { glyph: '!', color: 'red' }
155 return { glyph: '·', color: 'gray' }
156}
157
158export const SEVERITY_COLOR: Record<Severity, string> = { blocker: 'red', important: 'yellow', finding: 'yellow', nit: 'gray' }
159
160// What a row says after the title. The findings are the facts, so they are
161// what a revise says; "ship" is said only when the reviewer said it, and an
162// answer that merely had nothing to fix says "no findings".
163export function verdictText(round: Round): string {
164 const counts = countsText(round.answer.findings)
165 if (round.state === 'live') return round.task.status === 'queued' ? 'queued' : 'reviewing'
166 if (round.state === 'failed') return round.problem?.label ?? 'failed'
167 if (round.state === 'answered') return 'answered'
168 if (round.state === 'ship') {
169 if (round.answer.source !== 'inferred') return counts ? `ship · ${counts}` : 'ship'
170 return counts || 'no findings'
171 }
172 return counts || 'revise'
173}
174
175export function durationOf(round: Round): number | null {
176 return round.startedAt !== null && round.finishedAt !== null ? round.finishedAt - round.startedAt : null
177}
178
179// "branch vs main · 6 files · tree changed during review"
180export function scopeText(task: PafTask): string | null {
181 if (task.kind !== 'review' || !task.reviewScope) return null
182 const base = task.reviewBase ? ` vs ${task.reviewBase}` : ''
183 const files = task.diffFiles != null ? ` · ${task.diffFiles} file${task.diffFiles === 1 ? '' : 's'}` : ''
184 const drift = task.driftDetected === true ? ' · tree changed during review' : ''
185 return `${task.reviewScope}${base}${files}${drift}`
186}
187
188// Backend steps arrive as `Running: /bin/zsh -lc "rtk rg -n …"`; keep the command.
189export function cleanActivity(text: string): string {
190 return oneLine(text)
191 .replace(/^Running:\s*/i, '')
192 .replace(/\/bin\/(?:ba|z)?sh\s+-l?c\s+["']/g, '')
193 .replace(/\brtk\s+/g, '')
194 .replace(/["']\s*(…?)$/, '$1')
195}
196
197// "9h", "3d": how long a thread has been going.
198export function threadSpan(thread: Thread): string {
199 return age(Math.max(0, thread.lastAt - thread.firstAt))
200}
201
202export const AGENT_LOOK: Record<AgentState, { color: string; word: string }> = {
203 blocked: { color: 'yellow', word: 'needs you' },
204 done: { color: 'green', word: 'done' },
205 working: { color: 'cyan', word: 'working' },
206 idle: { color: 'gray', word: 'idle' },
207 unknown: { color: 'gray', word: 'open' },
208}
209
210export const agentGlyph = (state: AgentState, frame: number): string =>
211 state === 'working' ? spin(frame) : state === 'blocked' ? '●' : state === 'done' ? '✓' : '·'
212
213// ---------------------------------------------------------------- the call as Claude ran it
214
215export type RelayCall = {
216 backend: string | null
217 label: string | null
218 prompt: string | null
219 isReview: boolean
220 scope: string | null
221 // True when the command is that call and nothing else worth showing:
222 // at most a `cd` before it and a `tail` after it.
223 isOnlyCall: boolean
224 title: string
225}
226
227type Token = { text: string; isOp: boolean }
228
229// Enough of a shell's word splitting to find one command's flags: quotes,
230// backslashes, and the operators between commands.
231function tokenize(command: string): Token[] | null {
232 const tokens: Token[] = []
233 let word = ''
234 let hasWord = false
235 let i = 0
236 const flush = (): void => {
237 if (hasWord) tokens.push({ text: word, isOp: false })
238 word = ''
239 hasWord = false
240 }
241 while (i < command.length) {
242 const ch = command[i] ?? ''
243 if (ch === "'") {
244 const end = command.indexOf("'", i + 1)
245 if (end < 0) return null
246 word += command.slice(i + 1, end)
247 hasWord = true
248 i = end + 1
249 } else if (ch === '"') {
250 let j = i + 1
251 while (j < command.length && command[j] !== '"') {
252 if (command[j] === '\\' && j + 1 < command.length) j += 1
253 word += command[j] ?? ''
254 j += 1
255 }
256 if (j >= command.length) return null
257 hasWord = true
258 i = j + 1
259 } else if (ch === '\\' && i + 1 < command.length) {
260 word += command[i + 1] ?? ''
261 hasWord = true
262 i += 2
263 } else if (ch === ' ' || ch === '\t') {
264 flush()
265 i += 1
266 } else if (ch === '\n' || ch === ';') {
267 flush()
268 tokens.push({ text: ';', isOp: true })
269 i += 1
270 } else if (ch === '&' && command[i + 1] === '&') {
271 flush()
272 tokens.push({ text: '&&', isOp: true })
273 i += 2
274 } else if (ch === '|' && command[i + 1] === '|') {
275 flush()
276 tokens.push({ text: '||', isOp: true })
277 i += 2
278 } else if (ch === '|') {
279 flush()
280 tokens.push({ text: '|', isOp: true })
281 i += 1
282 } else if (ch === '&' && !word.endsWith('>')) {
283 flush()
284 tokens.push({ text: '&', isOp: true })
285 i += 1
286 } else {
287 word += ch
288 hasWord = true
289 i += 1
290 }
291 }
292 flush()
293 return tokens
294}
295
296const NOT_A_RELAY = new Set(['task', 'job', 'session', 'doctor', 'config', 'plugin', 'setup', 'agentic', 'install', 'update', 'uninstall'])
297const VALUE_FLAGS = new Set(['--to', '--session', '--prompt', '--review-scope', '--repo', '--base', '--model', '--sandbox', '--backend-session', '--context-file', '--context-text', '--schema', '--timeout', '--peer-messaging'])
298const isRedirect = (word: string): boolean => /^\d*[<>]/.test(word)
299const isPaf = (word: string): boolean => word === 'phone-a-friend' || word.endsWith('/phone-a-friend')
300
301// Reads a Bash command for a phone-a-friend relay or review, or null when it is not one.
302export function parseRelay(command: string): RelayCall | null {
303 if (!command.includes('phone-a-friend')) return null
304 const tokens = tokenize(command)
305 if (tokens === null) return null
306
307 const segments: { words: string[]; before: string | null }[] = []
308 let words: string[] = []
309 let before: string | null = null
310 for (const token of tokens) {
311 if (token.isOp) {
312 segments.push({ words, before })
313 words = []
314 before = token.text
315 } else {
316 words.push(token.text)
317 }
318 }
319 segments.push({ words, before })
320 const filled = segments.filter(segment => segment.words.length > 0)
321
322 const at = filled.findIndex(segment => isPaf(segment.words.find(word => !/^[A-Z_][A-Z0-9_]*=/.test(word)) ?? ''))
323 const call = filled[at]
324 if (!call) return null
325 const args = call.words.slice(call.words.findIndex(isPaf) + 1)
326 const first = args.find(word => !word.startsWith('-')) ?? null
327 if (args[0] && NOT_A_RELAY.has(args[0])) return null
328 if (args.includes('--quiet') || args.includes('--help') || args.includes('--version') || args.includes('-h') || args.includes('-V')) return null
329
330 const value = (flag: string): string | null => {
331 const inline = args.find(word => word.startsWith(`${flag}=`))
332 if (inline) return inline.slice(flag.length + 1)
333 const index = args.indexOf(flag)
334 return index >= 0 ? (args[index + 1] ?? null) : null
335 }
336 const prompt = value('--prompt')
337 const scope = value('--review-scope')
338 const isReview = args.includes('--review') || args.includes('--verdict-json') || scope !== null
339 if (prompt === null && !isReview) return null
340 // A word that is neither a flag nor a flag's value means this is not the relay form.
341 if (first !== null && first !== 'relay') {
342 const index = args.indexOf(first)
343 const owner = args[index - 1] ?? ''
344 if (!VALUE_FLAGS.has(owner) && !isRedirect(first)) return null
345 }
346
347 const isOnlyCall =
348 !command.includes('<<') &&
349 filled.every((segment, index) => {
350 if (index === at) return true
351 const head = segment.words[0] ?? ''
352 if (index < at) return index === at - 1 && head === 'cd' && segment.words.length <= 2
353 return index === at + 1 && segment.before === '|' && (head === 'tail' || head === 'head')
354 })
355
356 const backend = value('--to')
357 return {
358 backend,
359 label: value('--session'),
360 prompt,
361 isReview,
362 scope,
363 isOnlyCall,
364 title: titleOf(prompt ?? '', isReview ? 'review' : 'relay', scope, backend ?? 'phone-a-friend'),
365 }
366}
367
368// PaF records a task moments after the command starts; clocks on one machine agree to well under this.
369const START_SLACK_MS = 3_000
370const START_WINDOW_MS = 60_000
371
372// How a tool call stands, as its row in the transcript is told.
373export type CallState = { isRunning: boolean; isErrored: boolean; isInterrupted: boolean; output?: unknown }
374
375// What the command printed, once it has ended: its answer stream, and both streams together.
376function printedOf(state: CallState): { stdout: string; all: string; isBackground: boolean } | null {
377 if (state.isRunning || state.output === undefined || state.output === null) return null
378 if (typeof state.output === 'string') return { stdout: state.output, all: state.output, isBackground: false }
379 const output = state.output as { stdout?: unknown; stderr?: unknown; backgroundTaskId?: unknown }
380 const stdout = typeof output.stdout === 'string' ? output.stdout : ''
381 const stderr = typeof output.stderr === 'string' ? output.stderr : ''
382 return { stdout, all: `${stdout}\n${stderr}`, isBackground: Boolean(output.backgroundTaskId) }
383}
384
385// A call of this session as kept in $.state: which tool call, when it began,
386// and once it ended in the foreground, the task it printed (null: none).
387export type CallStart = { id: string; at: number; task?: string | null; isBackground?: boolean }
388// A call's own start, and the starts of this session's other calls that asked the same thing.
389export type Binding = CallStart & { twins: readonly CallStart[] }
390
391// What makes two calls the same ask: backend, session label and prompt.
392export const relayKey = (call: RelayCall): string => `${call.backend ?? ''}|${call.label ?? ''}|${oneLine(call.prompt ?? '').slice(0, 120)}`
393
394// The task a tool call started. Named in its output: that one. Else, among the
395// tasks asked the same thing of the same backend under the same label, the one
396// that began right after the call did. When the call's start is not known
397// (after a reload), only a match that is the single one of its kind is taken:
398// two rows must never claim the same task by guesswork.
399export function taskOfCall(call: RelayCall, printed: string | null, isBackground: boolean, tasks: readonly PafTask[], bind: Binding | null = null): PafTask | null {
400 const named = /\bTask ([0-9a-f]{8}) (?:started|completed|failed)/.exec(printed ?? '')?.[1]
401 if (named) return tasks.find(task => task.id === named) ?? null
402 if (printed !== null && !isBackground) return null
403 const want = oneLine(call.prompt ?? '').slice(0, 120)
404 const matches = tasks.filter(
405 task =>
406 (call.backend === null || task.backend === call.backend) &&
407 (call.label === null || task.sessionLabel === call.label) &&
408 (want === '' ? task.kind === 'review' && (task.promptPreview ?? '') === '' : oneLine(task.promptPreview ?? '').startsWith(want)),
409 )
410 if (bind !== null) {
411 const started = matches.map(task => ({ task, at: parseTime(task.startedAt) ?? parseTime(task.createdAt) ?? 0 })).sort((a, b) => a.at - b.at)
412 // A task starts after the command that runs it. Calls that asked the same
413 // thing take tasks one each, in the order they began: each the first task
414 // not yet taken that started after it did, within the minute. So two rows
415 // never claim one task.
416 const others = bind.twins.filter(twin => twin.id !== bind.id)
417 // A twin that ended holds the task it printed, or none: only the ones still open share out the rest.
418 const taken = new Set(others.flatMap(twin => (typeof twin.task === 'string' ? [twin.task] : [])))
419 const open = others.filter(twin => twin.task === undefined)
420 // Begun in the same millisecond, which came first cannot be told.
421 if (open.some(twin => twin.at === bind.at)) return null
422 // A call in the background never says which task it made, or whether it
423 // made one: a task that started after such a twin could be its own, so it
424 // is not guessed at. One that started before every such twin is safe.
425 const isNear = (twin: CallStart): boolean => Math.abs(twin.at - bind.at) <= START_WINDOW_MS
426 const unknown = open.filter(twin => isNear(twin) && (twin.isBackground === true || bind.isBackground === true))
427 const calls = [...open, { id: bind.id, at: bind.at }].sort((a, b) => a.at - b.at)
428 for (const one of calls) {
429 const pick = started.find(item => !taken.has(item.task.id) && item.at >= one.at && item.at <= one.at + START_WINDOW_MS)
430 if (one.id !== bind.id) {
431 if (pick) taken.add(pick.task.id)
432 continue
433 }
434 if (pick) return unknown.some(twin => pick.at >= twin.at) ? null : pick.task
435 // One just before counts only for a call alone, as the only task near it (clocks round).
436 const near = started.filter(item => item.at >= bind.at - START_SLACK_MS && item.at <= bind.at + START_WINDOW_MS)
437 return calls.length === 1 && near.length === 1 ? (near[0]?.task ?? null) : null
438 }
439 return null
440 }
441 const active = matches.filter(isActive)
442 if (active.length === 1) return active[0] ?? null
443 return isBackground && active.length === 0 && matches.length === 1 ? (matches[0] ?? null) : null
444}
445
446// The round a tool call became: PaF's own record when the panel holds it,
447// else read off what the command printed. No round while nothing is known.
448export function roundOfCall(call: RelayCall, state: CallState, tasks: readonly PafTask[], bind: Binding | null = null): { task: PafTask | null; round: Round | null } {
449 const printed = printedOf(state)
450 const task = taskOfCall(call, printed?.all ?? null, printed?.isBackground ?? false, tasks, bind)
451 if (task !== null) return { task, round: roundOf(task) }
452 if (printed === null || printed.isBackground || state.isInterrupted) return { task: null, round: null }
453 return {
454 task: null,
455 round: toRound({
456 id: '',
457 kind: call.isReview ? 'review' : 'relay',
458 status: state.isErrored ? 'failed' : 'completed',
459 backend: call.backend ?? 'phone-a-friend',
460 model: null,
461 sandbox: null,
462 repoPath: null,
463 branch: null,
464 reviewScope: call.scope,
465 reviewBase: null,
466 diffFiles: null,
467 driftDetected: null,
468 sessionLabel: call.label,
469 backendSessionId: null,
470 promptPreview: call.prompt,
471 result: state.isErrored ? null : answerIn(printed.stdout),
472 error: state.isErrored ? answerIn(printed.all) : null,
473 createdAt: null,
474 startedAt: null,
475 finishedAt: null,
476 }),
477 }
478}
479
480// The reviewer's answer inside a tool call's output: PaF's own progress and receipt lines dropped.
481export function answerIn(stdout: string): string {
482 return stdout
483 .split('\n')
484 .filter(line => !/^\s*(?:◇|✔|✖|⠋|⠙|⠹|⠸|⠼|⠴|⠦|⠧|⠇|⠏)\s/.test(line) && !/^\s*Task [0-9a-f]{8} (?:started|completed|failed)\b/.test(line) && !/^\s*\[phone-a-friend\]/.test(line))
485 .join('\n')
486 .trim()
487}
488hooks/model.ts 758 lines1// What phone-a-friend calls mean, worked out from the task records alone.
2//
3// In practice a call is one round of a longer conversation about a branch: a
4// reviewer is asked ("Round 3 for #400. Fixed: ..."), answers with findings
5// ("Found three defects: ...", "VERDICT: revise", "No real defects."), the fix
6// lands, and the next round goes out until the reviewer has nothing left.
7// This file turns flat task records into those rounds and threads. It makes no
8// engine calls, so it runs the same in a test.
9
10import type { HerdrAgent, PafTask } from '../types'
11
12// `finding` is one the reviewer did not rate ("Found three defects:").
13export type Severity = 'blocker' | 'important' | 'finding' | 'nit'
14export type Verdict = 'ship' | 'revise' | 'none'
15// json: PaF's --verdict-json envelope. declared: the answer says "VERDICT: x"
16// or opens with "Ship.". inferred: read off the findings, or "No defects."
17export type VerdictSource = 'json' | 'declared' | 'inferred'
18
19export type Finding = {
20 severity: Severity
21 location: string | null
22 // The finding itself, as markdown on one line.
23 text: string
24 // What the reviewer wrote under it: the scenario, the suggested fix.
25 detail: string | null
26}
27
28export type Answer = {
29 verdict: Verdict
30 source: VerdictSource | null
31 findings: Finding[]
32 // The answer in prose, when there is any beside the findings.
33 summary: string | null
34}
35
36export type RoundState = 'live' | 'ship' | 'revise' | 'answered' | 'failed'
37
38export type Problem = {
39 // Two or three words for a list row: "timed out", "session lost".
40 label: string
41 // The line of the error that says what went wrong.
42 line: string
43 // What to do about it, when the failure is a known one.
44 hint: string | null
45}
46
47export type Round = {
48 task: PafTask
49 title: string
50 state: RoundState
51 answer: Answer
52 // The most serious finding, which colors a revise round.
53 worst: Severity | null
54 problem: Problem | null
55 startedAt: number | null
56 finishedAt: number | null
57}
58
59export type Thread = {
60 key: string
61 // The branch the rounds are about, or the repository when there is none.
62 title: string
63 repo: string
64 backends: string[]
65 labels: string[]
66 // Oldest first.
67 rounds: Round[]
68 state: RoundState
69 firstAt: number
70 lastAt: number
71}
72
73export const isActive = (task: PafTask): boolean => task.status === 'running' || task.status === 'queued'
74
75export function parseTime(iso: string | null): number | null {
76 if (!iso) return null
77 const ms = Date.parse(iso)
78 return Number.isNaN(ms) ? null : ms
79}
80
81// ---------------------------------------------------------------- answers
82
83const SEVERITY_WORDS: Record<string, Severity> = {
84 blocker: 'blocker',
85 blockers: 'blocker',
86 blocking: 'blocker',
87 critical: 'blocker',
88 high: 'blocker',
89 major: 'important',
90 p0: 'blocker',
91 p1: 'blocker',
92 important: 'important',
93 medium: 'important',
94 moderate: 'important',
95 p2: 'important',
96 nit: 'nit',
97 nits: 'nit',
98 nitpick: 'nit',
99 minor: 'nit',
100 low: 'nit',
101 trivial: 'nit',
102 p3: 'nit',
103 suggestion: 'nit',
104 optional: 'nit',
105}
106const SEVERITY = 'blockers?|blocking|critical|high|major|p0|p1|important|medium|moderate|p2|nits?|nitpick|minor|low|trivial|p3|suggestion|optional'
107const RANK: Record<Severity, number> = { blocker: 0, important: 1, finding: 2, nit: 3 }
108
109const LIST_ITEM = /^(\s*)(?:[-*•]|\d+[.)])\s+(.*)$/
110const HEADING = /^\s{0,3}#{1,6}\s+(.*?)\s*#*\s*$/
111// A severity that opens a line or a list item: "[blocker] [src/x.ts:1] — ...",
112// "**High — ...**", "P1 [x.ts:3](...): ...", "Important — ...", "- P2: ...".
113const TAG = new RegExp(
114 `^(\\*\\*|__)?\\s*([\\[(])?\\s*(${SEVERITY})\\b(?:\\s+(?:severity|priority))?\\s*?([\\])])?\\s*?(\\*\\*|__)?(\\s*[—–:]\\s*|\\s+-\\s+|\\.\\s+)?(.*)$`,
115 'i',
116)
117// "One blocker: ...", "Defect: ...", "One new, low-probability edge remains: ..."
118const INLINE_LEAD =
119 /^(?:(?:one|a|an|two|three|four|five|\d+)\s+)?(?:[\w,'-]+\s+){0,5}?(blocker|defect|issue|bug|concern|problem|regression|edge(?: case)?|gap|bypass|finding|risk)s?(?:\s+(?:remains?|left|found))?\s*:\s+(\S.*)$/i
120// "Found 2 defects." announces a list without a colon.
121const LEAD_SENTENCE =
122 /^(?:i\s+)?(?:found|see|have|identified|there\s+(?:are|is))\s+(?:[\w-]+\s+){0,4}?(?:defects?|issues?|bugs?|findings?|problems?|regressions?|blockers?|concerns?|bypass(?:es)?|refusals?|gaps?)\b[^.]{0,40}[.:]?\s*$/i
123// "**Hard standards violation — [db.ts](...).** text", "**Spec finding — partial coverage.** text"
124const BOLD_LEAD = /^\*\*([^*]+)\*\*\s*(.*)$/
125// "The single most likely correctness bug is that ...": one defect, told as a sentence.
126const PROSE_DEFECT =
127 /^(?:the\s+)?(?:(?:single|one|main|only|most\s+likely|likeliest|biggest|real|remaining|clearest)\s+)+(?:[\w-]+\s+){0,2}?(?:bug|defect|issue|problem|regression|flaw)\s+(?:here\s+)?is\s+(?:that\s+)?(\S.*)$/i
128// "**Location:** Line 485 in `Frame::Result` parsing." says where the finding above is.
129const WHERE_LABEL = /^(?:location|where|file|site)s?\s*:?\s*$/i
130const BOLD_NOUN = /\b(findings?|violations?|conflicts?|defects?|bugs?|regressions?|blockers?|missing|partial|gaps?)\b/i
131const NEGATED = /\b(?:no|not|none|without|zero)\b/i
132// A line that announces a list of findings: "Found three defects:", "Findings, highest severity first:"
133const FINDINGS_LEAD =
134 /\b(findings?|defects?|issues?|bugs?|blockers?|blocking|bypass(?:es)?|regressions?|refusals?|problems?|concerns?|gaps?|risks?|nits?|suggestions?|objections?|violations?|must[- ]fix|should[- ]fix|required changes|remain(?:s|ing)?|request changes)\b/i
135// A line that announces a list that is not findings: "Verified:", "Confirmed:"
136const NOTES_LEAD =
137 /^(?:what i |i |also |already )?(?:verified|confirmed|checked|reviewed|tested|tests?|validation|passed|resolved|closed|fixed|context|notes?|summary|coverage|scope|ran|not run|residual|follow[- ]?ups?|open questions?|assumptions?)\b/i
138const NONE = /^(?:\*\*|__)?\s*(?:none|no\b|nothing|n\/a|not applicable|clean\b)/i
139// The answer says outright there is nothing to fix.
140const CLEAN =
141 /^(?:yes\b[\s,.—–-]*)?(?:i\s+)?(?:(?:found|see|have|there\s+(?:are|is))\s+)?(?:no\s+(?:[\w/'-]+\s+){0,6}?(?:findings?|blockers?|issues?|defects?|bugs?|concerns?|regressions?|problems?|violations?|gaps?|p0s?|p1s?|behaviou?r)\b|none\b|nothing\s+(?:to|left|further|blocking|new)\b|lgtm\b|looks good\b|clean\b|blockers?\s*:\s*none\b|(?:all|both|the)\s+(?:[\w-]+\s+){0,5}?(?:findings?|issues?|defects?|cases|items?)\s+(?:(?:is|are|now)\s+)*(?:\*\*)?(?:resolved|closed|fixed|addressed)\b)/i
142// "No blockers, but two issues remain" is not a clean answer.
143const TURNS = /\b(?:but|however|except|although|though)\b/i
144const DECLARED = /^[\s>#*_-]*(?:final\s+)?verdict\s*[:=—–-]\s*[*_`"']*\s*([a-z][a-z -]{0,30})/im
145const SHIP_WORDS = /^(ship|ships|shipped|approve|approved|lgtm|pass|passes|accept|accepted|clean|go\b|ok\b|okay|ready)/i
146const REVISE_WORDS = /^(revise|iterate|rework|changes?|fix|block|blocked|reject|rejected|hold|no[- ]?go|not\s+ready|needs?|request)/i
147const OPENS_SHIP = /^(?:\*\*|__)?\s*(?:ship(?:\s+it)?|lgtm|approved?)\b\s*(?:\*\*|__)?\s*(?:[.!:,—–-]|$)/i
148const OPENS_REVISE = /^(?:\*\*|__)?\s*(?:request(?:ing)? changes|revise|iterate|do not (?:ship|merge)|don't (?:ship|merge)|not ready)\b/i
149
150// Route folders count as path segments: `src/app/(app)/[id]/page.tsx`. A
151// bracket or parenthesis only ever wraps a whole segment, so "(see x.ts:3)"
152// and "[x.ts:3]" still read as x.ts:3.
153const SEGMENT = '(?:\\([\\w.-]+\\)|\\[[\\w.-]+\\]|[\\w@.~-]+)'
154const FILE = '(?:\\[[\\w.-]+\\]|[\\w@-])(?:\\[[\\w.-]+\\]|[\\w@.-])*'
155const PATH = `(?:${SEGMENT}\\/)*${FILE}\\.[A-Za-z][A-Za-z0-9]{0,5}`
156const LINES = ':\\d+(?:[-–]\\d+)?(?:,\\s*\\d+(?:[-–]\\d+)?)*'
157// "[AppModel.swift:751](/abs/AppModel.swift:751)", "[taps.ts](/abs/taps.ts:190)"
158const LINK = /\[([^\]\n]+)\]\(([^)\s]+)\)/
159const LINK_ALL = /\[([^\]\n]+)\]\(([^)\s]+)\)/g
160const SPAN_LOCATION = new RegExp(`^\`(${PATH}(?:${LINES})?)\``)
161const BRACKET_LOCATION = /^\[([^\]\n]{1,120})\](?!\()/
162const BARE_LOCATION = new RegExp(`^(${PATH}${LINES})`)
163const ANY_LOCATION = new RegExp(`\`?(${PATH}${LINES})\`?`)
164
165// "[taps.ts](/abs/path/taps.ts:190)" reads "taps.ts:190"; a web link stays a link.
166function linkText(label: string, href: string): string {
167 if (/^https?:/i.test(href)) return `[${label}](${href})`
168 const text = label.replace(/`/g, '')
169 if (/:\d/.test(text) || /^\d+(?:[-–]\d+)?$/.test(text)) return text
170 const line = /:(\d+(?:[-–]\d+)?)$/.exec(href)?.[1]
171 return line ? `${text}:${line}` : text
172}
173
174const tidy = (text: string): string =>
175 text
176 .replace(LINK_ALL, (_whole, label: string, href: string) => linkText(label, href))
177 .replace(/\s+/g, ' ')
178 .trim()
179
180// Splits "`src/x.ts:10` — text" into where and what.
181function takeLocation(raw: string): { location: string | null; text: string } {
182 let rest = raw.trim()
183 let location: string | null = null
184 const link = LINK.exec(rest)
185 if (link && link.index === 0 && !/^https?:/i.test(link[2] ?? '')) {
186 location = linkText(link[1] ?? '', link[2] ?? '')
187 rest = rest.slice(link[0].length)
188 const range = /^[-–]\d+/.exec(rest)
189 if (range) {
190 location += range[0]
191 rest = rest.slice(range[0].length)
192 }
193 } else {
194 const found = SPAN_LOCATION.exec(rest) ?? BARE_LOCATION.exec(rest) ?? BRACKET_LOCATION.exec(rest)
195 if (found) {
196 location = (found[1] ?? '').trim()
197 rest = rest.slice(found[0].length)
198 }
199 }
200 if (location !== null) rest = rest.replace(/^\s*(?:\*\*|__)?\s*(?:[—–:,]|-(?=\s))?\s*/, '')
201 const text = tidy(rest)
202 if (location === null) {
203 const inner = LINK.exec(rest)
204 location =
205 inner && !/^https?:/i.test(inner[2] ?? '') && /\.[A-Za-z]/.test(inner[2] ?? '')
206 ? linkText(inner[1] ?? '', inner[2] ?? '')
207 : (ANY_LOCATION.exec(text)?.[1] ?? null)
208 }
209 return { location, text }
210}
211
212// The severity a line opens with, when it is marked as one and not just a word
213// of the sentence ("Important changes were made" is prose).
214function takeTag(raw: string): { severity: Severity; rest: string } | null {
215 const tag = TAG.exec(raw)
216 if (!tag) return null
217 const word = (tag[3] ?? '').toLowerCase()
218 const isMarked = Boolean(tag[1] || tag[2] || tag[5] || tag[6]) || /^p\d$/.test(word)
219 if (!isMarked) return null
220 let rest = tag[7] ?? ''
221 // "**High — title.** text" closes its bold after the title.
222 if (tag[1] && !tag[5]) rest = rest.replace(/\*\*|__/, '')
223 return { severity: SEVERITY_WORDS[word] ?? 'important', rest }
224}
225
226const leadSeverity = (line: string): Severity | null => {
227 if (/\bnon[- ]?blocking\b/i.test(line)) return 'nit'
228 const word = new RegExp(`\\b(${SEVERITY})\\b`, 'i').exec(line)?.[1]?.toLowerCase()
229 return word ? (SEVERITY_WORDS[word] ?? null) : null
230}
231
232function fromEnvelope(result: string): Answer | null {
233 if (!result.startsWith('{')) return null
234 try {
235 const parsed = JSON.parse(result) as {
236 verdict?: string
237 summary?: string
238 findings?: { severity?: string; title?: string; rationale?: string; location?: string | null }[]
239 }
240 if (typeof parsed.verdict !== 'string' || !Array.isArray(parsed.findings)) return null
241 const findings = parsed.findings.map(finding => ({
242 severity: SEVERITY_WORDS[String(finding.severity).toLowerCase()] ?? 'important',
243 location: finding.location ? String(finding.location) : null,
244 text: tidy(String(finding.title ?? finding.rationale ?? '')),
245 detail: finding.title && finding.rationale ? tidy(String(finding.rationale)) : null,
246 }))
247 const verdict: Verdict = parsed.verdict === 'ship' ? 'ship' : parsed.verdict === 'iterate' ? 'revise' : 'none'
248 return { verdict, source: 'json', findings, summary: parsed.summary ? String(parsed.summary) : null }
249 } catch {
250 return null
251 }
252}
253
254type Mode = 'neutral' | 'findings' | 'notes' | 'section'
255
256const opensWithLocation = (body: string): boolean =>
257 SPAN_LOCATION.test(body) || BARE_LOCATION.test(body) || (LINK.exec(body)?.index === 0 && !/^\[[^\]]*\]\(https?:/i.test(body))
258
259// How freely a list is read as findings depends on what was asked:
260// `diff` is PaF's own review of a diff, where a list under any heading is
261// findings; `review` is a prompt that asks for one, where a list item must open
262// with a location or a severity; `other` needs the severity or a lead-in.
263export type Asked = 'diff' | 'review' | 'other'
264
265export function parseAnswer(result: string | null, asked: Asked = 'diff'): Answer {
266 const isReview = asked !== 'other'
267 const text = (result ?? '').trim()
268 if (text === '') return { verdict: 'none', source: null, findings: [], summary: null }
269 const envelope = fromEnvelope(text)
270 if (envelope) return envelope
271
272 const findings: Finding[] = []
273 const prose: string[] = []
274 let mode: Mode = 'neutral'
275 let lead: Severity | null = null
276 let open: Finding | null = null
277 let isFenced = false
278
279 const add = (severity: Severity, raw: string): void => {
280 const { location, text: body } = takeLocation(raw)
281 if (body === '' && location === null) return
282 open = { severity, location, text: body, detail: null }
283 findings.push(open)
284 }
285 const extend = (raw: string): void => {
286 if (!open) return
287 const more = tidy(raw.replace(LIST_ITEM, '$2'))
288 if (more === '') return
289 if (open.text === '') open.text = more
290 else open.detail = open.detail ? `${open.detail} ${more}` : more
291 if (open.location === null) open.location = ANY_LOCATION.exec(more)?.[1] ?? null
292 }
293
294 // What a heading or a lead-in line says the lines under it are.
295 const enter = (label: string): Mode => {
296 const bare = label.replace(/[*_`]/g, '').trim()
297 if (NOTES_LEAD.test(bare)) return 'notes'
298 if (FINDINGS_LEAD.test(bare) && !/^no\b/i.test(bare)) {
299 lead = /\bhard\b/i.test(bare) ? 'blocker' : leadSeverity(bare)
300 return 'findings'
301 }
302 lead = null
303 return 'section'
304 }
305 // A heading keeps its say until the next one; a lead-in line only until prose resumes.
306 let isSticky = false
307
308 for (const raw of text.split('\n')) {
309 if (/^\s*```/.test(raw)) {
310 isFenced = !isFenced
311 if (!open) prose.push(raw)
312 continue
313 }
314 if (isFenced) {
315 if (!open) prose.push(raw)
316 continue
317 }
318 if (raw.trim() === '') {
319 prose.push('')
320 continue
321 }
322 const indent = /^\s*/.exec(raw)?.[0].length ?? 0
323 const heading = HEADING.exec(raw)
324 if (heading) {
325 open = null
326 mode = enter(heading[1] ?? '')
327 isSticky = true
328 continue
329 }
330 if (DECLARED.test(raw) && findings.length === 0) continue
331
332 const item = LIST_ITEM.exec(raw)
333 if (item) {
334 const body = item[2] ?? ''
335 // An indented item belongs to the finding above it.
336 if (indent >= 2 && open) {
337 extend(body)
338 continue
339 }
340 const tag = mode === 'notes' ? null : takeTag(body)
341 if (tag) {
342 add(tag.severity, tag.rest)
343 } else if (NONE.test(body)) {
344 open = null
345 } else if (body.split(/\s+/).length <= 6 && !/[.!?:]\s/.test(body) && FINDINGS_LEAD.test(body)) {
346 // "2) BLOCKING objections" is a heading written as a list item.
347 open = null
348 mode = enter(body)
349 isSticky = true
350 } else if (mode === 'findings' || (mode === 'section' && asked === 'diff')) {
351 add(lead ?? 'finding', body)
352 } else if (mode === 'neutral' && isReview && opensWithLocation(body)) {
353 add('finding', body)
354 } else {
355 open = null
356 prose.push(raw)
357 }
358 continue
359 }
360
361 if (indent >= 2 && open) {
362 extend(raw)
363 continue
364 }
365 const line = raw.trim()
366 const tag = mode === 'notes' ? null : takeTag(line)
367 if (tag) {
368 add(tag.severity, tag.rest)
369 continue
370 }
371
372 const bold = BOLD_LEAD.exec(line)
373 if (bold && open && (bold[2] ?? '') !== '' && WHERE_LABEL.test(bold[1] ?? '')) {
374 const finding: Finding = open
375 if (finding.location === null) finding.location = tidy(bold[2] ?? '').replace(/[.;]\s*$/, '').slice(0, 60)
376 continue
377 }
378 if (bold && (bold[2] ?? '') === '' && (bold[1] ?? '').split(/\s+/).length <= 8) {
379 // A line that is all bold is a heading by another spelling.
380 open = null
381 mode = enter(bold[1] ?? '')
382 isSticky = true
383 continue
384 }
385 if (bold && mode !== 'notes') {
386 const label = bold[1] ?? ''
387 const rest = bold[2] ?? ''
388 if (BOLD_NOUN.test(label) && !NEGATED.test(label.split(/[—–:]/)[0] ?? '')) {
389 if (NONE.test(rest) || CLEAN.test(rest)) {
390 open = null
391 prose.push(raw)
392 continue
393 }
394 // What follows a dash inside the bold is the finding's own words.
395 const own = (/[—–]\s*(.+)$/.exec(label)?.[1] ?? '').replace(/^(?:hard|soft)\s*[:.]?\s*$/i, '')
396 const severity = /\bhard\b/i.test(label) ? 'blocker' : /\bsoft\b/i.test(label) ? 'nit' : (leadSeverity(label) ?? 'finding')
397 add(severity, `${own} ${rest}`.trim())
398 continue
399 }
400 if (mode === 'findings') {
401 add(lead ?? 'finding', line)
402 continue
403 }
404 }
405
406 const told = mode !== 'notes' && isReview ? PROSE_DEFECT.exec(line) : null
407 if (told) {
408 add('finding', told[1] ?? '')
409 continue
410 }
411 const inline = mode === 'notes' ? null : INLINE_LEAD.exec(line)
412 if (inline && !NONE.test(inline[2] ?? '') && !NEGATED.test(line.slice(0, line.indexOf(':')))) {
413 const named = /blocker/i.test(inline[1] ?? '') ? 'blocker' : leadSeverity(line.slice(0, line.indexOf(':')))
414 add(named ?? 'finding', inline[2] ?? '')
415 if (!isSticky) mode = 'neutral'
416 continue
417 }
418 open = null
419 if (/:\s*$/.test(line) || OPENS_REVISE.test(line) || LEAD_SENTENCE.test(line)) {
420 const next = NEGATED.test(line) && !OPENS_REVISE.test(line) ? 'neutral' : enter(line)
421 mode = next === 'section' ? 'neutral' : next
422 isSticky = false
423 if (mode !== 'neutral') continue
424 } else if (!isSticky) {
425 mode = 'neutral'
426 }
427 prose.push(raw)
428 }
429
430 const head = text.slice(0, 600)
431 const first = text.split('\n').find(line => line.trim() !== '' && !HEADING.test(line)) ?? ''
432 // "**Spec review:** I found no hard violation" opens with a label, then says it.
433 const opening = first.trim().replace(/^\*\*[^*]{0,48}:\*\*\s*/, '').replace(/^(?:\*\*|__)\s*/, '')
434 const isClean = CLEAN.test(opening) && !TURNS.test(opening.split(/[.!?](?:\s|$)/)[0] ?? '')
435 const word = DECLARED.exec(head)?.[1]?.trim() ?? null
436 // A long list nobody rated or located is a plan or an essay, not a review.
437 if (findings.length > 10 && findings.filter(finding => finding.location !== null || finding.severity !== 'finding').length * 2 < findings.length) {
438 return { verdict: 'none', source: null, findings: [], summary: text }
439 }
440 const hasDefect = findings.some(finding => finding.severity !== 'nit')
441 // "approve, with revisions before implementation" is not a ship.
442 const isQualified = /\b(?:with|but|after|once|pending|except|before|provided)\b/i.test(DECLARED.exec(head)?.[0] ? (head.split('\n').find(line => DECLARED.test(line)) ?? '') : '')
443
444 let verdict: Verdict = 'none'
445 let source: VerdictSource | null = null
446 if (word !== null && SHIP_WORDS.test(word)) {
447 verdict = isQualified && hasDefect ? 'revise' : 'ship'
448 source = 'declared'
449 } else if (word !== null && REVISE_WORDS.test(word)) {
450 verdict = 'revise'
451 source = 'declared'
452 } else if (OPENS_SHIP.test(opening) && !hasDefect) {
453 verdict = 'ship'
454 source = 'declared'
455 } else if (OPENS_REVISE.test(opening)) {
456 verdict = 'revise'
457 source = 'declared'
458 } else if (hasDefect) {
459 verdict = 'revise'
460 source = 'inferred'
461 } else if (isClean || findings.length > 0) {
462 // Nothing but nits is a ship, as PaF's own verdict rule has it.
463 verdict = 'ship'
464 source = 'inferred'
465 }
466
467
468 const summary = prose.join('\n').replace(/\n{3,}/g, '\n\n').trim()
469 return { verdict, source, findings, summary: summary === '' ? null : summary }
470}
471
472export function worstOf(findings: readonly Finding[]): Severity | null {
473 let worst: Severity | null = null
474 for (const finding of findings) {
475 if (worst === null || RANK[finding.severity] < RANK[worst]) worst = finding.severity
476 }
477 return worst
478}
479
480// "1 blocker · 2 important · 1 nit"
481export function countsText(findings: readonly Finding[]): string {
482 const count = { blocker: 0, important: 0, finding: 0, nit: 0 }
483 for (const finding of findings) count[finding.severity] += 1
484 const parts: string[] = []
485 if (count.blocker) parts.push(`${count.blocker} blocker${count.blocker === 1 ? '' : 's'}`)
486 if (count.important) parts.push(`${count.important} important`)
487 if (count.finding) parts.push(`${count.finding} finding${count.finding === 1 ? '' : 's'}`)
488 if (count.nit) parts.push(`${count.nit} nit${count.nit === 1 ? '' : 's'}`)
489 return parts.join(' · ')
490}
491
492// ---------------------------------------------------------------- failures
493
494// Lines a backend prints before the one that says what went wrong.
495const NOISE = [/^reading additional input from stdin/i, /^yolo mode is enabled/i, /^stderr:\s*$/i, /^warning:/i, /^\(node:\d+\)/i, /^loaded cached credentials/i]
496
497const KNOWN: [RegExp, string, string][] = [
498 [/no rollout found for thread|thread\/resume failed/i, 'session lost', 'Codex no longer has this session. Start the next round under a new --session label.'],
499 [/timed out after \d+\s*s/i, 'timed out', 'The backend ran out of time. Raise the timeout, or ask for less in one round.'],
500 [/git diff is too large/i, 'diff too large', 'The diff is over the review limit. Narrow the scope, or review commit by commit.'],
501 [/completed without producing output|no output produced|empty (?:answer|response)/i, 'no output', 'The backend finished with nothing to say, which often means a tool call was denied.'],
502 [/owner process exited/i, 'interrupted', 'The phone-a-friend process was stopped before the answer came back.'],
503 [/belongs to a different repo|different repository/i, 'wrong repo', 'This session label is tied to another repository. Use a new label here.'],
504 [/error authenticating|not logged in|unauthorized|\b401\b|ineligibletier/i, 'not signed in', 'The backend is not signed in. Run phone-a-friend doctor.'],
505 [/rate limit|\b429\b|quota|usage limit/i, 'rate limited', 'The backend hit a usage limit. Try again later, or ask another backend.'],
506 [/connection error|econnrefused|fetch failed/i, 'unreachable', 'The backend could not be reached. Check that its server is running.'],
507 [/verdict parse failed/i, 'unreadable verdict', 'The answer did not fit the verdict format. Ask again, or drop --verdict-json.'],
508]
509
510export function explain(error: string | null, status: string): Problem | null {
511 if (status !== 'failed' && status !== 'interrupted') return null
512 const whole = error ?? ''
513 const lines = whole
514 .split('\n')
515 .map(line => line.trim())
516 .filter(line => line !== '' && !NOISE.some(noise => noise.test(line)))
517 const line = (lines[0] ?? (status === 'interrupted' ? 'Interrupted before it finished.' : 'Failed without an error message.'))
518 .replace(/^(?:error|stderr):\s*/i, '')
519 .replace(/^runtime task [0-9a-f-]{8,} failed:\s*/i, '')
520 const known = KNOWN.find(([pattern]) => pattern.test(whole))
521 return { label: known?.[1] ?? (status === 'interrupted' ? 'interrupted' : 'failed'), line, hint: known?.[2] ?? null }
522}
523
524// ---------------------------------------------------------------- rounds
525
526const DEFAULT_REVIEW = /^(?:please\s+)?review\s+(?:the\s+|this\s+)?(?:changes|diff|branch\s+\S+\s+(?:against|vs)|working tree|code)\b/i
527const ROUND_HEAD =
528 /^((?:round|pass|gate|phase|step|iteration)\s+[\w.]+(?:,\s*(?:pass|round)\s+\w+)?(?:\s*\([^)]{1,24}\))?(?:\s+(?:for|on|of)\s+[^.:;,]{1,40}?)?)\s*(?:[.:;,—–-]\s|$)/i
529const LABEL_PREFIX = /^(?:user request|request|task|question|prompt|context)\s*:\s*/i
530const TITLE_CHARS = 46
531
532const askedOf = (task: PafTask): Asked =>
533 task.kind === 'review'
534 ? 'diff'
535 : /\b(review|round|pass|gate|findings?|defects?|verdict|sign[- ]off|blockers?)\b/i.test(task.promptPreview ?? '')
536 ? 'review'
537 : 'other'
538
539// The opening clause of the prompt names the round: "Round 3 for #400",
540// "Gate A, pass 2", "Delta after your ship at b86b56f".
541export function titleOf(prompt: string, kind: string, reviewScope: string | null, backend: string): string {
542 const preview = prompt.replace(/\s+/g, ' ').trim().replace(LABEL_PREFIX, '')
543 if (preview === '' || DEFAULT_REVIEW.test(preview)) {
544 if (kind === 'review' || preview !== '') {
545 return reviewScope === 'working-tree' ? 'Working tree review' : reviewScope === 'all' ? 'Review of all changes' : 'Branch review'
546 }
547 return `${backend} ${kind}`
548 }
549 let title = ROUND_HEAD.exec(preview)?.[1] ?? null
550 if (title === null) {
551 title = preview
552 const sentence = /[.!?](?:\s|$)/.exec(title)
553 if (sentence && sentence.index >= 8) title = title.slice(0, sentence.index)
554 const colon = title.indexOf(': ')
555 if (colon >= 6 && colon <= TITLE_CHARS && !title.slice(0, colon).includes('(')) title = title.slice(0, colon)
556 }
557 title = title.replace(/[\s,;:—–-]+$/, '')
558 if (title.length > TITLE_CHARS) {
559 const cut = title.slice(0, TITLE_CHARS)
560 const space = cut.lastIndexOf(' ')
561 title = (space > 28 ? cut.slice(0, space) : cut).replace(/[\s,;:(—–-]+$/, '')
562 // Stop before a parenthesis the cut left open.
563 const paren = title.lastIndexOf('(')
564 title = paren > 12 && !title.slice(paren).includes(')') ? title.slice(0, paren).trim() : `${title}…`
565 }
566 // "ROUND 3 — final sign-off" reads better as a sentence.
567 if (/^[A-Z]{4,}\b/.test(title)) {
568 title = title.replace(/\b[A-Z]{4,}\b/g, word => word.toLowerCase())
569 title = title.charAt(0).toUpperCase() + title.slice(1)
570 }
571 return title
572}
573
574export const roundTitle = (task: PafTask): string => titleOf(task.promptPreview ?? '', task.kind, task.reviewScope, task.backend)
575
576// What the reviewer was asked beyond the title: the rest of the prompt PaF kept (its first 200 characters).
577export function questionOf(round: Round): string {
578 const preview = (round.task.promptPreview ?? '').replace(/\s+/g, ' ').trim().replace(LABEL_PREFIX, '')
579 if (preview === '' || DEFAULT_REVIEW.test(preview)) return ''
580 const title = round.title.replace(/…$/, '')
581 const rest = preview.toLowerCase().startsWith(title.toLowerCase()) ? preview.slice(title.length) : preview
582 return rest.replace(/^[\s.,;:!?—–-]+/, '').trim()
583}
584
585export function toRound(task: PafTask): Round {
586 const answer = parseAnswer(task.result, askedOf(task))
587 const problem = explain(task.error, task.status)
588 let state: RoundState
589 if (isActive(task)) state = 'live'
590 else if (problem !== null) state = 'failed'
591 else if (answer.verdict === 'ship') state = 'ship'
592 else if (answer.verdict === 'revise') state = 'revise'
593 else state = 'answered'
594 return {
595 task,
596 title: roundTitle(task),
597 state,
598 answer,
599 worst: worstOf(answer.findings),
600 problem,
601 startedAt: parseTime(task.startedAt) ?? parseTime(task.createdAt),
602 finishedAt: parseTime(task.finishedAt),
603 }
604}
605
606// Parsing an answer is the costly part and a finished task never changes, so
607// rounds are kept by task id and by what would change them.
608const kept = new Map<string, { stamp: string; round: Round }>()
609
610export function roundOf(task: PafTask): Round {
611 const stamp = `${task.status}|${task.finishedAt ?? ''}|${task.result?.length ?? 0}|${task.error?.length ?? 0}`
612 const hit = kept.get(task.id)
613 if (hit && hit.stamp === stamp) return hit.round.task === task ? hit.round : { ...hit.round, task }
614 const round = toRound(task)
615 if (kept.size > 600) kept.clear()
616 kept.set(task.id, { stamp, round })
617 return round
618}
619
620// ---------------------------------------------------------------- threads
621
622// ".herdr/worktrees/<repo>/worktree-green-river-fb03" is repo + "green-river".
623export function placeOf(path: string | null): { repo: string; worktree: string | null } {
624 if (!path) return { repo: 'unknown', worktree: null }
625 const parts = path.split('/').filter(Boolean)
626 const last = parts[parts.length - 1] ?? path
627 const marker = parts.findIndex(part => part === 'worktrees' || part === 'workspaces')
628 if (marker >= 0 && parts.length > marker + 1) {
629 // "<repo>/.claude/worktrees/<name>" is a worktree of <repo>.
630 if (marker >= 2 && parts[marker - 1]?.startsWith('.') && parts[marker - 1] !== '.herdr') {
631 return { repo: parts[marker - 2] ?? last, worktree: parts[marker + 1] ?? null }
632 }
633 if (parts.length > marker + 2) {
634 const name = (parts[marker + 2] ?? '').replace(/^worktree-/, '').replace(/-[0-9a-f]{4}$/, '')
635 return { repo: parts[marker + 1] ?? last, worktree: name || null }
636 }
637 }
638 return { repo: last, worktree: null }
639}
640
641// The thread a task belongs to. Every task listed is of this repository (git
642// confirmed it), so a branch names one thread across its worktrees; work on a
643// detached HEAD is one thread per worktree.
644export const threadKeyOf = (branch: string | null, repoPath: string | null): string => branch ?? `detached:${repoPath ?? ''}`
645
646// Everything asked about one branch is one thread, whoever was asked.
647export function toThreads(tasks: readonly PafTask[]): Thread[] {
648 const groups = new Map<string, PafTask[]>()
649 for (const task of tasks) {
650 const key = threadKeyOf(task.branch, task.repoPath)
651 const group = groups.get(key)
652 if (group) group.push(task)
653 else groups.set(key, [task])
654 }
655
656 const threads: Thread[] = []
657 for (const [key, group] of groups) {
658 const rounds = group.map(roundOf).sort((a, b) => (a.startedAt ?? 0) - (b.startedAt ?? 0))
659 const last = rounds[rounds.length - 1]
660 const first = rounds[0]
661 if (!last || !first) continue
662 const place = placeOf(last.task.repoPath)
663 const repo = place.repo
664 threads.push({
665 key,
666 title: last.task.branch ?? `detached HEAD (${place.worktree ?? repo})`,
667 repo,
668 backends: [...new Set(rounds.map(round => round.task.backend))],
669 labels: [...new Set(rounds.map(round => round.task.sessionLabel).filter((label): label is string => label !== null))],
670 rounds,
671 state: rounds.some(round => round.state === 'live') ? 'live' : last.state,
672 firstAt: first.startedAt ?? 0,
673 lastAt: Math.max(...rounds.map(round => round.finishedAt ?? round.startedAt ?? 0)),
674 })
675 }
676 return threads.sort((a, b) => Number(b.state === 'live') - Number(a.state === 'live') || b.lastAt - a.lastAt)
677}
678
679// How long this backend usually takes for this kind of call: the median of the finished ones.
680export function usualMs(tasks: readonly PafTask[], like: PafTask): number | null {
681 const runs = tasks
682 .filter(task => task.status === 'completed' && task.backend === like.backend && task.kind === like.kind)
683 .map(task => {
684 const started = parseTime(task.startedAt)
685 const finished = parseTime(task.finishedAt)
686 return started !== null && finished !== null ? finished - started : null
687 })
688 .filter((ms): ms is number => ms !== null && ms > 0)
689 .sort((a, b) => a - b)
690 if (runs.length < 3) return null
691 return runs[Math.floor(runs.length / 2)] ?? null
692}
693
694// What to hand Claude when the person asks for a round to be acted on.
695export function handoff(thread: Thread, round: Round): string {
696 const number = thread.rounds.indexOf(round) + 1
697 const lines = [`phone-a-friend: ${round.task.backend} answered "${round.title}" (${thread.title}, round ${number}, task ${round.task.id}).`]
698 if (round.answer.source !== 'inferred' && round.answer.verdict !== 'none') lines.push(`Verdict: ${round.answer.verdict}.`)
699 if (round.answer.findings.length > 0) {
700 lines.push('', 'Findings:')
701 for (const finding of round.answer.findings) {
702 lines.push(`- [${finding.severity}]${finding.location ? ` [${finding.location}]` : ''} ${finding.text}${finding.detail ? ` ${finding.detail}` : ''}`)
703 }
704 lines.push('', 'Check each finding against the code before acting on it, then fix the ones that hold.')
705 } else {
706 lines.push('', round.task.result ?? round.problem?.line ?? '')
707 }
708 return lines.join('\n')
709}
710
711// ---------------------------------------------------------------- agents
712
713export type AgentState = 'blocked' | 'done' | 'working' | 'idle' | 'unknown'
714// What wants the person first.
715const AGENT_ORDER: AgentState[] = ['blocked', 'done', 'working', 'idle', 'unknown']
716
717export type AgentCard = {
718 agent: HerdrAgent
719 state: AgentState
720 // What the session is about: its terminal title.
721 topic: string
722 repo: string
723 worktree: string | null
724 isHere: boolean
725 since: number | null
726}
727
728// Where this session is: its worktree, and its own pane when herdr runs it.
729export type Here = { root: string; pane: string | null }
730
731export const isInside = (path: string | null, root: string): boolean => path !== null && (path === root || path.startsWith(`${root}/`))
732
733// Whether a pane is this very session: by its id when herdr gave one, else by where it works.
734export const isSelf = (agent: HerdrAgent, here: Here): boolean => (here.pane !== null ? agent.id === here.pane : isInside(agent.cwd, here.root))
735
736export function toAgentCards(agents: readonly HerdrAgent[], since: Record<string, number>, here: Here): AgentCard[] {
737 return agents
738 .map(agent => {
739 const state = (AGENT_ORDER.includes(agent.status as AgentState) ? agent.status : 'unknown') as AgentState
740 const place = placeOf(agent.cwd)
741 return {
742 agent,
743 state,
744 topic: agent.title.replace(/^[^\p{L}\p{N}]+/u, '').trim() || `${agent.agent} session`,
745 repo: agent.repo ?? place.repo,
746 worktree: place.worktree,
747 isHere: isSelf(agent, here),
748 since: since[agent.id] ?? null,
749 }
750 })
751 .sort(
752 (a, b) =>
753 AGENT_ORDER.indexOf(a.state) - AGENT_ORDER.indexOf(b.state) ||
754 Number(b.isHere) - Number(a.isHere) ||
755 (a.agent.number ?? 99) - (b.agent.number ?? 99),
756 )
757}
758types/index.d.ts 90 lines1export type PafStatus = 'queued' | 'running' | 'completed' | 'failed' | 'interrupted'
2
3// The fields of `phone-a-friend task list --json` this mod reads.
4export type PafTask = {
5 id: string
6 kind: string
7 status: PafStatus
8 backend: string
9 model: string | null
10 sandbox: string | null
11 repoPath: string | null
12 branch: string | null
13 reviewScope: string | null
14 reviewBase: string | null
15 diffFiles: number | null
16 driftDetected: boolean | null
17 sessionLabel: string | null
18 backendSessionId: string | null
19 promptPreview: string | null
20 result: string | null
21 error: string | null
22 createdAt: string | null
23 startedAt: string | null
24 finishedAt: string | null
25}
26
27// One agent pane as herdr lists it, with the workspace it sits in.
28export type HerdrAgent = {
29 id: string
30 agent: string
31 status: string
32 cwd: string | null
33 title: string
34 isFocused: boolean
35 // The workspace's number in herdr, its repository, and its label.
36 number: number | null
37 repo: string | null
38 workspace: string | null
39}
40
41// One step of a running call's event log: seconds since it started, and the line.
42export type TimelineEntry = { at: number; text: string }
43
44// `repo` is this worktree; `project` is every worktree of the same repository.
45export type PafScope = 'repo' | 'project'
46export type PafView = 'calls' | 'agents'
47
48// When one of this session's calls began, and what it asked (backend, label, prompt).
49// `task`, once a call in the foreground has ended: the task it printed, or null for none.
50export type PafCallStart = { at: number; key: string; task?: string | null; isBackground?: boolean }
51
52// A phone-a-friend call this session's own turn is waiting on, by its tool call.
53export type PafWaiting = { id: string; backend: string; title: string }
54
55declare module 'claude-code' {
56 interface PluginState {
57 'paf-tasks': {
58 tasks: PafTask[]
59 timeline: Record<string, TimelineEntry[]>
60 frame: number
61 polledAt: number
62 problem: string | null
63 thread: string | null
64 round: string | null
65 isFull: boolean
66 isAllRounds: boolean
67 isAllBranches: boolean
68 scope: PafScope
69 agents: HerdrAgent[]
70 agentsSince: Record<string, number>
71 agentsProblem: string | null
72 view: PafView
73 waiting: PafWaiting[]
74 // When each of this session's own calls began, by tool call: a call's
75 // line can tick before PaF's record of it shows up, or without one.
76 callStarts: Record<string, PafCallStart>
77 // Where this session is: its worktree, the repository's worktrees, its pane in herdr.
78 here: string | null
79 roots: string[]
80 // Worktrees past the panel's limit, which are not asked about.
81 skippedWorktrees: number
82 // The branch checked out here; null on a detached HEAD.
83 branch: string | null
84 pane: string | null
85 // Whether the panel is on screen: placed, and the tab that shows.
86 isOnScreen: boolean
87 }
88 }
89}
90