SLOPSHOPPER

artifact-watchdog

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…

newpaneguardcommandtoaststatus
v0.1.0no licenseupdated 2026-10-07m2ai-portfolio/claude-mods/artifact-watchdog
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · artifact-watchdog
│ ┃ Artifact watchdog ✕ › fix the failing auth test and add an audit log call │ ┃ Stall after 5 min without growth and 2 min │ ┃ of agent silence · polled every 15s · wake ⏺ Read(src/auth.ts) │ ┃ on ⎿ Read 6 lines │ ┃ No Agent dispatches this session. ⏺ Update(src/auth.ts) │ ⎿ Added 2 lines, removed 1 line │ ⏺ 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 │ │ › /watchdog │ ⎿ artifact-watchdog: Artifact watchdog pane opened. Wake on stall: │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Artifact watchdog
Stall after 5 min without growth and 2 min of agent silence · polled every 15s · wake on No Agent dispatches this session.
README

claude-mods

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.

FolderWhat it does
artifact-watchdogWatches each Agent dispatch's report file and flags a 5-minute stall
auto-handoffWrites a /next handoff automatically once a long session crosses a token threshold, and before a compaction
flight-recorderLive timeline of model requests, tool calls and subagents in a turn
jev-tool-gateLog-only judgment of risky tool calls; never changes a decision
skills/next, skills/primeSession handoff skills, see below

Skills: /next and /prime

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

Source 2 files
hooks/register.tsx 665 lines
1// 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}
665
types/index.d.ts 71 lines
1// 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