Senses Claude Code idle time and user return for Meridian: arms push, cache-expiry warning and idle compaction timers through `meridian idle`, and compacts…

A Claude Code mod that tells Meridian when a session goes idle and when the user comes back. Meridian decides what to do about it: a push notification, a warning that the prompt cache is about to expire, and an optional compaction before it does. The mod does the sensing and the one action Claude only allows from inside itself.
Every meridian idle command below carries --interactive; the table omits that common flag for readability.
| Claude hook | Meridian call | Why | ||
|---|---|---|---|---|
session.start (TUI only) | meridian idle config --harness claude | Stays inert unless the role gate and the config say enabled. Under claude -p (isInteractive === false, no surface) it never calls Meridian. | ||
session.start, after a reload or resume | meridian idle status --json | Rebuilds timers from the stored schedule of this session's open stretch, skipping stages already done. | ||
turn.complete without agentId | meridian idle arm --harness claude --session <id> [--user-text ...] [--assistant-text ...] | A main-loop turn ended. Core resolves the recorded native transcript store through the inherited MERIDIAN_CHAT_ID; the mod never derives it from the session's mutable cwd. The mod sends the last composer prompt and the event's answer as notification excerpts; injected origins and system reminders are excluded. Skipped while a subagent still runs; the hand-back turn arms later. A new or re-anchored schedule returns absolute push_at / warn_at / compact_at; an absorbed arm keeps matching timers or recovers them from status. | ||
prompt.submit or command.run with origin composer (or bridge, the user's phone) | meridian idle return --harness claude --session <id> --user-prompt | The user is back. Timers are cancelled first, synchronously. | ||
| timer: push, warn | `meridian idle fire push\ | warn …` | Core sends the notification itself. The mod only reports that the timer fired. | |
| timer: compact | meridian idle fire compact … <facts>, then $.session.compact() on act, then `meridian idle done compact … --result ok\ | vetoed\ | failed` | Facts: --agents-running N from $.agent.list(), --busy while a main-loop turn is running (or the agent list is unreadable), --context-tokens N from $.session.usage(), --harness-autocompact-off from the autoCompact config row. |
Injected prompts (task-notification, peer, plugin, sdk, scheduled triggers, Stop-hook continuations) are not returns. Compaction emits no turn events, so it cannot re-arm the timeline.
Compaction only runs when fire compact answers act, and only from a timer callback ($.session.compact() is rejected inside a command.run hook). Unsent composer text does not veto it.
The mod never sends a notification, reads Meridian config files or MERIDIAN_* variables, writes under ~/.meridian, or prints to the terminal. Its own diagnostics live in the plugin's $.store under the key log (last 100 lines).
/meridian-idlePrints the current stretch, anchor, time left on each timer and the last log lines. It does not count as a return.
meridian claude adds --plugin-dir for interactive launches by itself, and sets MERIDIAN_SESSION_ROLE. To use the mod in a Claude session started outside Meridian, with meridian on PATH:
CLAUDE_CODE_PLUGIN_DIRS=$(meridian idle mod-path) claude
Outside Meridian the role is unset and the mod asserts --interactive itself, so only a TUI session ever arms.
claude plugin validate src/meridian/claude_runtime/meridian-idle
claude plugin test src/meridian/claude_runtime/meridian-idle
The module is plain TypeScript the host runs directly: no build step, no dependencies. hooks/register.test.ts covers each hook path against a fake meridian idle and a fake host.
Claude Code lays build-specific type files into any plugin directory it loads with --plugin-dir or CLAUDE_CODE_PLUGIN_DIRS: .claude-plugin/types/** (about 800 KB, different per Claude version, with its own .gitignore) and a 56-byte tsconfig.json that extends it. The repo ignores .claude-plugin/types/ and the wheel excludes it; tsconfig.json is committed because it is tiny and never changes. Claude also loads the mod fine from a read-only directory (an installed wheel it cannot write to), so the launcher does not copy the mod.
hooks/register.ts 519 lines1import type { EngineInterface, Register, Timer } from 'claude-code'
2
3// Meridian's Claude idle sensor.
4//
5// This module senses two things and hands every decision to `meridian idle`:
6// idle a main-loop turn finished -> `idle arm`, then one timer per stage
7// return the user typed or sent something -> `idle return`, timers cancelled
8// It acts on exactly one answer: `{decision: "act"}` from `idle fire compact`,
9// and then only from a timer callback, because `$.session.compact()` is
10// rejected inside a `command.run` hook and the mod cannot see its own
11// compaction (probes/P0a.md, Q2). Push and warn notifications belong to core;
12// this file never sends one, reads meridian config or env, or writes under
13// `~/.meridian`. Nothing here writes to the terminal: diagnostics go to the
14// plugin's own `$.store` key `log`, and `/meridian-idle` prints them on request.
15
16type Stage = 'push' | 'warn' | 'compact'
17
18const STAGES: readonly Stage[] = ['push', 'warn', 'compact']
19const HARNESS = 'claude'
20const COMMAND = 'meridian-idle'
21const CLI_TIMEOUT_MS = 20_000
22const LOG_LIMIT = 100
23const EXCERPT_CHAR_CAP = 4_000
24
25type Json = Record<string, unknown>
26
27/** One open stretch as the mod knows it: the ids `fire` and `done` need, and the absolute stage times. */
28type Armed = {
29 session: string
30 stretch: number
31 anchor: number
32 at: Partial<Record<Stage, number>>
33 /** What core answered when each stage's timer fired, for `/meridian-idle`. */
34 fired: Partial<Record<Stage, string>>
35}
36
37type State = {
38 /** `session.start` ran, so its `isInteractive`/`surface` verdict is authoritative. */
39 startSeen: boolean
40 /** Print mode or no surface: the whole session is ignored. */
41 inert: boolean
42 /** Memoised `idle config --interactive` verdict. */
43 gate: Promise<boolean> | undefined
44 armed: Armed | undefined
45 timers: Timer[]
46 /** Bumped by every user return. Async work compares it to detect that the user came back meanwhile. */
47 epoch: number
48 /** An `arm` reached core since the last `return`, so a `return` call has something to close. */
49 mayBeOpen: boolean
50 /** A main-loop turn is running (turn.start seen, its turn.complete not yet): `$.session.compact()` would be rejected. */
51 busy: boolean
52 /** Serialises the short `meridian idle` calls so arm/return/fire/done reach core in hook order. */
53 queue: Promise<unknown>
54 log: string[]
55 /** Last prompt typed by the human; tool results and injected hook turns never enter here. */
56 lastUserText: string | undefined
57}
58
59function newState(): State {
60 return {
61 startSeen: false,
62 inert: false,
63 gate: undefined,
64 armed: undefined,
65 timers: [],
66 epoch: 0,
67 mayBeOpen: false,
68 busy: false,
69 queue: Promise.resolve(),
70 log: [],
71 lastUserText: undefined,
72 }
73}
74
75// -- small helpers ---------------------------------------------------------
76
77function describeError(err: unknown): string {
78 if (err instanceof Error) return err.message
79 return String(err)
80}
81
82function isRecord(value: unknown): value is Json {
83 return typeof value === 'object' && value !== null && !Array.isArray(value)
84}
85
86function asNumber(value: unknown): number | undefined {
87 return typeof value === 'number' && Number.isFinite(value) ? value : undefined
88}
89
90function capExcerpt(value: string | undefined): string | undefined {
91 return value?.slice(0, EXCERPT_CHAR_CAP)
92}
93
94/** A prompt or command the user themselves sent. Injected origins (task-notification, peer, plugin, sdk, ...) are not returns. */
95function isUserOrigin(origin: { kind: string } | undefined): boolean {
96 return origin?.kind === 'composer' || origin?.kind === 'bridge'
97}
98
99function note($: EngineInterface, s: State, line: string): void {
100 s.log.push(`${new Date().toISOString()} ${line}`)
101 if (s.log.length > LOG_LIMIT) s.log.splice(0, s.log.length - LOG_LIMIT)
102 $.store.set('log', s.log).catch(() => undefined)
103}
104
105function cancelTimers(s: State): void {
106 for (const timer of s.timers) timer.cancel()
107 s.timers = []
108}
109
110function enqueue(s: State, task: () => Promise<unknown>): Promise<unknown> {
111 const run = s.queue.then(task).catch(() => undefined)
112 s.queue = run
113 return run
114}
115
116/** Runs `meridian idle <args>` in the session's environment and returns the parsed stdout (a JSON object or array). Never throws. */
117async function cli($: EngineInterface, s: State, args: string[]): Promise<unknown> {
118 try {
119 const out = await $.process.run(['meridian', 'idle', ...args, '--interactive'], { timeoutMs: CLI_TIMEOUT_MS })
120 const text = out.stdout.trim()
121 if (text === '') return { error: `no output (exit ${out.exitCode}): ${out.stderr.trim().slice(0, 200)}` }
122 try {
123 return JSON.parse(text)
124 } catch {
125 return { error: `unparseable output (exit ${out.exitCode}): ${text.slice(0, 200)}` }
126 }
127 } catch (err) {
128 return { error: describeError(err) }
129 }
130}
131
132/** The error text of a `cli` result, or undefined when it succeeded. */
133function failure(reply: unknown): string | undefined {
134 if (!isRecord(reply)) return Array.isArray(reply) ? undefined : 'unexpected reply'
135 return typeof reply.error === 'string' ? reply.error : undefined
136}
137
138function who(armed: Armed): string[] {
139 return ['--harness', HARNESS, '--session', armed.session, '--stretch', String(armed.stretch)]
140}
141
142// -- gate: is this a primary TUI session with idle enabled? ----------------
143
144async function checkGate($: EngineInterface, s: State): Promise<boolean> {
145 if (s.inert) return false
146 if (!s.startSeen) {
147 // Loaded after `session.start` (a plugin install or reload that skipped it): ask the host directly.
148 try {
149 if ((await $.session.surfaces()).length === 0) {
150 s.inert = true
151 note($, s, 'inert: no surface (print mode)')
152 return false
153 }
154 } catch (err) {
155 note($, s, `inert: cannot read surfaces: ${describeError(err)}`)
156 // Surface discovery can fail transiently during startup or reload. Keep
157 // the gate fail-closed for this call, but let the next event retry it.
158 s.gate = undefined
159 return false
160 }
161 }
162 const reply = await cli($, s, ['config', '--harness', HARNESS])
163 const problem = failure(reply)
164 if (problem !== undefined || !isRecord(reply)) {
165 // Not memoised: a transient failure (meridian not on PATH yet) should not disable idle for the whole session.
166 note($, s, `config failed: ${problem ?? 'unexpected reply'}`)
167 s.gate = undefined
168 return false
169 }
170 if (reply.enabled !== true) {
171 s.inert = true
172 note($, s, `inert: idle disabled (${typeof reply.reason === 'string' ? reply.reason : 'no reason given'})`)
173 return false
174 }
175 note($, s, 'active: idle enabled')
176 return true
177}
178
179function gate($: EngineInterface, s: State): Promise<boolean> {
180 if (s.inert) return Promise.resolve(false)
181 s.gate ??= checkGate($, s)
182 return s.gate
183}
184
185// -- arm / adopt timers ----------------------------------------------------
186
187function readSchedule(row: unknown): Armed['at'] {
188 const at: Armed['at'] = {}
189 if (!isRecord(row)) return at
190 for (const stage of STAGES) {
191 const when = asNumber(row[`${stage}_at`])
192 if (when !== undefined) at[stage] = when
193 }
194 return at
195}
196
197/** Replaces the pending timers with one per stage in `armed.at`, at its absolute epoch-ms time. */
198async function adopt($: EngineInterface, s: State, armed: Armed, epoch: number): Promise<void> {
199 const now = await $.clock.now()
200 if (s.epoch !== epoch) return // the user came back while we were waiting
201 cancelTimers(s)
202 s.armed = armed
203 for (const stage of STAGES) {
204 const when = armed.at[stage]
205 if (when === undefined) continue
206 s.timers.push(
207 $.clock.after(Math.max(0, when - now), () => {
208 void onTimer($, s, stage, armed, epoch)
209 }),
210 )
211 }
212 const plan = STAGES.filter(stage => armed.at[stage] !== undefined)
213 .map(stage => `${stage}+${Math.round(((armed.at[stage] ?? now) - now) / 1000)}s`)
214 .join(' ')
215 note($, s, `armed stretch ${armed.stretch} anchor ${armed.anchor}: ${plan || 'no stages left'}`)
216}
217
218/** Rebuilds timers from the stored schedule of this session's open stretch (reload or resume), never from memory. */
219async function recover($: EngineInterface, s: State, session: string, epoch: number): Promise<void> {
220 const rows = await cli($, s, ['status', '--json'])
221 if (!Array.isArray(rows)) {
222 const problem = failure(rows)
223 if (problem !== undefined) note($, s, `status failed: ${problem}`)
224 return
225 }
226 const row = rows.find(
227 candidate =>
228 isRecord(candidate) &&
229 candidate.harness === HARNESS &&
230 candidate.session === session &&
231 candidate.stretch_open === true,
232 )
233 if (!isRecord(row)) return
234 const stretch = asNumber(row.stretch)
235 const anchor = asNumber(row.anchor)
236 if (stretch === undefined || anchor === undefined) return
237 const done = isRecord(row.done) ? row.done : {}
238 const at = readSchedule(row.schedule)
239 for (const stage of STAGES) {
240 if (done[stage] !== undefined) delete at[stage] // the stage already ran in this stretch
241 }
242 s.mayBeOpen = true
243 await adopt($, s, { session, stretch, anchor, at, fired: {} }, epoch)
244}
245
246async function runningAgents($: EngineInterface): Promise<number | undefined> {
247 try {
248 return (await $.agent.list()).filter(agent => agent.status === 'running').length
249 } catch {
250 return undefined
251 }
252}
253
254async function armTask($: EngineInterface, s: State, epoch: number, assistantText?: string): Promise<void> {
255 if (!(await gate($, s))) return
256 try {
257 // The main loop can finish while a subagent still runs; the hand-back turn ends the main loop again, and arms then.
258 const running = await runningAgents($)
259 if (running !== undefined && running > 0) {
260 note($, s, `arm skipped: ${running} subagent(s) still running`)
261 return
262 }
263 const session = await $.session.id()
264 const args = ['arm', '--harness', HARNESS, '--session', session]
265 const tokens = await contextTokens($)
266 if (tokens !== undefined) args.push('--context-tokens', String(tokens))
267 const userExcerpt = capExcerpt(s.lastUserText)
268 const assistantExcerpt = capExcerpt(assistantText)
269 if (userExcerpt !== undefined) args.push(`--user-text=${userExcerpt}`)
270 if (assistantExcerpt !== undefined) args.push(`--assistant-text=${assistantExcerpt}`)
271 const reply = await cli($, s, args)
272 const problem = failure(reply)
273 if (problem !== undefined || !isRecord(reply)) {
274 note($, s, `arm failed: ${problem ?? 'unexpected reply'}`)
275 return
276 }
277 const stretch = asNumber(reply.stretch)
278 const anchor = asNumber(reply.anchor)
279 if (stretch === undefined || anchor === undefined) {
280 // Core declined (role or idle gate): nothing is open, nothing to time.
281 cancelTimers(s)
282 s.armed = undefined
283 note($, s, `arm declined${typeof reply.reason === 'string' ? ` (${reply.reason})` : ''}`)
284 return
285 }
286 s.mayBeOpen = true
287 if (s.epoch !== epoch) return // a return queued behind this arm closes the stretch; arm no timers for it
288 const current = s.armed
289 if (current !== undefined && current.session === session && current.stretch === stretch && current.anchor === anchor) {
290 return // same stretch and anchor: core absorbed this arm (compaction window, already compacted); keep our timers
291 }
292 const at = readSchedule(reply)
293 if (Object.keys(at).length === 0 && typeof reply.reason === 'string') {
294 // An absorbed arm for a stretch we do not know yet carries no deadlines: take them from the store.
295 await recover($, s, session, epoch)
296 return
297 }
298 await adopt($, s, { session, stretch, anchor, at, fired: {} }, epoch)
299 } catch (err) {
300 note($, s, `arm error: ${describeError(err)}`)
301 }
302}
303
304// -- return ----------------------------------------------------------------
305
306async function returnTask($: EngineInterface, s: State, sessionId: Promise<string | undefined>): Promise<void> {
307 if (!s.mayBeOpen) return // no arm reached core since the last return: nothing to close, skip the ~1 s call
308 const session = await sessionId
309 if (session === undefined) return
310 s.mayBeOpen = false
311 const reply = await cli($, s, ['return', '--harness', HARNESS, '--session', session, '--user-prompt'])
312 const problem = failure(reply)
313 if (problem !== undefined) {
314 s.mayBeOpen = true
315 note($, s, `return failed: ${problem}`)
316 } else if (isRecord(reply) && reply.stretch_closed === true) {
317 note($, s, 'return: stretch closed')
318 }
319}
320
321/** The user is back. Cancels timers at once (synchronously), then closes the stretch in core. */
322function userReturned($: EngineInterface, s: State): void {
323 s.epoch += 1
324 cancelTimers(s)
325 s.armed = undefined
326 // Read the id now, not when the queued call runs: `/clear` changes it in between.
327 const sessionId = $.session.id().catch(() => undefined)
328 void enqueue(s, () => returnTask($, s, sessionId))
329}
330
331// -- timers: fire, and act on compact --------------------------------------
332
333async function contextTokens($: EngineInterface): Promise<number | undefined> {
334 try {
335 const tokens = asNumber((await $.session.usage()).context.tokens)
336 return tokens !== undefined && tokens > 0 ? tokens : undefined
337 } catch {
338 return undefined
339 }
340}
341
342async function autoCompactOff($: EngineInterface): Promise<boolean> {
343 try {
344 const rows = await $.config.list()
345 return rows.some(row => row.key === 'autoCompact' && row.value === false)
346 } catch {
347 return false
348 }
349}
350
351/** Facts `idle fire compact` needs. A fact the host will not give is omitted, so core applies its own unknown-fact rule. */
352async function compactFacts($: EngineInterface, busy: boolean): Promise<string[]> {
353 const [agents, tokens, autoOff] = await Promise.all([
354 runningAgents($),
355 contextTokens($),
356 autoCompactOff($),
357 ])
358 const facts: string[] = []
359 // A running turn, or agents we cannot list, is not "idle": report busy so core skips instead of compacting under them.
360 if (busy || agents === undefined) facts.push('--busy')
361 if (agents !== undefined) facts.push('--agents-running', String(agents))
362 if (tokens !== undefined) facts.push('--context-tokens', String(tokens))
363 if (autoOff) facts.push('--harness-autocompact-off')
364 return facts
365}
366
367type Outcome = { result: 'ok' | 'failed' | 'vetoed'; reason?: string }
368
369function compacted(tokensBefore: number | undefined, tokensAfter: number | undefined): Outcome {
370 if (tokensBefore === undefined || tokensAfter === undefined) return { result: 'ok' }
371 return { result: 'ok', reason: `${Math.round(tokensBefore / 1000)}k -> ${Math.round(tokensAfter / 1000)}k tokens` }
372}
373
374/** Core said "act" and has claimed the stage, so exactly one `done` must follow whatever happens here. */
375async function compactNow($: EngineInterface, s: State, armed: Armed, epoch: number): Promise<Outcome> {
376 if (s.epoch !== epoch) return { result: 'vetoed', reason: 'user returned' }
377 try {
378 const result = await $.session.compact()
379 if (result.skip !== undefined) return { result: 'vetoed', reason: result.skip }
380 return compacted(result.tokensBefore, result.tokensAfter)
381 } catch (err) {
382 return { result: 'failed', reason: describeError(err) }
383 }
384}
385
386async function onTimer($: EngineInterface, s: State, stage: Stage, armed: Armed, epoch: number): Promise<void> {
387 try {
388 if (s.armed !== armed || s.epoch !== epoch) return
389 const facts = stage === 'compact' ? await compactFacts($, s.busy) : []
390 if (s.armed !== armed || s.epoch !== epoch) return
391 const args = ['fire', stage, ...who(armed), '--anchor', String(armed.anchor), ...facts]
392 const reply = await enqueue(s, () => cli($, s, args))
393 const problem = failure(reply)
394 if (problem !== undefined || !isRecord(reply)) {
395 note($, s, `fire ${stage} failed: ${problem ?? 'unexpected reply'}`)
396 return
397 }
398 const detail = typeof reply.detail === 'string' ? `: ${reply.detail}` : ''
399 note($, s, `fire ${stage}: ${String(reply.decision)} (${String(reply.reason)})${detail}`)
400 armed.fired[stage] = reply.decision === 'act' ? 'act' : `skip: ${String(reply.reason)}`
401 // Push and warn are core's job. Only a compact "act" asks anything of us.
402 if (stage !== 'compact' || reply.decision !== 'act') return
403
404 const outcome = await compactNow($, s, armed, epoch)
405 const done = ['done', 'compact', ...who(armed), '--result', outcome.result]
406 if (outcome.reason !== undefined) done.push('--reason', outcome.reason)
407 const doneReply = await enqueue(s, () => cli($, s, done))
408 note($, s, `compact ${outcome.result}${outcome.reason ? ` (${outcome.reason})` : ''}${failure(doneReply) ? `; done failed: ${failure(doneReply)}` : ''}`)
409 } catch (err) {
410 note($, s, `timer ${stage} error: ${describeError(err)}`)
411 }
412}
413
414// -- session start ---------------------------------------------------------
415
416async function startTask($: EngineInterface, s: State, epoch: number): Promise<void> {
417 if (!(await gate($, s))) return
418 try {
419 await $.command.register({ name: COMMAND, description: 'Show Meridian idle stretch and timers' })
420 } catch (err) {
421 note($, s, `command registration failed: ${describeError(err)}`)
422 }
423 try {
424 await recover($, s, await $.session.id(), epoch)
425 } catch (err) {
426 note($, s, `recover error: ${describeError(err)}`)
427 }
428}
429
430// -- /meridian-idle --------------------------------------------------------
431
432async function statusText($: EngineInterface, s: State): Promise<string> {
433 // The host already prefixes a plugin command's output with the plugin's name.
434 if (s.inert) return 'inactive for this session'
435 const now = await $.clock.now()
436 const lines: string[] = []
437 const armed = s.armed
438 if (armed === undefined) {
439 lines.push('no stretch armed')
440 } else {
441 lines.push(`stretch ${armed.stretch}, anchor ${armed.anchor}`)
442 for (const stage of STAGES) {
443 const when = armed.at[stage]
444 if (when === undefined) continue
445 const fired = armed.fired[stage]
446 const timing = fired !== undefined ? `fired (${fired})` : when <= now ? 'due' : `in ${Math.round((when - now) / 1000)}s`
447 lines.push(` ${stage.padEnd(7)} ${timing}`)
448 }
449 }
450 if (s.log.length > 0) {
451 lines.push('log:')
452 for (const line of s.log.slice(-8)) lines.push(` ${line}`)
453 }
454 return lines.join('\n')
455}
456
457// -- registration ----------------------------------------------------------
458
459export const register: Register = on => {
460 const s = newState()
461
462 on('session.start', async ($, e, next) => {
463 s.startSeen = true
464 if (e.isInteractive === false || e.surface === null) {
465 s.inert = true
466 } else {
467 // Off the startup path: `meridian` takes about a second.
468 const epoch = s.epoch
469 void enqueue(s, () => startTask($, s, epoch))
470 }
471 return next(e)
472 })
473
474 // A subagent's run raises no turn.start, so this is always the main loop.
475 on('turn.start', async ($, e, next) => {
476 s.busy = true
477 return next(e)
478 })
479
480 on('turn.complete', async ($, e, next) => {
481 // A subagent's turn carries agentId; only the main loop's end is the user-visible idle.
482 if (e.agentId === undefined && !s.inert) {
483 s.busy = false
484 const epoch = s.epoch
485 void enqueue(s, () => armTask($, s, epoch, e.answer))
486 }
487 return next(e)
488 })
489
490 on('prompt.submit', async ($, e, next) => {
491 if (!s.inert && isUserOrigin(e.origin)) {
492 s.lastUserText = e.text
493 userReturned($, s)
494 }
495 return next(e)
496 })
497
498 // Typed slash commands never reach prompt.submit. `/meridian-idle` itself is a read-only look at the timers and does not count as a return.
499 on('command.run', { command: COMMAND }, async ($, e, next) => {
500 void next
501 return { text: await statusText($, s) }
502 })
503
504 on('command.run', async ($, e, next) => {
505 if (!s.inert && e.command !== COMMAND && isUserOrigin(e.origin)) {
506 s.lastUserText = `/${e.command}${e.args ? ` ${e.args}` : ''}`
507 userReturned($, s)
508 }
509 return next(e)
510 })
511
512 on('session.end', async ($, e, next) => {
513 s.epoch += 1
514 cancelTimers(s)
515 s.armed = undefined
516 return next(e)
517 })
518}
519