Enforces the subagent output contract: watches each Agent dispatch's artifact file, flags a 5-minute stall, wakes an idle Claude once per stall, offers a Stop…

Claude Code mods (function-hooks plugins) and skills. Each mod lives in its own folder with a .claude-plugin/plugin.json; mods with a README explain their options there.
| Folder | What it does |
|---|---|
artifact-watchdog | Watches each Agent dispatch's report file and flags a 5-minute stall |
auto-handoff | Writes a /next handoff automatically once a long session crosses a token threshold, and before a compaction |
flight-recorder | Live timeline of model requests, tool calls and subagents in a turn |
jev-tool-gate | Log-only judgment of risky tool calls; never changes a decision |
skills/next, skills/prime | Session handoff skills, see below |
/next ends a session by writing an immutable handoff (Where we are / What we decided / Next step) to ~/handoffs/<project>/, moving a LATEST pointer, and appending each decision to the project's DECISIONS.md. /prime starts the next session from that handoff: it reads only the latest one, re-checks its claims against the live system, shows anything said after the handoff was written, and states the next step. It treats the handoff as data: handoff.mjs claims sorts each check into commands that only read, which it runs, and anything else, which it asks about first (never running it from a handoff another agent wrote). /prime history answers "why did we decide X?" from the decisions ledger.
Install:
git clone https://github.com/m2ai-portfolio/claude-mods ~/.claude/mods
cp -r ~/.claude/mods/skills/next ~/.claude/mods/skills/prime ~/.claude/skills/
The helper script needs Node 18 or newer and has no dependencies. Check it with:
node --test ~/.claude/skills/next/scripts/handoff.test.mjs
Set HANDOFF_ROOT to keep handoffs somewhere other than ~/handoffs. Models or harnesses that cannot load skills can follow ~/.claude/skills/prime/PRIME.md instead.
hooks/register.tsx 665 lines1// Artifact Watchdog: the point-of-action half of
2// ~/.claude/rules/subagent-output-contract.md clause 3 ("Bounded").
3//
4// tool.call (Agent): pull the artifact path out of the dispatch prompt and
5// start a watch; keep the async agentId so the pane can TaskStop it.
6// session.start: register /watchdog and start the poll timer.
7// Every TICK_MS: stat each live artifact. A write since dispatch resets the
8// clock, and a last line of "AGENT COMPLETE" closes the watch. The file is
9// stat'ed at dispatch first: a reused path (re-dispatching the same task
10// overwrites the same slug) may already end with an earlier run's marker, and
11// only a write by this dispatch may close its watch. STALL_MS without growth is only half
12// a stall: the agent may be alive and busy (a long test run) without
13// appending. So the agent's own activity is tracked too, from the events that
14// carry its agentId: tool.call (start, and its end when next resolves),
15// turn.step (a model request, start and end) and turn.complete. A tool call
16// or request still in flight counts as active for up to IN_FLIGHT_CAP_MS, so a
17// 6-minute build is not silence but a hung tool still trips. Quiet file +
18// active agent = "quiet": shown on the row, no toast, note or wake. Quiet
19// file + agent silent for SILENT_MS = stalled: toast, open the pane, plus a
20// note Claude reads on its next request. Activity after a stall clears it
21// back to quiet. A watch with no agentId (a foreground agent) has no activity
22// to read and keeps the file-only rule.
23// An agent that finishes without the marker ends its watch ("ended"), so a
24// dead agent's row never turns into a false stall.
25// Wake: a note waits for Claude's next request, and an idle main loop (it
26// dispatched a background agent and ended its turn) makes none. So each stall
27// also earns ONE $.prompt.submit, a turn of its own: at once when the main
28// loop is idle, else at that turn's end if the agent is still stalled and
29// running. $.session.send cannot do this: the engine refuses a send to its
30// own session. turn.start / turn.complete track whether the main loop is busy.
31// /watchdog wake off|on is the switch (session state, default on).
32// ui.render (Pane): one row per watch, a Stop button on stalled agents.
33
34import { atom, read, update } from 'claude-code'
35import type { EngineInterface, Register } from 'claude-code'
36
37import type { InFlight, Wake, Watch, WatchStatus } from '../types'
38
39const PANE = 'artifact-watchdog'
40const TICK_MS = 15_000
41const STALL_MS = 5 * 60_000
42// How long an agent may go without any event before it counts as silent.
43const SILENT_MS = 2 * 60_000
44// How long one tool call or model request may run and still count as activity.
45const IN_FLIGHT_CAP_MS = 15 * 60_000
46// An agent's activity record is dropped once it has been idle this long.
47const ACTIVITY_TTL_MS = 60 * 60_000
48// How long a pressed Stop may wait on TaskStop (a permission ask, a busy loop)
49// before Claude is told to stop the agent itself.
50const STOP_WAIT_MS = 30_000
51// Same pattern as ~/.claude/hooks/agent-output-contract.py ARTIFACT_RE.
52const ARTIFACT_RE = /(~|\/home\/apexaipc)\/\.claude\/agents\/\.artifacts\/[\w./-]+\.md/
53const MARKER_RE = /^AGENT COMPLETE:?\s*(.*)$/
54
55const watches = atom({ plugin: 'artifact-watchdog', key: 'watches' } as const, [])
56const wakeOn = atom({ plugin: 'artifact-watchdog', key: 'wake' } as const, true)
57
58let home = '/home/apexaipc'
59let isTicking = false
60// The main loop's running turn, or null when it is idle. A reload starts at
61// null, which holds: a hot reload lands when a turn ends.
62let mainTurn: string | null = null
63
64// Per subagent id: its last event and the calls it has running, keyed per
65// call. Module memory, written on every event and copied into the watches at
66// each poll (one state write per tick, not per event). A reload forgets the
67// calls in flight; the copied lastActivityAt carries over.
68type Activity = { lastAt: number; ops: Map<string, InFlight> }
69const activity = new Map<string, Activity>()
70let opSeq = 0
71
72export const register: Register = on => {
73 // Every subagent tool call, start to finish. Outermost, so it times the
74 // whole call (a permission ask included).
75 on('tool.call', async ($, e, next) => {
76 if (e.agentId === undefined) {
77 return next(e)
78 }
79 const done = await begin($, e.agentId, e.tool_use_id ?? `call-${++opSeq}`, e.tool)
80 try {
81 return await next(e)
82 } finally {
83 await done()
84 }
85 })
86
87 // A subagent's model request: thinking and writing a long tool input can
88 // take minutes with no tool call to show for it.
89 on('turn.step', async function* ($, e, next) {
90 if (e.agentId === undefined) {
91 return yield* next(e)
92 }
93 const done = await begin($, e.agentId, `step-${e.turnId}-${e.index}`, 'model request')
94 try {
95 return yield* next(e)
96 } finally {
97 await done()
98 }
99 })
100
101 on('session.start', async ($, e, next) => {
102 const result = await next(e)
103 home = (await $.env.get('HOME')) ?? home
104 await $.command.register({
105 name: 'watchdog',
106 description: 'Show subagent artifact files and flag any that stopped growing',
107 })
108 $.clock.every(TICK_MS, () => {
109 void tick($)
110 })
111 await tick($)
112
113 return result
114 })
115
116 // /watchdog opens the pane; /watchdog wake [on|off] reads or sets the wake.
117 on('command.run', { command: 'watchdog' }, async ($, e) => {
118 // args is "" for a bare /watchdog; another plugin's $.command.run may omit it.
119 const words = (e.args ?? '').trim().toLowerCase().split(/\s+/).filter(Boolean)
120 if (words[0] === 'wake' && (words[1] === 'on' || words[1] === 'off') && words.length === 2) {
121 await update($, wakeOn, () => words[1] === 'on')
122 if (words[1] === 'off') {
123 await cancelPendingWakes($)
124 }
125 return { text: `Artifact watchdog: wake on stall is ${words[1]}.` }
126 }
127 if (words[0] === 'wake' && words.length === 1) {
128 return { text: `Artifact watchdog: wake on stall is ${(await isWakeOn($)) ? 'on' : 'off'}.` }
129 }
130 if (words.length > 0) {
131 return { text: 'Usage: /watchdog (open the pane) · /watchdog wake [on|off]' }
132 }
133 await $.ui.open({ id: PANE, title: 'Artifact watchdog' })
134
135 return { text: `Artifact watchdog pane opened. Wake on stall: ${(await isWakeOn($)) ? 'on' : 'off'}.` }
136 })
137
138 // Busy or idle: a main-loop turn runs from turn.start to its turn.complete.
139 // A subagent's run raises no turn.start, and its turn.complete has agentId.
140 on('turn.start', ($, e, next) => {
141 mainTurn = e.turnId
142 return next(e)
143 })
144
145 on('turn.complete', async ($, e, next) => {
146 const result = await next(e)
147 if (e.agentId === undefined) {
148 mainTurn = null
149 // A stall noted mid-turn may never have been read: wake for it now.
150 await decideWakes($)
151 } else {
152 touch(e.agentId, await $.clock.now())
153 }
154
155 return result
156 })
157
158 on('tool.call', { tool: 'Agent' }, async ($, e, next) => {
159 const now = await $.clock.now()
160 const match = ARTIFACT_RE.exec(String(e.prompt ?? ''))
161 const path = match ? match[0].replace(/^~/, home) : null
162 // What is at the path before the agent starts: the baseline a write must change.
163 const before = path === null ? undefined : await $.fs.stat(path).catch(() => undefined)
164 const isFile = before?.kind === 'file'
165 const watch: Watch = {
166 id: e.tool_use_id ?? `agent-${now}`,
167 label: String(e.description ?? 'agent'),
168 path,
169 agentId: null,
170 startedAt: now,
171 lastSize: isFile ? before.size : 0,
172 lastMtimeMs: isFile ? before.mtimeMs : null,
173 lastGrowthAt: now,
174 status: match ? 'waiting' : 'missing',
175 summary: match ? '' : 'prompt names no artifact path',
176 lastActivityAt: now,
177 inFlight: null,
178 }
179 await update($, watches, list => [...list.filter(w => w.id !== watch.id), watch].slice(-50))
180 showStatus($, await currentWatches($))
181
182 const ran = await next(e)
183 const launched = ran.result as { status?: string; agentId?: string } | undefined
184 if (launched?.status === 'async_launched' && launched.agentId) {
185 const agentId = launched.agentId
186 await update($, watches, list => list.map(w => (w.id === watch.id ? { ...w, agentId } : w)))
187 } else {
188 // A foreground agent has finished: check its file now, not in 15s. If the
189 // marker is still absent the watch ends here, or it would stall later.
190 await tick($)
191 await update($, watches, list =>
192 list.map(w => (w.id === watch.id && isLive(w) ? endedWatch(w, 'finished') : w)),
193 )
194 showStatus($, await currentWatches($))
195 }
196
197 return ran
198 })
199
200 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
201 const { Box, Text, Button } = $.ui.resolve(e)
202 const list = await read($, watches)
203 const isWaking = await read($, wakeOn)
204 const now = await $.clock.now()
205 const room = Math.max(1, (e.viewport?.rows ?? 24) - 4)
206 const isDone = (w: Watch) => w.status === 'complete' || w.status === 'stopped' || w.status === 'ended'
207
208 return (
209 <Box flexDirection="column">
210 <Box flexDirection="row">
211 <Text dimColor>
212 Stall after {STALL_MS / 60_000} min without growth and {SILENT_MS / 60_000} min of agent silence ·
213 polled every {TICK_MS / 1000}s · wake{' '}
214 {isWaking ? 'on' : 'off'}{' '}
215 </Text>
216 {list.some(isDone) && (
217 <Button
218 key="clear"
219 label="Clear finished"
220 plain
221 onPress={() => update($, watches, l => l.filter(w => !isDone(w)))}
222 />
223 )}
224 </Box>
225 {list.length === 0 && <Text dimColor>No Agent dispatches this session.</Text>}
226 {list.slice(-room).map(w => (
227 <Box flexDirection="row">
228 <Text color={COLOR[w.status]} bold={w.status === 'stalled'}>
229 {GLYPH[w.status]} {w.status.padEnd(8)}
230 </Text>
231 <Text> {w.label} </Text>
232 <Text dimColor>
233 {detail(w, now)}
234 </Text>
235 {w.status === 'stalled' && w.agentId !== null && (
236 <Button
237 key={`stop-${w.id}`}
238 label="Stop"
239 variant="primary"
240 onPress={() => stop($, w)}
241 />
242 )}
243 </Box>
244 ))}
245 </Box>
246 )
247 })
248}
249
250const GLYPH: Record<WatchStatus, string> = {
251 waiting: '…',
252 growing: '▲',
253 quiet: '◇',
254 stalled: '■',
255 stopping: '◌',
256 complete: '✓',
257 stopped: '×',
258 ended: '–',
259 missing: '?',
260}
261
262const COLOR: Record<WatchStatus, string> = {
263 waiting: 'gray',
264 growing: 'green',
265 quiet: 'yellow',
266 stalled: 'red',
267 stopping: 'yellow',
268 complete: 'cyan',
269 stopped: 'gray',
270 ended: 'yellow',
271 missing: 'yellow',
272}
273
274function detail(w: Watch, now: number): string {
275 if (!isLive(w)) {
276 const stalled = w.stalledAt !== undefined && w.status !== 'stopping' ? ' (stalled earlier)' : ''
277 // A finished row keeps what its wake came to; a pending one never went out.
278 const woke = w.wake && (w.wake.state === 'sent' || w.wake.state === 'failed') ? ` · ${w.wake.detail}` : ''
279 return `${w.summary}${stalled}${woke}`
280 }
281 const idle = Math.round((now - w.lastGrowthAt) / 1000)
282 const file = w.path ? w.path.split('/').pop() : ''
283 const quiet = w.status === 'quiet' ? ' · quiet file, agent active' : ''
284 const woke = w.status === 'stalled' && w.wake ? ` · ${w.wake.detail}` : ''
285 const note = w.summary ? ` · ${w.summary}` : ''
286 return `${file} · ${kb(w.lastSize)} · idle ${idle}s${agentDetail(w, now)}${quiet}${woke}${note}`
287}
288
289// The agent half of a live row: what is running now, else its last event.
290function agentDetail(w: Watch, now: number): string {
291 if (w.agentId === null) {
292 return ''
293 }
294 if (w.inFlight) {
295 return ` · ${w.inFlight.what} running ${Math.round((now - w.inFlight.since) / 1000)}s`
296 }
297 const at = w.lastActivityAt ?? w.startedAt
298 return ` · last activity ${Math.round((now - at) / 1000)}s ago`
299}
300
301// Watches the poll still owns. A stopping row belongs to stop() until TaskStop answers.
302function isLive(w: Watch): boolean {
303 return w.status === 'waiting' || w.status === 'growing' || w.status === 'quiet' || w.status === 'stalled'
304}
305
306// Records one call starting in a subagent's loop; the returned function
307// records its end. Both count as activity.
308async function begin($: EngineInterface, agentId: string, key: string, what: string): Promise<() => Promise<void>> {
309 const since = await $.clock.now()
310 const record = touch(agentId, since)
311 record.ops.set(key, { what, since })
312 return async () => {
313 record.ops.delete(key)
314 touch(agentId, await $.clock.now().catch(() => since))
315 }
316}
317
318function touch(agentId: string, at: number): Activity {
319 const record = activity.get(agentId) ?? { lastAt: at, ops: new Map<string, InFlight>() }
320 record.lastAt = Math.max(record.lastAt, at)
321 activity.set(agentId, record)
322 return record
323}
324
325// The agent's activity as of now: its last event, and its newest call still
326// running (a later start is the better sign of life).
327function activityOf(w: Watch): { lastActivityAt: number; inFlight: InFlight | null } {
328 const record = w.agentId === null ? undefined : activity.get(w.agentId)
329 const lastActivityAt = Math.max(w.lastActivityAt ?? w.startedAt, record?.lastAt ?? 0)
330 let inFlight: InFlight | null = null
331 for (const op of record?.ops.values() ?? []) {
332 if (inFlight === null || op.since > inFlight.since) {
333 inFlight = op
334 }
335 }
336 return { lastActivityAt, inFlight }
337}
338
339// Active: an event within SILENT_MS, or a call in flight for under
340// IN_FLIGHT_CAP_MS. A watch with no agentId has nothing to read: never active,
341// so it keeps the file-only rule.
342function isAgentActive(w: Watch, seen: { lastActivityAt: number; inFlight: InFlight | null }, now: number): boolean {
343 if (w.agentId === null) {
344 return false
345 }
346 if (seen.inFlight !== null && now - seen.inFlight.since < IN_FLIGHT_CAP_MS) {
347 return true
348 }
349 return now - seen.lastActivityAt < SILENT_MS
350}
351
352function endedWatch(w: Watch, agentStatus: string): Watch {
353 return { ...w, status: 'ended', summary: `agent ${agentStatus} without AGENT COMPLETE` }
354}
355
356function kb(bytes: number): string {
357 return bytes >= 1024 ? `${(bytes / 1024).toFixed(1)}k` : `${bytes}b`
358}
359
360async function currentWatches($: EngineInterface): Promise<Watch[]> {
361 const { value } = await $.state.get({ plugin: 'artifact-watchdog', key: 'watches' } as const)
362 return value ?? []
363}
364
365async function isWakeOn($: EngineInterface): Promise<boolean> {
366 const { value } = await $.state.get({ plugin: 'artifact-watchdog', key: 'wake' } as const)
367 return value ?? true
368}
369
370function showStatus($: EngineInterface, list: Watch[]): void {
371 const growing = list.filter(w => w.status === 'growing' || w.status === 'waiting' || w.status === 'quiet').length
372 const stalled = list.filter(w => w.status === 'stalled').length
373 if (growing === 0 && stalled === 0) {
374 $.ui.status(undefined)
375 return
376 }
377 const parts = [growing > 0 ? `${growing} running` : '', stalled > 0 ? `${stalled} STALLED` : '']
378 $.ui.status(`artifacts: ${parts.filter(Boolean).join(' · ')}`)
379}
380
381// One poll: stat every live artifact, then merge the readings into state.
382async function tick($: EngineInterface): Promise<void> {
383 if (isTicking) {
384 return
385 }
386 isTicking = true
387 try {
388 const now = await $.clock.now()
389 const live = (await currentWatches($)).filter(
390 w =>
391 w.path !== null &&
392 (w.status === 'waiting' ||
393 w.status === 'growing' ||
394 w.status === 'quiet' ||
395 w.status === 'stalled' ||
396 w.status === 'stopping'),
397 )
398 const changes = new Map<string, Partial<Watch>>()
399 const put = (id: string, change: Partial<Watch>) => changes.set(id, { ...changes.get(id), ...change })
400 const newlyStalled: Watch[] = []
401 let wakeIsOn: boolean | undefined
402 // An async agent that hands back without the marker would otherwise sit
403 // here until it "stalls": ask the engine which agents are still running.
404 const agents = live.some(w => w.agentId !== null) ? await $.agent.list().catch(() => undefined) : undefined
405 const agentStatus = new Map((agents ?? []).map(a => [a.id, a.status]))
406
407 for (const w of live) {
408 const status = w.agentId !== null ? agentStatus.get(w.agentId) : undefined
409 const hasEnded = isLive(w) && status !== undefined && status !== 'running'
410 const path = w.path ?? ''
411 const stat = await $.fs.stat(path).catch(() => undefined)
412 const size = stat?.kind === 'file' ? stat.size : 0
413 const mtimeMs = stat?.kind === 'file' ? stat.mtimeMs : null
414 // Written since the last reading: longer, or rewritten (shorter, or the
415 // same size with a newer mtime). An unchanged file left by an earlier
416 // dispatch never counts, so its old marker cannot close this watch.
417 const lastMtimeMs = w.lastMtimeMs ?? null
418 const isNewer = mtimeMs !== null && lastMtimeMs !== null && mtimeMs > lastMtimeMs
419 const isWritten = size > 0 && (size !== w.lastSize || isNewer)
420 if (isWritten) {
421 const text: string = await $.fs.read(path).catch(() => '')
422 const lastLine = text.trimEnd().split('\n').pop() ?? ''
423 const marker = MARKER_RE.exec(lastLine.trim())
424 put(
425 w.id,
426 marker
427 ? { status: 'complete', lastSize: size, lastMtimeMs: mtimeMs, lastGrowthAt: now, summary: marker[1] ?? '' }
428 : { status: 'growing', lastSize: size, lastMtimeMs: mtimeMs, lastGrowthAt: now, summary: '' },
429 )
430 }
431 const seen = activityOf(w)
432 if (isLive(w) && (seen.lastActivityAt !== w.lastActivityAt || seen.inFlight?.since !== w.inFlight?.since)) {
433 put(w.id, seen)
434 }
435 if (hasEnded && changes.get(w.id)?.status !== 'complete') {
436 const { status: ended, summary } = endedWatch(w, status ?? 'ended')
437 put(w.id, { status: ended, summary })
438 } else if (changes.get(w.id)?.status === undefined && isLive(w) && now - w.lastGrowthAt >= STALL_MS) {
439 if (isAgentActive(w, seen, now)) {
440 // The file is quiet but the agent is working: say so, raise nothing.
441 // A stalled row whose agent came back to life clears here too.
442 if (w.status !== 'quiet') {
443 put(w.id, { status: 'quiet' })
444 }
445 } else if (w.status !== 'stalled') {
446 if (w.lastStallAt !== undefined && w.lastStallAt >= w.lastGrowthAt) {
447 // Stalled already since the file last grew, cleared only by agent
448 // activity: the same stall back. Keep its wake; raise nothing new,
449 // or an agent that flickers between work and silence would wake
450 // Claude every few minutes.
451 put(w.id, { status: 'stalled' })
452 } else {
453 // Each stall after growth gets a fresh wake, so one that recovers
454 // and stalls again earns one more. The switch is read as the stall
455 // lands; decideWakes sends the pending ones.
456 wakeIsOn ??= await isWakeOn($)
457 put(w.id, {
458 status: 'stalled',
459 stalledAt: w.stalledAt ?? now,
460 lastStallAt: now,
461 wake: wakeIsOn ? PENDING : OFF,
462 })
463 newlyStalled.push(w)
464 }
465 }
466 }
467 }
468 // Forget agents long idle with nothing running.
469 for (const [agentId, record] of activity) {
470 if (record.ops.size === 0 && now - record.lastAt > ACTIVITY_TTL_MS) {
471 activity.delete(agentId)
472 }
473 }
474
475 if (changes.size > 0) {
476 await update($, watches, list => list.map(w => (changes.has(w.id) ? { ...w, ...changes.get(w.id) } : w)))
477 } else if (live.length > 0) {
478 // Nothing changed state, so nothing redraws: refresh the idle counters.
479 $.ui.invalidate('ui.render')
480 }
481 showStatus($, await currentWatches($))
482
483 if (newlyStalled.length > 0) {
484 // Bring the Stop button to the person instead of waiting for /watchdog.
485 await $.ui.open({ id: PANE, title: 'Artifact watchdog' }).catch(() => undefined)
486 }
487 for (const w of newlyStalled) {
488 const mins = Math.round((now - w.lastGrowthAt) / 60_000)
489 $.ui.toast(`Stalled: "${w.label}" artifact has not grown in ${mins} min. Stop it from the watchdog pane.`, {
490 timeoutMs: 10_000,
491 })
492 await noteForClaude($, stallMessage(w, mins))
493 }
494 await decideWakes($)
495 } finally {
496 isTicking = false
497 }
498}
499
500const PENDING: Wake = { state: 'pending', detail: 'wake pending until the turn ends' }
501const OFF: Wake = { state: 'off', detail: 'wake off' }
502
503function stallMessage(w: Watch, mins: number): string {
504 const who = w.agentId ? `"${w.label}" (${w.agentId})` : `"${w.label}"`
505 return (
506 `[artifact-watchdog] Subagent ${who} artifact ${w.path} has not grown in ${mins} min. ` +
507 `Per ~/.claude/rules/subagent-output-contract.md clause 3, it is stuck: ` +
508 (w.agentId ? `TaskStop agent ${w.agentId} ` : 'stop it ') +
509 'and do the task inline.'
510 )
511}
512
513// Turns pending wakes into one $.prompt.submit each. Runs after every tick and
514// at each main-loop turn's end, and submits only while the main loop is idle,
515// for a watch still stalled whose agent the engine still lists as running (one
516// Claude already stopped waits for the next tick to mark it ended).
517async function decideWakes($: EngineInterface): Promise<void> {
518 if (mainTurn !== null) {
519 return
520 }
521 const pending = (await currentWatches($)).filter(w => w.status === 'stalled' && w.wake?.state === 'pending')
522 if (pending.length === 0) {
523 return
524 }
525 // A wake goes pending while a turn runs; /watchdog wake off before that turn
526 // ends must still hold it back.
527 if (!(await isWakeOn($))) {
528 await cancelPendingWakes($)
529 return
530 }
531 const agents = pending.some(w => w.agentId !== null) ? await $.agent.list().catch(() => undefined) : undefined
532 const running = new Set((agents ?? []).filter(a => a.status === 'running').map(a => a.id))
533 const ready = new Set(pending.filter(w => w.agentId === null || running.has(w.agentId)).map(w => w.id))
534 const now = await $.clock.now()
535
536 // Claim inside one update, so a tick and a turn end racing here cannot both
537 // submit for the same stall.
538 const claimed = new Map<string, Watch>()
539 await update($, watches, list => {
540 claimed.clear()
541 return list.map(w => {
542 if (!ready.has(w.id) || w.status !== 'stalled' || w.wake?.state !== 'pending') {
543 return w
544 }
545 claimed.set(w.id, w)
546 return { ...w, wake: { state: 'queued' as const, detail: 'wake queued' } }
547 })
548 })
549
550 for (const w of claimed.values()) {
551 const mins = Math.round((now - w.lastGrowthAt) / 60_000)
552 // Never awaited: it resolves when its turn starts, and this may be a
553 // turn.complete hook that the turn has to get past first.
554 void $.prompt
555 .submit({ text: stallMessage(w, mins) })
556 .then(
557 ran => settleWake($, w.id, ran.drop !== undefined ? failedWake(ran.drop) : { state: 'sent', detail: 'woke Claude' }),
558 (err: unknown) => settleWake($, w.id, failedWake(err instanceof Error ? err.message : String(err))),
559 )
560 .catch(() => undefined)
561 }
562}
563
564// Wake off: every wake still pending becomes "off". One already queued has
565// gone to the engine and is left to settle.
566async function cancelPendingWakes($: EngineInterface): Promise<void> {
567 await update($, watches, list => list.map(w => (w.wake?.state === 'pending' ? { ...w, wake: OFF } : w)))
568}
569
570function failedWake(reason: string): Wake {
571 return { state: 'failed', detail: `wake failed: ${reason}` }
572}
573
574// Records what a submit came to, unless a newer stall already replaced the wake.
575async function settleWake($: EngineInterface, id: string, wake: Wake): Promise<void> {
576 await update($, watches, list => list.map(w => (w.id === id && w.wake?.state === 'queued' ? { ...w, wake } : w)))
577}
578
579async function stop($: EngineInterface, w: Watch): Promise<void> {
580 if (w.agentId === null) {
581 return
582 }
583 const agentId = w.agentId
584 // Show the press at once: a TaskStop held by a permission ask otherwise
585 // leaves the row red with no sign the press landed.
586 await update($, watches, list =>
587 list.map(one => (one.id === w.id ? { ...one, status: 'stopping' as const, summary: 'Stop pressed, waiting on TaskStop' } : one)),
588 )
589 showStatus($, await currentWatches($))
590
591 let isSettled = false
592 const waitTimer = $.clock.after(STOP_WAIT_MS, () => {
593 if (isSettled) {
594 return
595 }
596 void (async () => {
597 await update($, watches, list =>
598 list.map(one =>
599 one.id === w.id && one.status === 'stopping'
600 ? { ...one, summary: `TaskStop unanswered after ${STOP_WAIT_MS / 1000}s (permission ask?)` }
601 : one,
602 ),
603 )
604 await noteForClaude(
605 $,
606 `[artifact-watchdog] Matthew pressed Stop on stalled subagent "${w.label}" (${agentId}), but TaskStop ` +
607 `has not answered in ${STOP_WAIT_MS / 1000}s. TaskStop agent ${agentId} yourself and do the task inline.`,
608 )
609 })()
610 })
611
612 // consent marks this call as the person's own request on the permission path.
613 // A call that rejects (no TaskStop, aborted) takes the failure branch too:
614 // left uncaught it would strand the row in stopping and escape the press.
615 let failure: string | null
616 try {
617 const ran = await $.tool.call({
618 tool: 'TaskStop',
619 task_id: agentId,
620 consent: `Matthew pressed "Stop" on stalled subagent "${w.label}" (${agentId}) in the artifact-watchdog pane.`,
621 })
622 failure = ran.deny !== undefined ? `denied: ${ran.deny}` : ran.isError ? `failed: ${ran.text ?? 'error'}` : null
623 } catch (err) {
624 failure = `threw: ${err instanceof Error ? err.message : String(err)}`
625 }
626 isSettled = true
627 waitTimer.cancel()
628 if (failure !== null) {
629 // The agent may still be running: back to stalled, so the poll keeps
630 // watching it and the Stop button is there to press again.
631 const summary = `Stop ${failure}`
632 await update($, watches, list =>
633 list.map(one => (one.id === w.id && one.status === 'stopping' ? { ...one, status: 'stalled' as const, summary } : one)),
634 )
635 showStatus($, await currentWatches($))
636 $.ui.toast(`Stop on "${w.label}" did not go through (TaskStop ${failure}). Press Stop again or stop it yourself.`, {
637 timeoutMs: 10_000,
638 })
639 await noteForClaude(
640 $,
641 `[artifact-watchdog] Matthew pressed Stop on stalled subagent "${w.label}" (${agentId}), but TaskStop ` +
642 `${failure}. The agent may still be running: TaskStop agent ${agentId} yourself and do the task inline.`,
643 )
644 return
645 }
646 const summary = 'stopped from the pane'
647 await update($, watches, list =>
648 list.map(one => (one.id === w.id && one.status === 'stopping' ? { ...one, status: 'stopped' as const, summary } : one)),
649 )
650 showStatus($, await currentWatches($))
651 await noteForClaude(
652 $,
653 `[artifact-watchdog] Matthew stopped stalled subagent "${w.label}" (${agentId}) from the watchdog pane ` +
654 `(TaskStop: ${summary}). Do the task inline.`,
655 )
656}
657
658// A user-role row Claude reads on its next request. The append can be refused
659// (a plugin above, a run no plugin may shape); the toast and pane still stand.
660async function noteForClaude($: EngineInterface, text: string): Promise<void> {
661 await $.session
662 .append({ message: { type: 'user', content: [{ type: 'text', text }] } })
663 .catch(() => undefined)
664}
665types/index.d.ts 71 lines1// waiting: dispatched, file not seen yet. growing: file grew within the window.
2// quiet: no growth for the stall window, but the agent itself is active (a
3// recent tool call or model request, or one still in flight): no alarm.
4// stalled: no growth for the stall window AND the agent silent for the silence
5// window (or no agent id to watch). complete: last line is AGENT COMPLETE.
6// stopping: Stop pressed, TaskStop not answered yet. stopped: TaskStop stopped it
7// (a denied or failed TaskStop puts the row back to stalled).
8// ended: the agent finished (or was stopped elsewhere) without writing AGENT COMPLETE.
9// missing: the prompt named no artifact path.
10export type WatchStatus =
11 | 'waiting'
12 | 'growing'
13 | 'quiet'
14 | 'stalled'
15 | 'stopping'
16 | 'complete'
17 | 'stopped'
18 | 'ended'
19 | 'missing'
20
21export type Watch = {
22 id: string
23 label: string
24 path: string | null
25 agentId: string | null
26 startedAt: number
27 lastSize: number
28 // The file's mtime at the last reading (at dispatch first), or null when
29 // there was no file: with lastSize, what a write by this dispatch changes.
30 lastMtimeMs?: number | null
31 lastGrowthAt: number
32 status: WatchStatus
33 summary: string
34 // When the watch first went stalled; kept after it recovers or completes.
35 stalledAt?: number
36 // The one wake the latest stall earned; replaced when the watch stalls again
37 // after file growth (a stall resumed after agent activity keeps it).
38 wake?: Wake
39 // When the latest stall landed: a stall cleared by agent activity alone and
40 // back before the file grows is the same stall, not a new one.
41 lastStallAt?: number
42 // The agent's own last event (a tool call starting or ending, a model
43 // request starting or ending, its run ending), copied in at each poll.
44 lastActivityAt?: number
45 // The agent's newest call still running at the last poll, if any.
46 inFlight?: InFlight | null
47}
48
49// what: the tool's name, or "model request". since: when it started.
50export type InFlight = {
51 what: string
52 since: number
53}
54
55// pending: stalled, waiting for the main loop to be idle. queued: prompt
56// submitted, its turn not started yet. sent: its turn started. failed: the
57// submit was dropped or threw. off: stalled while /watchdog wake was off.
58export type WakeState = 'pending' | 'queued' | 'sent' | 'failed' | 'off'
59
60export type Wake = {
61 state: WakeState
62 detail: string
63}
64
65declare module 'claude-code' {
66 interface PluginState {
67 // wake: whether a stall may start a turn of its own (default on).
68 'artifact-watchdog': { watches: Watch[]; wake: boolean }
69 }
70}
71