SLOPSHOPPER

meridian-idle

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

newcommandpromptprocesstimer
A shopper browsing a rack in a slop shop
README

Meridian idle plugin

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.

What it does

Every meridian idle command below carries --interactive; the table omits that common flag for readability.

Claude hookMeridian callWhy
session.start (TUI only)meridian idle config --harness claudeStays 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 resumemeridian idle status --jsonRebuilds timers from the stored schedule of this session's open stretch, skipping stages already done.
turn.complete without agentIdmeridian 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-promptThe 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: compactmeridian 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-idle

Prints the current stretch, anchor, time left on each timer and the last log lines. It does not count as a return.

Where it runs

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.

Developing it

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.

Source 1 files
hooks/register.ts 519 lines
1import 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