Tells the delivery lead's session when a team agent is waiting for input, and nudges it when team agents have been idle 15 minutes or more

A Claude Code mod that tells the delivery lead when a team agent is stuck waiting for input, such as a permission prompt, so nobody has to watch every pane, and nudges the delivery lead when team agents have been idle for fifteen minutes, so the team stays active.
It loads in every session on the machine and acts only in the one whose identity the roster gives the delivery-lead role. A session whose root holds no agents/roster.json (the house assistant's, or one started outside a checkout), or whose identity is not the delivery lead's, has nothing to watch: it shows no status line at all and logs nothing. The line exists only in the session that watches. Every ten seconds it reads each live session's ~/.claude/sessions/<pid>.json (a file whose process has gone, or whose pid now belongs to another process, is skipped). Each session is named by the GIT_AUTHOR_EMAIL its launch line set, looked up in agents/roster.json, or failing that by its session name. An active agent of any other role that has been waiting for 25 seconds is reported once, e.g. Wren waiting: permission prompt (proj:@1.%2), in a message marked as coming from the mod and not the owner; it is reported again only after it has moved on and starts waiting afresh. Nothing is sent while the delivery lead is mid-turn, so a wait that ends meanwhile is never reported. The delivery lead's status bar shows watching N, M waiting (the engine prefixes every mod's line with the mod's name).
It is also the nudger. Every five minutes, in the delivery lead's session, it reads the rows the context publisher writes (docs/context-publisher.md: CONTEXT_DIR, else ~/.local/state/<HARNESS_PROJECT>/context, resolved from the environment and harness.env as scripts/lib/harness-env.sh does, the project defaulting to the name of the MAIN checkout even when the lead's session root is a linked worktree such as strength-carl; ignoring any row older than two minutes). When that directory does not exist the status bar says context dir missing: <path> beside the watch count and the debug log names the two settings to check, because a silent hold on a wrong path looks exactly like a quiet team. When active agents of any other role have been idle for fifteen minutes or more (LEAD_NUDGE_THRESHOLD_S in harness.env or the environment), it queues one prompt into the delivery lead's session, e.g. [Automated nudge 1 of 2 from the waiting-agents mod, not from the owner; it assigns nothing.] Team agents idle for 15 minutes or more: Wren, developer idle 19m. As delivery lead, check each one and keep the team active. A queued prompt runs as a turn of its own once the session is idle and never touches the prompt box, so nothing the owner is typing there is disturbed. The same set of idle agents is nudged at most twice, a full threshold apart; the count survives a restart through the mod's store and is cleared once nobody is idle, so the bound covers one idle spell and the next starts afresh; a different set is a new state. It never prods the idle agents and assigns nothing. Every verdict, holds included, is appended to lead-nudge.log beside the context directory.
Install it for every session on the machine by naming the mods folder in the env block of ~/.claude/settings.json (each subfolder holding .claude-plugin/plugin.json loads as a mod, so every mod in this repo installs with the one line; naming this mod's own folder still works):
{ "env": { "CLAUDE_CODE_PLUGIN_DIRS": "/path/to/harness/mods" } }
It takes effect in sessions started after that. The session files are read from Claude Code's config directory, $CLAUDE_CONFIG_DIR when set and ~/.claude otherwise, so the mod's folder can live anywhere; a poll that cannot find them logs the error once per poll in the delivery lead's session and sends nothing.
hooks/watch.ts reads the roster and the session files and decides who is due a report; hooks/nudge.ts decides who is idle and whether a nudge may go; hooks/register.ts polls and sends; hooks/waiting.test.ts and hooks/nudge.test.ts hold the tests.claude plugin validate mods/waiting-agents and claude plugin test mods/waiting-agents check it. scripts/verify.sh runs the tests on every PR through scripts/check-mods-tests-pass.sh. .claude-plugin/types/, which tsconfig.json extends, is written by Claude Code when it loads the mod and is not committed.hooks/register.ts 175 lines1import type { EngineInterface, Register } from 'claude-code'
2import { type ContextRow, DEFAULT_THRESHOLD_S, MAX_NUDGES, decide, describe, envFileValue, freshRows, idleAgents, type NudgeState, nudgeText, stateKey, statePaths } from './nudge.ts'
3import { authorEmailOf, current, dueWaiting, forget, parseRoster, procStartOf, type Roster, type SessionFile } from './watch.ts'
4
5const POLL_MS = 10_000
6// How often the nudger looks at the team, in the delivery lead's session.
7const NUDGE_EVERY_MS = 300_000
8// The store key holding the NudgeState, so the two-nudge bound survives a session restart.
9const NUDGE_STATE = 'nudgeState'
10
11// Where Claude Code writes `<pid>.json` for every live session: under its config directory, which is
12// `$CLAUDE_CONFIG_DIR` when set and `~/.claude` otherwise.
13//
14// ⚠ NOT RELATIVE TO THE PLUGIN'S OWN DIRECTORY. The first version used `${$.plugin.root}/../../sessions`,
15// which reaches ~/.claude/sessions only when the mod sits two levels under ~/.claude. Installed the way
16// the README says (CLAUDE_CODE_PLUGIN_DIRS=/path/to/harness/mods/waiting-agents) it resolved to
17// /path/to/harness/sessions and every poll in the delivery lead's session logged
18// `$.fs.list(...) failed: ENOENT` (2026-10-15). The tests passed because their fake filesystem answered
19// any path ending in /sessions; they now answer only the real one.
20export async function sessionsDir($: EngineInterface): Promise<string> {
21 const config = await $.env.get('CLAUDE_CONFIG_DIR')
22 if (config) return `${config}/sessions`
23 const home = await $.env.get('HOME')
24 if (!home) throw new Error('neither CLAUDE_CONFIG_DIR nor HOME is set, so the sessions directory cannot be found')
25 return `${home}/.claude/sessions`
26}
27
28// The session files of live Claude processes: a file whose pid has gone, or now belongs to another
29// process, is left out. Each is named by its launch line's GIT_AUTHOR_EMAIL in the roster, or failing
30// that by the session's own name.
31async function sessionFiles($: EngineInterface, roster: Roster): Promise<SessionFile[]> {
32 const dir = await sessionsDir($)
33 const live: SessionFile[] = []
34 for (const entry of await $.fs.list(dir)) {
35 if (!/^\d+\.json$/.test(entry.name)) continue
36 const pid = entry.name.slice(0, -5)
37 const stat = await $.fs.read(`/proc/${pid}/stat`).catch(() => undefined)
38 if (typeof stat !== 'string') continue
39 const text = await $.fs.read(`${dir}/${entry.name}`).catch(() => undefined)
40 if (typeof text !== 'string') continue
41 let file: SessionFile
42 try { file = JSON.parse(text) as SessionFile } catch { continue }
43 if (file.procStart !== procStartOf(stat)) continue
44 const environ = await $.fs.read(`/proc/${pid}/environ`).catch(() => undefined)
45 const email = typeof environ === 'string' ? authorEmailOf(environ) : undefined
46 const name = (email && roster.byEmail.get(email)) || file.name
47 live.push({ ...file, name })
48 }
49 return live
50}
51
52// One poll: does nothing unless this session is the delivery lead's. Alerts
53// are sent only while that session is not mid-turn, so a message is never
54// queued behind a long turn: each poll re-reads the files, and an agent that
55// stopped waiting in the meantime is simply not reported.
56type Nudger = { nextAt: number; problem?: string }
57
58async function poll($: EngineInterface, told: Set<string>, nudger: Nudger): Promise<void> {
59 // The mod loads in EVERY session on the machine (CLAUDE_CODE_PLUGIN_DIRS names the mods folder),
60 // including ones whose root is not a strength checkout: the house assistant's, or a developer's
61 // started in ~/dev. There is no roster to read there, so there is nothing to watch, and nothing
62 // is shown: the status line exists only in the session that actually watches (owner, 2026-10-16;
63 // the first fix said so in the status bar, with a ⚠ the owner did not want). No ENOENT is logged
64 // either (Alfred, 2026-10-16).
65 const rosterPath = `${await $.session.root()}/agents/roster.json`
66 const rosterText = await $.fs.read(rosterPath).catch(() => undefined)
67 if (typeof rosterText !== 'string') { $.ui.status(undefined); return }
68 const roster = parseRoster(rosterText)
69 const files = await sessionFiles($, roster)
70 const id = await $.session.id()
71 const mine = files.find(f => f.sessionId === id)
72 if (!mine?.name || !roster.lead.has(mine.name)) { $.ui.status(undefined); return }
73 const agents = current(files)
74 forget(agents, told)
75 const isFree = mine.status !== 'busy' && mine.status !== 'waiting'
76 const lines = isFree ? dueWaiting(agents, roster.team, told, await $.clock.now()) : []
77 const waiting = [...roster.team].filter(n => agents.get(n)?.status === 'waiting').length
78 // The nudger's standing problem, when it has one, rides on the same line so it is never silent.
79 $.ui.status(`watching ${roster.team.size}, ${waiting} waiting${nudger.problem ? `; ${nudger.problem}` : ''}`)
80 if (lines.length > 0) {
81 await $.prompt.submit({
82 text: ['[waiting-agents mod, not the owner] A team agent is waiting for input:', ...lines].join('\n'),
83 })
84 return
85 }
86 const now = await $.clock.now()
87 if (now < nudger.nextAt) return
88 nudger.nextAt = now + NUDGE_EVERY_MS
89 nudger.problem = await nudge($, roster, isFree, now)
90}
91
92// One line, prefixed with the local time, appended to the nudger's log. Every verdict is logged,
93// holds included, so "did the nudger fire" is answered from the file.
94async function logVerdict($: EngineInterface, path: string, nowMs: number, text: string): Promise<void> {
95 const d = new Date(nowMs)
96 const p = (n: number) => String(n).padStart(2, '0')
97 const stamp = `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())} ${p(d.getHours())}:${p(d.getMinutes())}:${p(d.getSeconds())}`
98 const before = await $.fs.read(path).catch(() => '')
99 await $.fs.write(path, `${typeof before === 'string' ? before : ''}${stamp} ${text}\n`)
100}
101
102// The nudger: tells the delivery lead, as a queued prompt of its own, which team agents have been idle
103// for the threshold or more. A queued prompt never touches the prompt box, so nothing the owner is
104// typing in this session is disturbed. Idleness comes from the rows the context publisher writes
105// (docs/context-publisher.md); where they live, and the threshold, come from the environment or
106// harness.env as the harness scripts read them.
107//
108// Returns the problem that stops it reading the rows at all (the context directory is missing), so
109// the status line can show it; a silent hold on a misconfigured path looks exactly like a quiet team.
110async function nudge($: EngineInterface, roster: Roster, isFree: boolean, now: number): Promise<string | undefined> {
111 const root = await $.session.root()
112 const envFileText = await $.fs.read(`${root}/harness.env`).catch(() => undefined)
113 const envFile = typeof envFileText === 'string' ? envFileText : undefined
114 // A linked worktree's `.git` is a one-line file naming the main checkout; a main checkout's is a
115 // directory, and the read fails.
116 const gitFileText = await $.fs.read(`${root}/.git`).catch(() => undefined)
117 const home = (await $.env.get('HOME')) ?? ''
118 const paths = statePaths(home, root, {
119 CONTEXT_DIR: (await $.env.get('CONTEXT_DIR')) || undefined,
120 HARNESS_PROJECT: (await $.env.get('HARNESS_PROJECT')) || undefined,
121 }, envFile, typeof gitFileText === 'string' ? gitFileText : undefined)
122 const thresholdS = Number((await $.env.get('LEAD_NUDGE_THRESHOLD_S')) || envFileValue(envFile, 'LEAD_NUDGE_THRESHOLD_S')) || DEFAULT_THRESHOLD_S
123 const entries = await $.fs.list(paths.contextDir).catch(() => undefined)
124 if (entries === undefined) {
125 const problem = `context dir missing: ${paths.contextDir}`
126 $.ui.log(`${problem} (set CONTEXT_DIR or HARNESS_PROJECT; see docs/context-publisher.md)`)
127 return problem
128 }
129 const rows: ContextRow[] = []
130 for (const entry of entries) {
131 if (!entry.name.endsWith('.json')) continue
132 const text = await $.fs.read(`${paths.contextDir}/${entry.name}`).catch(() => undefined)
133 try { if (typeof text === 'string') rows.push(JSON.parse(text) as ContextRow) } catch { continue }
134 }
135 const idle = idleAgents(roster.roleOf, freshRows(rows, now), thresholdS)
136 if (idle.length === 0) {
137 // The team is active, so the bound has covered one idle spell: the next spell, of the same agents
138 // or others, starts afresh. Without this a set nudged twice stayed held for good.
139 if ((await $.store.get(NUDGE_STATE)) !== undefined) await $.store.delete(NUDGE_STATE)
140 await logVerdict($, paths.log, now, `hold: no team agent idle for ${thresholdS}s or more`)
141 return
142 }
143 if (!isFree) { await logVerdict($, paths.log, now, `hold: delivery lead is mid-turn; idle: ${describe(idle)}`); return }
144 const key = stateKey(idle)
145 const prev = (await $.store.get(NUDGE_STATE)) as NudgeState | undefined
146 const verdict = decide(key, prev, now, thresholdS)
147 if (!verdict.send) { await logVerdict($, paths.log, now, `hold: ${verdict.why}; idle: ${describe(idle)}`); return }
148 await $.prompt.submit({ text: nudgeText(verdict.count, idle, thresholdS) })
149 await $.store.set(NUDGE_STATE, { key, count: verdict.count, lastMs: now } satisfies NudgeState)
150 await logVerdict($, paths.log, now, `sent nudge ${verdict.count} of ${MAX_NUDGES}; idle: ${describe(idle)}`)
151 return
152}
153
154// Loaded into every session, it acts only in the one the roster names as the
155// delivery lead, polling ~/.claude/sessions every ten seconds and looking for
156// idle team agents every five minutes.
157export const register: Register = on => {
158 const told = new Set<string>()
159 const nudger: Nudger = { nextAt: 0 }
160 let timer: { cancel: () => void } | undefined
161 let busy = false
162
163 on('session.start', async ($, e, next) => {
164 timer?.cancel()
165 timer = $.clock.every(POLL_MS, () => {
166 if (busy) return
167 busy = true
168 poll($, told, nudger)
169 .catch(err => $.ui.log(String(err)))
170 .finally(() => { busy = false })
171 })
172 return next(e)
173 })
174}
175hooks/nudge.ts 128 lines1// The pure part of the nudger: which team agents have been idle long enough to wake the delivery lead
2// for, and whether this state has already been nudged as often as it may be.
3//
4// An agent runs one turn per prompt and then stops; nothing inside its session restarts it. The
5// nudger tells the delivery lead, who decides what each one should do. It never talks to the idle
6// agents and it assigns nothing. The owner, 2026-10-02: "I only want you to have a nudger so you
7// ensure the team remain active".
8
9export const DEFAULT_THRESHOLD_S = 900
10// A context row older than this says nothing about what the agent is doing now.
11export const STALE_S = 120
12// At most this many nudges for one state (the same agents idle).
13export const MAX_NUDGES = 2
14
15// One row of the context directory (docs/context-publisher.md), published by each session's status line.
16export type ContextRow = { agent?: string; ts?: string; activity?: string; idle_s?: number | null }
17
18export type Idle = { name: string; role: string; idleS: number }
19
20// What has been sent for the current state: the state's key, how many nudges, and when the last went.
21export type NudgeState = { key: string; count: number; lastMs: number }
22
23// The value of `KEY=value` in harness.env, which scripts/lib/harness-env.sh sources: an optional
24// `export`, quotes around the value and a trailing comment are allowed; an empty value is no value.
25export function envFileValue(text: string | undefined, key: string): string | undefined {
26 for (const line of (text ?? '').split('\n')) {
27 const m = line.match(new RegExp(`^\\s*(?:export\\s+)?${key}=(.*)$`))
28 if (!m) continue
29 let value = m[1]!.trim()
30 const quoted = value.match(/^"([^"]*)"|^'([^']*)'/)
31 value = quoted ? (quoted[1] ?? quoted[2] ?? '') : value.replace(/(^|\s+)#.*$/, '').trim()
32 return value === '' ? undefined : value
33 }
34 return undefined
35}
36
37// The main checkout a linked worktree belongs to, from the one-line `.git` FILE git leaves in the
38// worktree (`gitdir: /path/to/project/.git/worktrees/<name>`); undefined for a main checkout, where
39// `.git` is a directory and there is no such text.
40export function mainCheckoutOf(gitFile: string | undefined): string | undefined {
41 const m = (gitFile ?? '').match(/^\s*gitdir:\s*(.+?)\/\.git\/worktrees\/[^/\s]+\/?\s*$/m)
42 return m?.[1]
43}
44
45// Where the publisher writes the rows and where the nudger logs, from the same settings the harness
46// scripts read: `CONTEXT_DIR`, else `~/.local/state/<HARNESS_PROJECT>/context` with the project
47// defaulting to the checkout's directory name. The log sits beside the context directory.
48//
49// ⚠ The checkout is the MAIN one, not the session's worktree. The delivery lead works in a linked
50// worktree named after themself (AGENTS.md rule 0: `../<project>-<you>`), so the session root's
51// basename is `strength-carl` while the publisher writes under `strength`. The first version took
52// the root's basename, found no rows, and logged "hold: no team agent idle" on every tick, in a log
53// directory that did not exist either (Alfred's review, 2026-10-16). `gitFile` is the text of
54// `<root>/.git` when the root is a linked worktree.
55export function statePaths(
56 home: string,
57 root: string,
58 vars: { CONTEXT_DIR?: string; HARNESS_PROJECT?: string },
59 envFile: string | undefined,
60 gitFile?: string,
61): { contextDir: string; log: string } {
62 const checkout = mainCheckoutOf(gitFile) ?? root
63 const project = vars.HARNESS_PROJECT ?? envFileValue(envFile, 'HARNESS_PROJECT') ?? checkout.slice(checkout.lastIndexOf('/') + 1)
64 const contextDir = vars.CONTEXT_DIR ?? envFileValue(envFile, 'CONTEXT_DIR') ?? `${home}/.local/state/${project}/context`
65 return { contextDir, log: `${contextDir.slice(0, contextDir.lastIndexOf('/'))}/lead-nudge.log` }
66}
67
68// The freshest row per agent, leaving out rows older than STALE_S.
69export function freshRows(rows: readonly ContextRow[], nowMs: number): Map<string, ContextRow & { ageS: number }> {
70 const best = new Map<string, ContextRow & { ageS: number }>()
71 for (const row of rows) {
72 if (!row.agent) continue
73 const ts = Date.parse(row.ts ?? '')
74 if (Number.isNaN(ts)) continue
75 const ageS = (nowMs - ts) / 1000
76 if (ageS > STALE_S) continue
77 const held = best.get(row.agent)
78 if (!held || ageS < held.ageS) best.set(row.agent, { ...row, ageS })
79 }
80 return best
81}
82
83// The role id of agents/roster.json as the message says it: `head-of-testing` reads "head of testing".
84export function roleWords(role: string): string {
85 return role.replace(/-/g, ' ')
86}
87
88// The team agents (name to role) idle for `thresholdS` or more, in roster order.
89export function idleAgents(team: ReadonlyMap<string, string>, rows: ReadonlyMap<string, ContextRow>, thresholdS: number): Idle[] {
90 const idle: Idle[] = []
91 for (const [name, role] of team) {
92 const row = rows.get(name)
93 if (row?.activity !== 'idle' || (row.idle_s ?? 0) < thresholdS) continue
94 idle.push({ name, role, idleS: Math.floor(row.idle_s ?? 0) })
95 }
96 return idle
97}
98
99// The state a nudge is about: the same agents idle is the same state, however long they stay idle.
100export function stateKey(idle: readonly Idle[]): string {
101 return idle.map(i => i.name).sort().join('\n')
102}
103
104export function describe(idle: readonly Idle[]): string {
105 return idle.map(i => `${i.name}, ${roleWords(i.role)} idle ${Math.floor(i.idleS / 60)}m`).join('; ')
106}
107
108// Whether a nudge may go now: a new state may always be nudged; the same state at most MAX_NUDGES
109// times, a full threshold apart. A bound, not a cadence: no amount of time produces a third.
110export function decide(
111 key: string,
112 prev: NudgeState | undefined,
113 nowMs: number,
114 thresholdS: number,
115): { send: true; count: number } | { send: false; why: string } {
116 const sent = prev?.key === key ? prev : undefined
117 if (!sent) return { send: true, count: 1 }
118 if (sent.count >= MAX_NUDGES) return { send: false, why: `already nudged ${MAX_NUDGES} times for this exact state` }
119 if (nowMs - sent.lastMs < thresholdS * 1000) return { send: false, why: `${thresholdS}s not yet passed since nudge ${sent.count} for this state` }
120 return { send: true, count: sent.count + 1 }
121}
122
123export function nudgeText(count: number, idle: readonly Idle[], thresholdS: number): string {
124 return `[Automated nudge ${count} of ${MAX_NUDGES} from the waiting-agents mod, not from the owner; it assigns nothing.] `
125 + `Team agents idle for ${Math.floor(thresholdS / 60)} minutes or more: ${describe(idle)}. `
126 + 'As delivery lead, check each one and keep the team active.'
127}
128hooks/watch.ts 99 lines1// The pure part of the watcher: reading the roster and the session files,
2// and deciding who has just started waiting.
3
4export type Roster = {
5 lead: ReadonlySet<string>
6 team: ReadonlySet<string>
7 // GIT_AUTHOR_EMAIL -> name: the launch line's identity, as agents/roster.json records it.
8 byEmail: ReadonlyMap<string, string>
9 // name -> role id, for every team agent (the roles the nudger names), in roster order.
10 roleOf: ReadonlyMap<string, string>
11}
12
13export type SessionFile = {
14 pid: number
15 procStart?: string
16 sessionId?: string
17 name?: string | null
18 status?: string | null
19 waitingFor?: string | null
20 tmux?: string | null
21 updatedAt?: number | null
22 statusUpdatedAt?: number | null
23}
24
25// agents/roster.json: the active rows of `agents`. The delivery lead watches; every other active role
26// is watched. Assistants are kept apart in `assistants` and are never watched.
27export function parseRoster(json: string): Roster {
28 const lead = new Set<string>()
29 const team = new Set<string>()
30 const byEmail = new Map<string, string>()
31 const roleOf = new Map<string, string>()
32 const doc = JSON.parse(json) as { agents?: { name?: string; email?: string; role?: string; status?: string }[] }
33 for (const a of doc.agents ?? []) {
34 if (a.status !== 'active' || !a.name) continue
35 if (a.email) byEmail.set(a.email, a.name)
36 if (a.role === 'delivery-lead') lead.add(a.name)
37 else { team.add(a.name); roleOf.set(a.name, a.role ?? 'agent') }
38 }
39 return { lead, team, byEmail, roleOf }
40}
41
42// The GIT_AUTHOR_EMAIL in a process's NUL-separated /proc/<pid>/environ, if it has one.
43export function authorEmailOf(environ: string): string | undefined {
44 for (const kv of environ.split('\0')) {
45 if (kv.startsWith('GIT_AUTHOR_EMAIL=')) return kv.slice('GIT_AUTHOR_EMAIL='.length)
46 }
47 return undefined
48}
49
50// Field 22 of /proc/<pid>/stat, the process's start time: the session file's
51// `procStart`, so a pid reused by another process is not taken for the session.
52export function procStartOf(stat: string): string | undefined {
53 return stat.slice(stat.lastIndexOf(')') + 2).split(' ')[19]
54}
55
56// Several files can name one agent (old ones stay behind): the live one with
57// the newest `updatedAt` stands for it.
58export function current(files: readonly SessionFile[]): Map<string, SessionFile> {
59 const byName = new Map<string, SessionFile>()
60 for (const f of files) {
61 if (!f.name) continue
62 const held = byName.get(f.name)
63 if (!held || (f.updatedAt ?? 0) > (held.updatedAt ?? 0)) byName.set(f.name, f)
64 }
65 return byName
66}
67
68// An agent counts as waiting once it has been waiting this long: a prompt
69// answered within seconds is not worth waking the delivery lead for.
70export const GRACE_MS = 25_000
71
72// Drops from `told` every agent that is no longer waiting, so its next wait is
73// reported afresh.
74export function forget(agents: ReadonlyMap<string, SessionFile>, told: Set<string>): void {
75 for (const name of [...told]) {
76 if (agents.get(name)?.status !== 'waiting') told.delete(name)
77 }
78}
79
80// The lines for team agents waiting for at least GRACE_MS and not yet reported,
81// each added to `told` as it is reported.
82export function dueWaiting(
83 agents: ReadonlyMap<string, SessionFile>,
84 team: ReadonlySet<string>,
85 told: Set<string>,
86 now: number,
87): string[] {
88 const lines: string[] = []
89 for (const name of team) {
90 const f = agents.get(name)
91 if (f?.status !== 'waiting' || told.has(name)) continue
92 const since = f.statusUpdatedAt ?? f.updatedAt ?? now
93 if (now - since < GRACE_MS) continue
94 told.add(name)
95 lines.push(`${name} waiting: ${f.waitingFor || 'input'} (${f.tmux || 'no pane'})`)
96 }
97 return lines
98}
99