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

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.
| Claude hook | Action |
|---|---|
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 agentId | Ends 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.change | Records when the user last touched the prompt box. |
session.end | Ends 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 consumption is recorded only once the lead's conversation has the text.
delivery.turn.start at any point up to the submit ends the hold.--exclude, and calls $.prompt.submit with the text under a [meridian-spawn-watch delivery <nonce>] line.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.turn.start whose text contains that nonce, and nothing else. The claim is released when the ack succeeds.| Situation | Outcome |
|---|---|
Esc (aborted), an API or context error, a refusal | No collector after that turn. Results stay owed; the next normal idle delivers them. |
| The user sends something before delivery | The 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 cleared | No 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 reload | The 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 dies | collect 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 prompt | Its end begins a run like any main-loop turn's. The cache is warm by then, so the delivery costs no cold re-read. |
/clear | The 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-watchPrints 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.
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.
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.
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:
$ to be a top-level function declaration, not a closure inside register.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.
hooks/register.ts 371 lines1import 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