SLOPSHOPPER

meridian-spawn-watch

Delivers finished background Meridian spawns to the lead as a new turn when its Claude Code session goes idle, through `meridian spawn collect`.

newcommandpromptprocess
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · meridian-spawn-watch
› fix the failing auth test and add an audit log call ⏺ Read(src/auth.ts) ⎿ Read 6 lines ⏺ 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 › /meridian-spawn-watch ⎿ meridian-spawn-watch: idle run: none ⎿ meridian-spawn-watch: last collect: error at 17:11:17 ⎿ meridian-spawn-watch: log: ⎿ meridian-spawn-watch: 17:11:17 idle run failed: collect exited undefined: ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts
README

Meridian spawn-watch plugin

A Claude Code mod that wakes a lead whose background children finished while it sat idle. When a main-loop turn ends, the mod runs meridian spawn collect, which blocks until child results are owed and ready; the mod submits their status headers and meridian spawn show <id> lookup commands as a new turn, then records the results as consumed with meridian spawn ack. Meridian decides which children are owed, when they are done, what the lead sees and what counts as consumed. The mod only senses idle, runs the two commands, and keeps the delivery safe against the ways a turn can fail to happen.

What it does

Claude hookAction
session.start (TUI only)Registers /meridian-spawn-watch. It starts no idle run: a resumed chat's prompt cache is cold, so a turn the user did not ask for would re-bill the whole context for a status ping. Results owed from before a crash, restart or reload wait until the user's first turn ends. Under claude -p (isInteractive === false, no surface) the mod stays inert for the whole session.
turn.complete without agentId, reason: 'answer'After 2 s, starts meridian spawn collect [--exclude <claimed ids>].
turn.start without agentIdEnds the pending 2 s delay, kills a running collector, ends a hold. If its text carries the nonce of a delivery, runs meridian spawn ack <ids>.
prompt.edit, prompt.changeRecords when the user last touched the prompt box.
session.endEnds the idle stretch.

Subagent, teammate and compaction-fork turns raise turn.start and turn.complete too, with agentId set. Both hooks ignore them; without the filter, background agents would starve delivery.

The collector prints one JSON object and exits 0:

{"kind": "delivery", "ids": ["p12", "p13"], "text": "..."}
{"kind": "empty",    "ids": [],             "text": ""}

Anything else (non-zero exit, unparseable output, no usable ids) is logged and retried at the next idle. Its stderr is diagnostics.

Delivery text is intentionally header-only: status/outcome, worker, failure and distinct task directory when present, plus the exact spawn show lookup. It never contains or reads the report body. A lead that wants the body runs the lookup. A terminal report body shown by spawn show or spawn report show consumes the notification, so the fallback announces only results the lead has not already seen. spawn status --full follows the same rule. Summary-only output, report search, and session logs do not consume it. --no-observe keeps any show or cancel result owed; piped or redirected output otherwise still counts as shown. Commands directed through -C never consume results in either project. In particular, spawn wait -C is a pure read and spawn ack -C is refused.

A normal no-argument meridian spawn wait uses the same owed-child definition. It leases its targets while running and consumes terminal results on return, so the collector skips those children during the wait and does not deliver them afterward. Wait takes at most the oldest 32 children that were already finished when target resolution ran and renders them through the same header-only delivery formatter. Running targets are uncapped and keep their full report output after wait blocks for them. Already-finished rows do not affect waited-run counts, fail-fast, or exit status; JSON returns them separately in owed_spawns. The remaining finished children stay owed for the next wait or collection. Pi delivery receipts, cleared IDs, terminal reports observed by CLI operations, and caller-issued cancels that report cancelled count as consumed for both wait and collection, so those results are not replayed here. spawn wait --no-observe deliberately does neither and may return the same finished child again until a normal wait or spawn ack consumes it; its rendering is unchanged.

Delivery, step by step

Delivery consumption is recorded only once the lead's conversation has the text.

  1. The collector returns a delivery.
  2. The mod holds while the prompt box has a draft, or was touched less than 10 s ago (checked every second). A main-loop turn.start at any point up to the submit ends the hold.
  3. The mod claims the ids in memory, so any collector started before the ack gets them as --exclude, and calls $.prompt.submit with the text under a [meridian-spawn-watch delivery <nonce>] line.
  4. submit resolves when the turn starts, when the prompt is queued behind a running turn, or with { drop }. On drop the claim is released and nothing is acked.
  5. The ack runs on the main-loop turn.start whose text contains that nonce, and nothing else. The claim is released when the ack succeeds.
SituationOutcome
Esc (aborted), an API or context error, a refusalNo collector after that turn. Results stay owed; the next normal idle delivers them.
The user sends something before deliveryThe turn.start kills the collector or ends the hold and releases the claim. Whatever the lead did not consume through wait, cancel, or a full report read is delivered at the next idle.
Prompt queued, then the queue is clearedNo turn carries the nonce, so nothing is acked. The next idle (2 s after a turn ends with no prompt behind it) releases the stale claim and the collector redelivers.
Ack fails (lock, disk, retired row)Logged. The ids stay excluded until the mod reloads: releasing them would redeliver the same results at every idle. After a restart they are delivered once more.
Mod reloadThe engine kills a running collector. The new copy gets session.start and starts with clean state; the next turn's end delivers what is owed.
Claude diescollect exits when its parent changes. Nothing was consumed, so the resumed chat delivers after the user's first turn ends, never before it.
A peer, scheduled or Stop-hook turn runs before the user's first promptIts end begins a run like any main-loop turn's. The cache is warm by then, so the delivery costs no cold re-read.
/clearThe engine fires session.end and no session.start: the run ends, and nothing starts another until a turn ends.

The 10 s rule has two sources because the engine splits them: typing and deletion (a kill included) arrive as prompt.edit; history recall, clears and a plugin's fill arrive as prompt.change. A draft that sits in the box holds the delivery for as long as it is there.

/clear delivers into the cleared context. A child the lead cancels itself is consumed only when the cancel reports cancelled; a finalizing cancel is delivered later unless the lead sees its terminal report first.

/meridian-spawn-watch

Prints the idle stretch's phase (delay, collecting, holding, submitting), the last collector verdict, the claims in flight, and the last log lines. The log lives in the plugin's $.store under log (last 100 lines). The mod writes nothing to the terminal, never reads Meridian config files or MERIDIAN_* variables (the collector inherits them from the session), and never writes under ~/.meridian.

Where it runs

meridian claude adds --plugin-dir for interactive launches when harness.claude.spawn_watch.enabled is true (the default). That Python plugin list is the only config gate: the mod itself asks nothing of Meridian at session.start, unlike meridian-idle.

Shared code with meridian-idle

The store log and a few small helpers are duplicated from meridian-idle. Claude plugins cannot share a module: the loader refuses an import that leaves the plugin folder (cannot import "../../shared/x.ts": it is outside the plugin's folder), and a symlink to a sibling directory is not followed either. Each plugin must hold all of its own source.

Developing it

claude plugin validate src/meridian/claude_runtime/meridian-spawn-watch
claude plugin test     src/meridian/claude_runtime/meridian-spawn-watch

The module is plain TypeScript the host runs directly: no build step, no dependencies. hooks/register.test.ts plays meridian spawn collect and ack, the prompt box and the engine's prompt queue, and covers each rule above. Two things to know when changing it:

  • The module validator wants every helper that takes $ to be a top-level function declaration, not a closure inside register.
  • The kit's clock.advance does not settle while a collector's stream is pending (it gives up after 1 s of real time), so the tests start the advance without awaiting it and wait for the collector instead.

Claude Code lays build-specific type files into the plugin directory it loads with --plugin-dir: .claude-plugin/types/**, ignored by the repo and the wheel, beside the committed 56-byte tsconfig.json that extends it. See the meridian-idle README.

Source 1 files
hooks/register.ts 371 lines
1import type { EngineInterface, Register } from 'claude-code'
2
3// Meridian's Claude spawn watcher.
4//
5// When the lead's main loop goes idle, `meridian spawn collect` finds the background children it never
6// waited on and blocks until their results are due; this module submits that text as a new turn and
7// then tells Meridian the lead has it (`meridian spawn ack`). Which children are owed, when they are
8// done, what the lead sees and what counts as consumed all live in core. This file only senses idle,
9// runs the two commands, and keeps the delivery safe against the ways a turn can fail to happen.
10// Nothing here writes to the terminal: diagnostics go to the plugin's own `$.store` key `log`, and
11// `/meridian-spawn-watch` prints them on request.
12//
13// One idle stretch is one `Run`: a 2 s delay, the collector, a hold while the user types, the submit.
14// A stretch begins only when a main-loop turn ends, never at session start: a resumed chat's prompt cache is
15// cold, so a turn the user did not ask for would re-bill the whole context for a status ping. Results owed
16// from before a crash or restart wait for the first turn the user starts. A main-loop `turn.start` aborts
17// the run wherever it is; aborting the collector's read loop kills the child.
18//
19// Consumption is acked only once the lead has the text. Only one run exists at a time, so no collector
20// runs while a delivery is held; the ids are claimed in memory when the prompt is submitted, and every
21// collector started until the ack lands excludes them. The ack runs on the main-loop `turn.start` whose
22// text carries the delivery's nonce. A dropped, queued or never-started prompt is never acked.
23
24const COMMAND = 'meridian-spawn-watch'
25/** The idle signal is a main-loop turn ending; a queued prompt starts its turn within this, so it costs no process. */
26const IDLE_DELAY_MS = 2_000
27/** The prompt box must be empty and untouched this long before a delivery starts a turn under the user. */
28const QUIET_MS = 10_000
29const HOLD_POLL_MS = 1_000
30const ACK_TIMEOUT_MS = 20_000
31const LOG_LIMIT = 100
32/** Safety cap for the collector subprocess's JSON output. */
33const STDOUT_CHAR_CAP = 1 << 20
34const STDERR_CHAR_CAP = 500
35const SPAWN_ID = /^[A-Za-z0-9][\w.-]*$/
36
37type Phase = 'delay' | 'collecting' | 'holding' | 'submitting'
38
39/** One idle stretch's work. Aborting it ends the delay, kills the collector, and ends the hold. */
40type Run = { ctl: AbortController; phase: Phase }
41
42/**
43 * Ids handed to the lead and not yet recorded as consumed.
44 *   submitted the prompt went to `$.prompt.submit`; no turn carrying the nonce has started
45 *   acking    that turn started; the ack is running
46 *   unacked   the ack failed; the ids stay excluded for the life of this module copy, because releasing them
47 *             would redeliver the same results at every idle
48 */
49type Claim = { ids: string[]; phase: 'submitted' | 'acking' | 'unacked' }
50
51type Reply = { kind: 'empty' } | { kind: 'delivery'; ids: string[]; text: string }
52
53type State = {
54  /** Print mode or no surface: the whole session is ignored. */
55  inert: boolean
56  run: Run | undefined
57  claims: Map<string, Claim>
58  /** When the box was last touched (`prompt.edit` or `prompt.change`), as `$.clock.now()` answered it. Kept as the pending answer so the hooks never wait on it. */
59  lastChange: Promise<number>
60  /** The last collector verdict, for `/meridian-spawn-watch`. Empty answers stay out of the log. */
61  last: string
62  log: string[]
63}
64
65function newState(): State {
66  return {
67    inert: false,
68    run: undefined,
69    claims: new Map(),
70    lastChange: Promise.resolve(Number.NEGATIVE_INFINITY),
71    last: 'no collect yet',
72    log: [],
73  }
74}
75
76// -- small helpers ---------------------------------------------------------
77
78function describeError(err: unknown): string {
79  return err instanceof Error ? err.message : String(err)
80}
81
82function isRecord(value: unknown): value is Record<string, unknown> {
83  return typeof value === 'object' && value !== null && !Array.isArray(value)
84}
85
86/** Local wall-clock time of day: `/meridian-spawn-watch` shows it in a narrow column. */
87function stamp(): string {
88  return new Date().toTimeString().slice(0, 8)
89}
90
91function note($: EngineInterface, s: State, line: string): void {
92  s.log.push(`${stamp()} ${line}`)
93  if (s.log.length > LOG_LIMIT) s.log.splice(0, s.log.length - LOG_LIMIT)
94  try {
95    $.store.set('log', s.log).catch(() => undefined)
96  } catch {
97    // the log is a convenience; a store that refuses it is not the lead's problem
98  }
99}
100
101function claimedIds(s: State): Set<string> {
102  const ids = new Set<string>()
103  for (const claim of s.claims.values()) for (const id of claim.ids) ids.add(id)
104  return ids
105}
106
107function endRun(s: State): void {
108  s.run?.ctl.abort()
109  s.run = undefined
110}
111
112// -- collector -------------------------------------------------------------
113
114function parseReply(stdout: string): Reply {
115  const text = stdout.trim()
116  let parsed: unknown
117  try {
118    parsed = JSON.parse(text)
119  } catch {
120    throw new Error(`unparseable output: ${text.slice(0, 200)}`)
121  }
122  if (!isRecord(parsed)) throw new Error(`unexpected output: ${text.slice(0, 200)}`)
123  if (parsed.kind === 'empty') return { kind: 'empty' }
124  if (parsed.kind !== 'delivery') throw new Error(`unknown kind: ${String(parsed.kind)}`)
125  const { ids, text: body } = parsed
126  if (!Array.isArray(ids) || ids.length === 0 || !ids.every(id => typeof id === 'string' && SPAWN_ID.test(id))) {
127    throw new Error('delivery without usable ids')
128  }
129  if (typeof body !== 'string') throw new Error('delivery without text')
130  return { kind: 'delivery', ids: ids as string[], text: body }
131}
132
133/**
134 * Runs `meridian spawn collect --exclude <claimed ids>` and returns its verdict, or undefined if the run was aborted.
135 * Leaving the read loop is what kills the child, so an abort has to win the race against a read that may never come.
136 */
137async function collect($: EngineInterface, s: State, run: Run): Promise<Reply | undefined> {
138  const signal = run.ctl.signal
139  if (signal.aborted) return undefined
140  const excluded = [...claimedIds(s)]
141  const argv = ['meridian', 'spawn', 'collect', ...(excluded.length > 0 ? ['--exclude', excluded.join(',')] : [])]
142  const gone = new Promise<'gone'>(resolve => signal.addEventListener('abort', () => resolve('gone'), { once: true }))
143  const stream = $.process.spawn({ argv })
144  let stdout = ''
145  let stderr = ''
146  try {
147    for (;;) {
148      const pull = stream.next()
149      void pull.catch(() => undefined) // an abandoned read may still fail later
150      const step = await Promise.race([pull, gone])
151      if (step === 'gone') return undefined
152      if (step.done) {
153        const exit = step.value
154        if (exit.code !== 0) {
155          throw new Error(`collect exited ${exit.code ?? exit.signal}: ${stderr.trim().slice(-200)}`)
156        }
157        return parseReply(stdout)
158      }
159      if (step.value.stream === 'stdout') {
160        stdout += step.value.text
161        if (stdout.length > STDOUT_CHAR_CAP) throw new Error('collect output over the cap')
162      } else {
163        stderr = (stderr + step.value.text).slice(-STDERR_CHAR_CAP)
164      }
165    }
166  } finally {
167    void stream.return({ code: null, signal: null }).catch(() => undefined)
168  }
169}
170
171// -- delivery --------------------------------------------------------------
172
173/** Resolves once the prompt box is empty and untouched for `QUIET_MS`; rejects if the run is aborted first. */
174async function holdForUser($: EngineInterface, s: State, run: Run): Promise<void> {
175  for (;;) {
176    run.ctl.signal.throwIfAborted()
177    const now = await $.clock.now()
178    const changedAt = await s.lastChange
179    const { text } = await $.prompt.read()
180    if (text === '' && now - changedAt >= QUIET_MS) return
181    await $.clock.sleep(HOLD_POLL_MS, { signal: run.ctl.signal })
182  }
183}
184
185function newNonce(): string {
186  return `${Date.now().toString(36)}${Math.random().toString(36).slice(2, 8)}`
187}
188
189async function deliver($: EngineInterface, s: State, run: Run, reply: Extract<Reply, { kind: 'delivery' }>): Promise<void> {
190  try {
191    run.phase = 'holding'
192    await holdForUser($, s, run)
193    // A turn can start during the hold's last awaits; submit is the one step that cannot be undone.
194    run.ctl.signal.throwIfAborted()
195  } catch (err) {
196    note($, s, `held delivery of ${reply.ids.join(',')} released: ${run.ctl.signal.aborted ? 'a turn started' : describeError(err)}`)
197    throw err
198  }
199  // From here the prompt is out of our hands: a main-loop turn.start aborts the run but cannot unsubmit it.
200  const nonce = newNonce()
201  const claim: Claim = { ids: reply.ids, phase: 'submitted' }
202  s.claims.set(nonce, claim)
203  run.phase = 'submitting'
204  try {
205    const result = await $.prompt.submit({ text: `[meridian-spawn-watch delivery ${nonce}]\n\n${reply.text}` })
206    if (result.drop !== undefined) {
207      s.claims.delete(nonce)
208      note($, s, `delivery of ${reply.ids.join(',')} dropped: ${result.drop}`)
209      return
210    }
211    note($, s, `submitted ${reply.ids.join(',')}`)
212  } catch (err) {
213    if (claim.phase === 'submitted') s.claims.delete(nonce)
214    throw err
215  }
216}
217
218async function idleRun($: EngineInterface, s: State, run: Run): Promise<void> {
219  try {
220    await $.clock.sleep(IDLE_DELAY_MS, { signal: run.ctl.signal })
221    // A submitted prompt has entered by now (`submit` resolves once it starts its turn or queues behind a running one),
222    // and a queued one would have started its turn. A claim still waiting for one was dropped (Esc, a cleared queue).
223    for (const [nonce, claim] of s.claims) if (claim.phase === 'submitted') s.claims.delete(nonce)
224    run.phase = 'collecting'
225    const reply = await collect($, s, run)
226    if (reply === undefined) return
227    if (reply.kind === 'empty') {
228      s.last = `empty at ${stamp()}`
229      return
230    }
231    s.last = `delivery ${reply.ids.join(',')} at ${stamp()}`
232    await deliver($, s, run, reply)
233  } catch (err) {
234    if (!run.ctl.signal.aborted) {
235      s.last = `error at ${stamp()}`
236      note($, s, `idle run failed: ${describeError(err)}`)
237    }
238  } finally {
239    if (s.run === run) s.run = undefined
240  }
241}
242
243async function ackTask($: EngineInterface, s: State, nonce: string, claim: Claim): Promise<void> {
244  const argv = ['meridian', 'spawn', 'ack', ...claim.ids]
245  try {
246    const out = await $.process.run(argv, { timeoutMs: ACK_TIMEOUT_MS })
247    if (out.exitCode === 0) {
248      s.claims.delete(nonce)
249      note($, s, `acked ${claim.ids.join(',')}`)
250      return
251    }
252    note($, s, `ack ${claim.ids.join(',')} failed (exit ${out.exitCode}): ${out.stderr.trim().slice(0, 200)}`)
253  } catch (err) {
254    note($, s, `ack ${claim.ids.join(',')} failed: ${describeError(err)}`)
255  }
256  claim.phase = 'unacked'
257}
258
259// -- hook bodies -----------------------------------------------------------
260
261/** A main-loop turn started: whatever the idle stretch was doing ends, and a delivery turn is acked. */
262function mainTurnStarted($: EngineInterface, s: State, text: string): void {
263  endRun(s)
264  for (const [nonce, claim] of s.claims) {
265    if (claim.phase === 'acking' || claim.phase === 'unacked' || !text.includes(nonce)) continue
266    claim.phase = 'acking'
267    void ackTask($, s, nonce, claim) // acks are idempotent set unions in core, so their order does not matter
268  }
269}
270
271/** Begins an idle stretch, replacing the one in flight. Returns at once; the run's failures only reach the log. */
272function startRun($: EngineInterface, s: State): void {
273  endRun(s)
274  const run: Run = { ctl: new AbortController(), phase: 'delay' }
275  s.run = run
276  void idleRun($, s, run)
277}
278
279function boxTouched($: EngineInterface, s: State): void {
280  try {
281    s.lastChange = $.clock.now()
282    void s.lastChange.catch(() => undefined)
283  } catch {
284    // a missed timestamp only shortens the hold; it must never break typing
285  }
286}
287
288function statusText(s: State): string {
289  if (s.inert) return 'inactive for this session'
290  const lines = [`idle run: ${s.run?.phase ?? 'none'}`, `last collect: ${s.last}`]
291  for (const [nonce, claim] of s.claims) lines.push(`claim ${nonce}: ${claim.ids.join(',')} (${claim.phase})`)
292  if (s.log.length > 0) {
293    lines.push('log:')
294    for (const line of s.log.slice(-8)) lines.push(`  ${line}`)
295  }
296  return lines.join('\n')
297}
298
299// -- registration ----------------------------------------------------------
300
301export const register: Register = on => {
302  const s = newState()
303
304  on('session.start', async ($, e, next) => {
305    try {
306      if (e.isInteractive === false || e.surface === null) s.inert = true
307      else {
308        // Off the startup path; a reload is a fresh module copy and registers again. No idle run starts here.
309        $.command
310          .register({ name: COMMAND, description: 'Show Meridian spawn-watch state' })
311          .catch(err => note($, s, `command registration failed: ${describeError(err)}`))
312      }
313    } catch (err) {
314      note($, s, `session.start error: ${describeError(err)}`)
315    }
316    return next(e)
317  })
318
319  // Subagent, teammate and compaction-fork turns raise turn.start and turn.complete too, with agentId set.
320  // Only the main loop is the lead's idle; treating the others as turns would starve delivery while background agents run.
321  on('turn.start', async ($, e, next) => {
322    try {
323      if (e.agentId === undefined) mainTurnStarted($, s, e.text)
324    } catch (err) {
325      note($, s, `turn.start error: ${describeError(err)}`)
326    }
327    return next(e)
328  })
329
330  on('turn.complete', async ($, e, next) => {
331    try {
332      // Not after an Esc (do not resume what the user stopped), a rate or context limit, or a refusal: the next
333      // request would likely fail the same way. The results stay owed; the next normal idle delivers them.
334      if (e.agentId === undefined && e.reason === 'answer' && !s.inert) startRun($, s)
335    } catch (err) {
336      note($, s, `turn.complete error: ${describeError(err)}`)
337    }
338    return next(e)
339  })
340
341  // The user touching the prompt box. Typing and deletion (a kill included) arrive as prompt.edit; prompt.change
342  // carries every other write: history recall, a clear, a plugin's fill. Both run per edit, so they only record
343  // the time and never make the chain wait for it.
344  on('prompt.edit', async ($, e, next) => {
345    boxTouched($, s)
346    return next(e)
347  })
348  on('prompt.change', async ($, e, next) => {
349    boxTouched($, s)
350    return next(e)
351  })
352
353  on('command.run', { command: COMMAND }, async ($, e, next) => {
354    void next
355    try {
356      return { text: statusText(s) }
357    } catch (err) {
358      return { text: `status unavailable: ${describeError(err)}` }
359    }
360  })
361
362  on('session.end', async ($, e, next) => {
363    try {
364      endRun(s)
365    } catch (err) {
366      note($, s, `session.end error: ${describeError(err)}`)
367    }
368    return next(e)
369  })
370}
371