SLOPSHOPPER

Phone a Friend panel

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…

newpanebandspinnerrowsguard
★ 10v4.13.5Apache-2.0updated 2026-10-08freibergergarcia/phone-a-friend/mods/paf-tasks
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · paf-tasks
│ ┃ phone-a-friend ✕ › fix the failing auth test and add an audit log call │ ┃ ◇ phone-a-friend 1: Reviews 2: Agents │ ┃ ⏺ Read(src/auth.ts) │ ┃ This folder changed repository while it was ⎿ Read 6 lines │ ┃ being read. Asking again shortly. ⏺ Update(src/auth.ts) │ ┃ ⎿ Added 2 lines, removed 1 line │ ┃ w: all worktrees r: refresh ⏺ Bash(bun test) │ ⎿ 3 pass, 1 fail │ │ ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ › /paf │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · phone-a-friend
◇ phone-a-friend 1: Reviews 2: Agents This folder changed repository while it was being read. Asking again shortly. w: all worktrees r: refresh
README

paf-tasks: phone-a-friend, made legible

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.

Install

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:

NeedsWhy
Claude Code 2.1.287 or newermods are on by default from that version (claude --version)
phone-a-friend 4.5.0 or newer on PATHthe 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.

What you see

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):

  • the trail, one mark per round, oldest to newest (✗ ✓ ✗ ✗ ✓): whether the review is converging;
  • the rounds, newest first, each with its verdict;
  • the open round: what was asked, the findings with severity and location, or for a round still running, its steps and how long this usually takes;
  • other branches of this worktree; one with nothing newer than a week is folded under +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.

Keys (panel focused: /paf, a click, or ctrl+x tab)

KeyDoes
j / kolder / newer round
mmore / fewer rounds
ffull answer / less
ssend the round's findings to Claude
ccopy the reviewer's answer
bnext branch
wall worktrees / this worktree only (the default)
rrefresh now
1 / 2Reviews / Agents

/paf agents, /paf reviews, /paf close.

Marks

MarkMeans
✓ greenship: the reviewer said so, or had nothing to fix
✗ redfindings, at least one blocker
✗ yellowfindings, none a blocker
· grayan answer that is not a review
! redthe call failed (the row says why, the round says what to do)
spinnerstill 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.

Options (/plugin → paf-tasks, or /config)

OptionValuesDefault
panelboth, calls, agentsboth
auto_openopen the panel once there are reviews to showtrue
bandauto (only while the panel is hidden), always, neverauto
transcriptdraw calls in the conversationtrue

What it reads

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

Development

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.

Source 4 files
hooks/register.tsx 1588 lines
1import { 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 lines
1// 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}
488
hooks/model.ts 758 lines
1// 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}
758
types/index.d.ts 90 lines
1export 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