SLOPSHOPPER

agent-state

Reports what a Claude Code session is really doing (idle / working / tool / permission, plus signals and token use) from inside the session, as a JSON snapshot…

newguardprompttimer
v0.1.1MITupdated 2026-10-09kleinheinczg/claude-code-agent-state-mod
A shopper browsing a rack in a slop shop
README

agent-state

A Claude Code mod that reports what a session is really doing, from inside the session, instead of guessing it from the terminal screen.

If you run Claude Code sessions unattended (in tmux, as background agents, several at once), the usual way to know whether one is busy, stuck on a permission prompt, or idle with a half-typed prompt is to scrape the screen. That breaks on every rendering change, and a dim autocomplete suggestion looks exactly like text someone typed. This mod asks the engine instead and writes the answer to two plain files you can read from anything.

Install

At the prompt of a Claude Code terminal session:

/plugin install agent-state --marketplace kleinheinczg/claude-code-agent-state-mod

Answer y to add the marketplace, then pick a scope (user scope: every session from then on).

What it writes

Per session, in ~/.claude/agent-state/ (change it with the outputDir option in /config; do not point it at a shared or synced folder such as iCloud Drive or Dropbox, or the data listed under Privacy leaves the machine):

filecontent
<session-id>.jsonthe current snapshot, rewritten on every change and every 30 s
<session-id>.jsonlthe event log: state changes, signal changes, token use per turn (written in batches; rolls over to .jsonl.1 past ~1 MB)

Snapshot:

{
  "schema": 1,
  "session_id": "3f2a9c1e-...",
  "cwd": "/work/project",
  "cc_version": "2.1.295",
  "session_started_ms": 1791550000123,
  "state": "tool",
  "tool": "Bash",
  "state_since_ms": 1791550123456,
  "heartbeat_ms": 1791550125000,
  "signals": { "draft": { "value": "", "at_ms": 1791550120000 }, "context": { "value": "41", "at_ms": 1791550124000 } }
}

state: idle · working (the model is answering) · tool (a tool runs; tool names it) · permission (a permission prompt is waiting for a person; tool names the tool) · ended.

Liveness: the heartbeat rewrites the snapshot every 30 s. A heartbeat_ms older than about a minute means the session (or the mod) is not running, whatever state says.

signals:

signalvalue
draft1 while text waits in the prompt box (never the text)
suggest1 while the dim autocomplete suggestion shows (never its text)
commandthe slash command running, by name only, '' when it returned
turn_endhow the last turn ended (answer, aborted, ...)
quota_5h, quota_7dthe rate-limit window fill, whole percent
contextthe context window fill, whole percent
background, background_procbackground agent work / background processes still running
session_endwhy the session ended (clear, logout, ...)
api_error, noticesee the note below

Event log lines:

{"t":1791550123456,"kind":"state","state":"tool","tool":"Bash"}
{"t":1791550124000,"kind":"signal","signal":"context","value":"41"}
{"t":1791550130000,"kind":"usage","model":"claude-opus-5-5","sub_agent":false,"input":12,"output":480,"cache_read":91000,"cache_creation":2100}

Token use is summed per turn and per model: a turn that fell back to another model mid-way books each model its own line.

Privacy

Nothing you type leaves the session through this mod: the prompt box is reported only as whether text waits, the suggestion only as whether it shows, a slash command by name without its arguments, token use as numbers and the model name. The files are written only to your own disk; the mod makes no network call.

What the files DO hold, so you can judge where they may go:

  • the session's full working folder path (cwd), which can carry your user name and a client or project folder name;
  • the names of the tools the session calls, MCP tools included (an MCP tool's name can tell what the machine is connected to);
  • the model names and token counts.

The files are created with your default file permissions, so anyone who can read your home folder can read them too.

Lessons measured while building it (useful for other mod authors)

  1. **classic.* events did not reach the mod live** (Claude Code 2.1.294 and 2.1.295). The settings-hook events (classic.Stop, classic.Notification, classic.StopFailure, classic.PermissionRequest) fire in the plugin test kit, and the same hooks configured in settings.json ran in the same live session, yet not one event arrived at the mod's classic.* hooks. So:
  2. the permission state comes from tool.check: its result's decision === 'ask' arrived about 0.8 s before the dialog showed (and allow under bypass mode, so the mode is already in the verdict);
  3. the background count comes from the starting tool's result (backgroundTaskId, async_launched) and the task's terminal <task-notification>, not from classic.Stop's background_tasks. The classic.StopFailure and classic.Notification hooks stay registered: on an engine that delivers them, the api_error and notice signals start filling with no change here.
  4. A late verdict must not outlive its turn. A tool.check answer can land after the turn was interrupted; counted as-is it marks an idle session as waiting for permission, and the heartbeat keeps that fresh. The mod counts an ask only while the turn that asked is still open.
  5. The dim suggestion is not typed text. The engine reports it separately (prompt.suggest); only Tab or the right arrow puts it in the box, and only then does draft turn 1.

Development

claude plugin validate .
claude plugin test .
claude --plugin-dir .

License

MIT, see LICENSE.

Source 1 files
hooks/register.ts 472 lines
1import type { Register } from 'claude-code'
2
3// agent-state: what this Claude Code session is really doing, as the session itself sees it -- not read off the
4// screen. Two files per session in the output folder:
5//   <session-id>.json   the current snapshot (state, tool, signals, heartbeat), rewritten on every change
6//   <session-id>.jsonl  the event log (state changes, signal changes, per-turn token use), appended in batches
7//
8// REPORT ONLY, NEVER A GATE: every hook passes the event on unchanged and returns what the engine answered; a failure
9// in here is caught and the session goes on. A snapshot whose heartbeat_ms is older than ~1 minute means the mod is
10// not running (the heartbeat rewrites it every 30 s).
11//
12// PRIVACY: the prompt box is reported only as WHETHER text waits (draft) or a suggestion shows (suggest), never the
13// text; a slash command by name only, never its arguments; token use as numbers and the model name only.
14
15export const HEARTBEAT_MS = 30_000
16export const LOG_MAX_CHARS = 1_000_000   // the .jsonl rolls over to .jsonl.1 past this (one previous file is kept)
17const SESSION_END_BUDGET_MS = 1_000      // session.end has a short bound: the last write is awaited up to this
18
19export type State = 'idle' | 'working' | 'tool' | 'permission' | 'ended'
20export type Signal = 'draft' | 'command' | 'turn_end' | 'suggest' | 'api_error' | 'notice' | 'session_end'
21  | 'quota_5h' | 'quota_7d' | 'context' | 'background' | 'background_proc'
22
23/** EVENT signals are logged every time; the rest are states, logged only when the value changes. */
24export const EVENT_SIGNALS: readonly Signal[] = ['turn_end', 'api_error', 'notice', 'session_end']
25
26/** Tools whose 'ask' verdict is a question to the person, not a permission prompt. */
27export const PERMISSION_EXEMPT_TOOLS = ['AskUserQuestion', 'ExitPlanMode', 'EnterPlanMode']
28
29/** Whether a tool.check verdict is the permission state: an 'ask', not a dialog tool's, in the turn still open. A
30 *  verdict that lands after its turn ended would otherwise mark an idle session as waiting for permission. */
31export function permissionVerdictCounts(decision: unknown, tool: string, turnOpen: boolean, askedIn: number, nowTurn: number): boolean {
32  return decision === 'ask' && !PERMISSION_EXEMPT_TOOLS.includes(tool) && turnOpen && askedIn === nowTurn
33}
34
35/** The output folder: the option when it is an absolute path (or starts with ~/), else <home>/.claude/agent-state.
36 *  null when no usable folder can be named (no home, a relative option): the mod then writes nothing. */
37export function outputDir(option: unknown, home: string | undefined): string | null {
38  const o = typeof option === 'string' ? option.trim().replace(/\/+$/, '') : ''
39  const h = typeof home === 'string' && home.startsWith('/') ? home.replace(/\/+$/, '') : null
40  if (o.startsWith('/')) return o
41  if (o.startsWith('~/')) return h ? h + o.slice(1) : null
42  if (o.length > 0) return null
43  return h ? h + '/.claude/agent-state' : null
44}
45
46/** A file-name-safe session id (the transcript's name is a UUID; anything else is reduced to [A-Za-z0-9_-]). */
47export function safeId(id: unknown): string | null {
48  const s = String(id ?? '').replace(/[^A-Za-z0-9_-]/g, '').slice(0, 80)
49  return s.length > 0 ? s : null
50}
51
52/** A word-shaped value for the EVENT signals: lower-cased, every other character an underscore, '' -> 'unknown'. */
53export function wordValue(raw: unknown): string {
54  const w = String(raw ?? '').toLowerCase().replace(/[^a-z0-9_]/g, '_').slice(0, 60)
55  return w.length > 0 ? w : 'unknown'
56}
57
58/** '1' while text (anything but whitespace) waits in the prompt box, '' otherwise. Never the text itself. */
59export function draftValue(text: string): string {
60  return text.trim().length > 0 ? '1' : ''
61}
62
63export function suggestValue(isShown: unknown): string {
64  return isShown === true ? '1' : ''
65}
66
67/** A whole number 0..9999 for the percent / count signals, '' when there is no number. */
68export function numberValue(raw: unknown): string {
69  if (typeof raw !== 'number' || !Number.isFinite(raw)) return ''
70  return String(Math.min(9999, Math.max(0, Math.round(raw))))
71}
72
73export function quotaSignal(kind: string): Signal | null {
74  return kind === 'five_hour' ? 'quota_5h' : kind === 'seven_day' ? 'quota_7d' : null
75}
76
77/** An edit or suggestion answer is stale when the box changed (a submit, typing, a new turn) while it was in flight. */
78export function editIsStale(changesAtStart: number, changesNow: number): boolean {
79  return changesAtStart !== changesNow
80}
81
82export function startedFrom(u: { startedAt?: unknown } | null | undefined): number | null {
83  const v = u?.startedAt
84  return typeof v === 'number' && Number.isInteger(v) && v > 1e12 && v < 1e13 ? v : null
85}
86
87// ---- token use: summed per turn and model ---------------------------------------------------------------------------
88// turn.complete's usage is the sum of the turn's requests under the LAST request's model, so a turn that switched
89// models (a fallback) would book everything to one. Each request's usage comes with its own model in turn.step.
90
91export type StepSum = { sub: boolean; model: string; input_tokens: number; output_tokens: number; cache_read_input_tokens: number; cache_creation_input_tokens: number }
92export const STEP_SUM_CAP = 200   // turns whose end never came must not grow the map without bound
93
94export function addStepUsage(sums: Map<string, StepSum>, turnId: string, sub: boolean, usage: unknown): void {
95  if (!turnId || !usage || typeof usage !== 'object') return
96  const u = usage as Record<string, unknown>
97  const model = typeof u['model'] === 'string' ? (u['model'] as string).slice(0, 80) : ''
98  const n = (k: string): number | null => (typeof u[k] === 'number' && Number.isInteger(u[k]) && (u[k] as number) >= 0 ? u[k] as number : null)
99  const inp = n('input_tokens'), out = n('output_tokens'), cr = n('cache_read_input_tokens'), cc = n('cache_creation_input_tokens')
100  if (!model || inp == null || out == null || cr == null || cc == null) return
101  const key = turnId + '\u0000' + model
102  const cur = sums.get(key)
103  if (cur) { cur.input_tokens += inp; cur.output_tokens += out; cur.cache_read_input_tokens += cr; cur.cache_creation_input_tokens += cc; return }
104  if (sums.size >= STEP_SUM_CAP) { const oldest = sums.keys().next().value; if (oldest !== undefined) sums.delete(oldest) }
105  sums.set(key, { sub, model, input_tokens: inp, output_tokens: out, cache_read_input_tokens: cr, cache_creation_input_tokens: cc })
106}
107
108/** The turn's per-model sums, removed from the map (so a turn is logged once). */
109export function takeTurnUsage(sums: Map<string, StepSum>, turnId: string): StepSum[] {
110  const out: StepSum[] = []
111  const prefix = turnId + '\u0000'
112  for (const [k, v] of [...sums]) if (k.startsWith(prefix)) { out.push(v); sums.delete(k) }
113  return out
114}
115
116// ---- background work: the mod keeps its own list ---------------------------------------------------------------------
117// Measured on Claude Code 2.1.294-2.1.295: classic.* events (the settings-hook events, among them Stop with its
118// background_tasks) did not reach a mod live, so the count comes from the starting tool's result and the task's
119// terminal notification. async Agent / Workflow = agent work; a backgrounded Bash and a Monitor = a process.
120
121export type BackgroundKind = 'agent' | 'process'
122export function backgroundStarted(tool: string, result: unknown): { id: string; kind: BackgroundKind } | null {
123  const r = (result ?? {}) as Record<string, unknown>
124  const id = (v: unknown) => (typeof v === 'string' && v.length > 0 && v.length <= 200 ? v : null)
125  if (tool === 'Bash') { const t = id(r.backgroundTaskId); return t ? { id: t, kind: 'process' } : null }
126  if (tool === 'Monitor') { const t = id(r.taskId); return t ? { id: t, kind: 'process' } : null }
127  if (tool === 'Agent' && r.status === 'async_launched') { const t = id(r.agentId); return t ? { id: t, kind: 'agent' } : null }
128  if (tool === 'Workflow' && (r.status === 'async_launched' || r.status === 'remote_launched')) {
129    const t = id(r.taskId); return t ? { id: t, kind: 'agent' } : null
130  }
131  return null
132}
133
134const TERMINAL_TASK_STATUS = ['completed', 'failed', 'killed', 'stopped', 'cancelled', 'canceled', 'error', 'timeout']
135/** The <task-id> of each <task-notification> block whose <status> is terminal, in order. */
136export function backgroundEnded(text: string): string[] {
137  const out: string[] = []
138  for (const b of text.split(/<task-notification>/).slice(1)) {
139    const id = /<task-id>\s*([^<\s]{1,200})\s*<\/task-id>/.exec(b)
140    const st = /<status>\s*([a-z_]{1,40})\s*<\/status>/i.exec(b)
141    if (id?.[1] && st?.[1] && TERMINAL_TASK_STATUS.includes(st[1].toLowerCase())) out.push(id[1])
142  }
143  return out
144}
145
146// An end that arrives BEFORE its start's tool result (a fast task) is kept and cancels that later start. Both lists are
147// bounded, so a lost end never grows them past a handful.
148export const RUNNING_CAP = 50
149export function applyStart(running: Map<string, BackgroundKind>, endedEarly: Set<string>, id: string, kind: BackgroundKind): boolean {
150  if (endedEarly.delete(id)) return false
151  running.set(id, kind)
152  while (running.size > RUNNING_CAP) { const first = running.keys().next().value; if (first === undefined) break; running.delete(first) }
153  return true
154}
155export function applyEnd(running: Map<string, BackgroundKind>, endedEarly: Set<string>, id: string): boolean {
156  if (running.delete(id)) return true
157  endedEarly.add(id)
158  while (endedEarly.size > RUNNING_CAP) { const first = endedEarly.values().next().value; if (first === undefined) break; endedEarly.delete(first) }
159  return false
160}
161export function countRunning(running: ReadonlyMap<string, BackgroundKind>): { agentWork: number; processes: number } {
162  let agentWork = 0, processes = 0
163  for (const k of running.values()) { if (k === 'agent') agentWork++; else processes++ }
164  return { agentWork, processes }
165}
166
167// ---- the two files -----------------------------------------------------------------------------------------------
168
169export type Snapshot = {
170  schema: 1
171  session_id: string
172  cwd: string
173  cc_version: string | null
174  session_started_ms: number | null
175  state: State
176  tool: string | null
177  state_since_ms: number
178  heartbeat_ms: number
179  signals: Record<string, { value: string; at_ms: number }>
180}
181
182/** New log lines appended to the old log text; past the cap the old text is returned as `rolled` (for .jsonl.1) and
183 *  the log starts over with the new lines alone. */
184export function appendLog(old: string, lines: readonly string[], cap: number = LOG_MAX_CHARS): { log: string; rolled: string | null } {
185  const add = lines.map(l => l + '\n').join('')
186  if (old.length > 0 && old.length + add.length > cap) return { log: add, rolled: old }
187  return { log: old + add, rolled: null }
188}
189
190// module state: a reload starts it over, and session.start fills it again (helpers that take $ must be top-level
191// functions: the loader follows $ only into those)
192let optionDir: unknown = undefined
193let dir: string | null = null
194let sid: string | null = null
195let snap: Snapshot | null = null
196let turnOpen = false
197let turnSeq = 0
198let submits = 0
199let boxChanges = 0
200let heartbeat: { cancel: () => void } | null = null
201const stepSums = new Map<string, StepSum>()
202const running = new Map<string, BackgroundKind>()
203const endedEarly = new Set<string>()
204// one write in flight; the snapshot is latest-wins, log lines queue in order
205let inFlight = false
206let snapDirty = false
207let logQueue: string[] = []
208let logDue = false   // the log is written in batches (heartbeat, turn end, session end): a read+rewrite per tool call
209                     // of a file up to LOG_MAX_CHARS would cost more than the report is worth
210
211const now = () => Date.now()
212
213// eslint-disable-next-line @typescript-eslint/no-explicit-any
214async function writeOnce($: any): Promise<void> {
215  if (!dir || !sid || !snap) return
216  if (snapDirty) {
217    snapDirty = false
218    await $.fs.write(`${dir}/${sid}.json`, JSON.stringify(snap) + '\n')
219  }
220  if (logDue && logQueue.length > 0) {
221    logDue = false
222    const lines = logQueue
223    logQueue = []
224    const path = `${dir}/${sid}.jsonl`
225    let old = ''
226    try { if (await $.fs.exists(path)) old = String(await $.fs.read(path)) } catch { old = '' }
227    const { log, rolled } = appendLog(old, lines)
228    if (rolled !== null) await $.fs.write(path + '.1', rolled)
229    await $.fs.write(path, log)
230  }
231}
232
233// eslint-disable-next-line @typescript-eslint/no-explicit-any
234function flush($: any): void {
235  // nothing can be written without a folder and a session: return, or the finally below would re-run flush forever
236  // on a flag no write ever clears
237  if (!dir || !sid || !snap) { snapDirty = false; logDue = false; return }
238  if (inFlight || !(snapDirty || (logDue && logQueue.length > 0))) return
239  inFlight = true
240  Promise.resolve()
241    .then(() => writeOnce($))
242    .catch(() => undefined)   // report only: the next change or heartbeat writes again
243    .finally(() => { inFlight = false; try { flush($) } catch { /* next change */ } })
244    .catch(() => undefined)
245}
246
247function log(entry: Record<string, unknown>): void {
248  logQueue.push(JSON.stringify({ t: now(), ...entry }))
249  if (logQueue.length > 5000) logQueue = logQueue.slice(-5000)   // a folder that cannot be written must not grow memory
250}
251
252// eslint-disable-next-line @typescript-eslint/no-explicit-any
253function report($: any, state: State, tool?: string): void {
254  if (!snap) return
255  const t = tool && (state === 'tool' || state === 'permission') ? tool.slice(0, 200) : null
256  if (snap.state !== state || snap.tool !== t) {
257    snap.state = state
258    snap.tool = t
259    snap.state_since_ms = now()
260    log({ kind: 'state', state, tool: t })
261  }
262  snap.heartbeat_ms = now()
263  snapDirty = true
264  flush($)
265}
266
267// eslint-disable-next-line @typescript-eslint/no-explicit-any
268function signal($: any, name: Signal, value: string): void {
269  if (!snap) return
270  const v = value.slice(0, 60)
271  const isEvent = EVENT_SIGNALS.includes(name)
272  if (!isEvent && (snap.signals[name]?.value ?? '') === v) return
273  snap.signals[name] = { value: v, at_ms: now() }
274  log({ kind: 'signal', signal: name, value: v })
275  snapDirty = true
276  flush($)
277}
278
279// eslint-disable-next-line @typescript-eslint/no-explicit-any
280function reportBackground($: any): void {
281  const c = countRunning(running)
282  signal($, 'background', c.agentWork > 0 ? numberValue(c.agentWork) : '')
283  signal($, 'background_proc', c.processes > 0 ? numberValue(c.processes) : '')
284}
285
286export const register: Register = (on, options) => {
287  optionDir = options?.['outputDir']
288
289  on('session.start', async ($, e, next) => {
290    try {
291      let home: string | undefined
292      try { home = await $.env.get('HOME') } catch { home = undefined }
293      dir = outputDir(optionDir, home)
294      try { sid = safeId(await $.session.id()) } catch { sid = null }
295      let version: string | null = null
296      try { version = (await $.session.version()).version ?? null } catch { version = null }
297      let started: number | null = null
298      try { started = startedFrom(await $.session.usage()) } catch { started = null }
299      running.clear(); endedEarly.clear(); stepSums.clear()
300      logQueue = []; logDue = false; snapDirty = false   // a new session's files start clean
301      turnOpen = false
302      const t = now()
303      snap = {
304        schema: 1, session_id: sid ?? '', cwd: String(e.cwd ?? '').slice(0, 1000), cc_version: version,
305        session_started_ms: started, state: 'idle', tool: null, state_since_ms: t, heartbeat_ms: t, signals: {},
306      }
307      log({ kind: 'session_start', cwd: snap.cwd, cc_version: version, session_started_ms: started })
308      log({ kind: 'state', state: 'idle', tool: null })   // the log's first state: report() below logs only changes
309      report($, 'idle')
310      if (heartbeat) heartbeat.cancel()
311      heartbeat = $.clock.every(HEARTBEAT_MS, async () => {
312        if (snap && snap.session_started_ms === null) {
313          try { snap.session_started_ms = startedFrom(await $.session.usage()) } catch { /* next beat */ }
314        }
315        logDue = true
316        if (snap) report($, snap.state, snap.tool ?? undefined)
317      })
318    } catch {
319      // a reporter never stops the session
320    }
321    return next(e)
322  })
323
324  on('turn.start', ($, e, next) => {
325    try { boxChanges++; turnSeq++; turnOpen = true; report($, 'working'); signal($, 'suggest', '') } catch { /* report only */ }
326    return next(e)
327  })
328
329  on('turn.step', async function* ($, e, next) {
330    const r = yield* next(e)
331    try { addStepUsage(stepSums, String(e.turnId ?? ''), typeof e.agentId === 'string', r?.usage) } catch { /* report only */ }
332    return r
333  })
334
335  on('turn.complete', ($, e, next) => {
336    try {
337      for (const s of takeTurnUsage(stepSums, String((e as { turnId?: unknown }).turnId ?? ''))) {
338        log({ kind: 'usage', model: s.model, sub_agent: s.sub, input: s.input_tokens, output: s.output_tokens,
339          cache_read: s.cache_read_input_tokens, cache_creation: s.cache_creation_input_tokens })
340      }
341      turnOpen = false
342      logDue = true
343      report($, 'idle')
344      signal($, 'turn_end', wordValue((e as { reason?: string }).reason ?? 'answer'))
345    } catch { /* report only */ }
346    return next(e)
347  })
348
349  // draft: the box after each edit (the result of next is the box as the editor will show it)
350  on('prompt.edit', async ($, e, next) => {
351    const seen = submits
352    const r = await next(e)
353    try {
354      if (!editIsStale(seen, submits)) {
355        const v = draftValue(String(r?.text ?? ''))
356        signal($, 'draft', v)
357        if (v) { boxChanges++; signal($, 'suggest', '') }   // typing (or Tab taking it) replaces the suggestion
358      }
359    } catch { /* report only */ }
360    return r
361  }).catch(($, e, next) => next(e))
362
363  on('prompt.submit', ($, e, next) => {
364    try { submits++; boxChanges++; signal($, 'draft', ''); signal($, 'suggest', '') } catch { /* report only */ }
365    try {
366      if ((e as { origin?: { kind?: string } }).origin?.kind === 'task-notification') {
367        let changed = false
368        for (const id of backgroundEnded(String(e.text ?? ''))) changed = applyEnd(running, endedEarly, id) || changed
369        if (changed) reportBackground($)
370      }
371    } catch { /* report only */ }
372    return next(e)
373  }).catch(($, e, next) => next(e))
374
375  // command: open while the slash command runs, by name only
376  on('command.run', async ($, e, next) => {
377    try { signal($, 'command', String(e.command)) } catch { /* report only */ }
378    try {
379      return await next(e)
380    } finally {
381      try { signal($, 'command', '') } catch { /* report only */ }
382    }
383  }).catch(($, e, next) => next(e))
384
385  on('tool.call', async ($, e, next) => {
386    try { report($, 'tool', String(e.tool)) } catch { /* report only */ }
387    const result = await next(e)
388    try { if (turnOpen) report($, 'working') } catch { /* report only */ }
389    try {
390      const r = result as { result?: unknown; isError?: boolean }
391      if (!r?.isError) {
392        const started = backgroundStarted(String(e.tool), r?.result)
393        if (started && applyStart(running, endedEarly, started.id, started.kind)) reportBackground($)
394        if (String(e.tool) === 'TaskStop') {
395          const inp = (e as { input?: { task_id?: unknown; shell_id?: unknown } }).input
396          const stopped = String(inp?.task_id ?? inp?.shell_id ?? '')
397          if (stopped && running.delete(stopped)) reportBackground($)
398        }
399      }
400    } catch { /* report only */ }
401    return result
402  }).catch(($, e, next) => next(e))   // never a refusal; when next was already called, next(e) replays its result
403
404  // The permission prompt: tool.check's 'ask' verdict, measured 0.8 s before the dialog shows (2.1.295). The call's
405  // result (tool.call above) turns it back to 'working'.
406  on('tool.check', async ($, e, next) => {
407    const askedIn = turnSeq
408    const r = await next(e)
409    try {
410      if (permissionVerdictCounts(r?.decision, String(e.tool), turnOpen, askedIn, turnSeq)) report($, 'permission', String(e.tool))
411    } catch { /* report only */ }
412    return r
413  }).catch(($, e, next) => next(e))   // never a refusal, never an approval: the verdict goes on as it came
414
415  // the dim suggestion: only WHETHER it shows, never its text
416  on('prompt.suggest', async ($, e, next) => {
417    const seen = boxChanges
418    const r = await next(e)
419    try { if (!editIsStale(seen, boxChanges)) signal($, 'suggest', suggestValue(r?.isShown)) } catch { /* report only */ }
420    return r
421  }).catch(($, e, next) => next(e))
422
423  // Kept registered although they did not reach a mod live on 2.1.294-2.1.295 (see the README): a later engine that
424  // delivers them adds rows with no change here.
425  on('classic.StopFailure', ($, e, next) => {
426    try { signal($, 'api_error', wordValue((e as { error?: string }).error)) } catch { /* report only */ }
427    return next(e)
428  }).catch(($, e, next) => next(e))
429
430  on('classic.Notification', ($, e, next) => {
431    try { signal($, 'notice', wordValue((e as { notification_type?: string }).notification_type)) } catch { /* report only */ }
432    return next(e)
433  }).catch(($, e, next) => next(e))
434
435  // the 5-hour / weekly window fill and the context fill, when a whole point moves
436  on('session.measure', ($, e, next) => {
437    try {
438      for (const w of e.rateLimits ?? []) {
439        const name = quotaSignal(String(w.kind))
440        if (name) signal($, name, numberValue(w.percentUsed))
441      }
442      signal($, 'context', numberValue(e.context?.percent))
443    } catch { /* report only */ }
444    return next(e)
445  })
446
447  // session.end has a short bound: the last snapshot and log lines are awaited up to 1 s, not queued
448  on('session.end', async ($, e, next) => {
449    try {
450      if (heartbeat) { heartbeat.cancel(); heartbeat = null }
451      if (snap) {
452        const reason = wordValue(e.reason)
453        snap.signals['session_end'] = { value: reason, at_ms: now() }
454        log({ kind: 'signal', signal: 'session_end', value: reason })
455        snap.state = 'ended'; snap.tool = null; snap.state_since_ms = now(); snap.heartbeat_ms = now()
456        log({ kind: 'state', state: 'ended', tool: null })
457        snapDirty = true
458        logDue = true
459        if (!inFlight) {
460          inFlight = true
461          await Promise.race([
462            Promise.resolve().then(() => writeOnce($)).catch(() => undefined),
463            Promise.resolve().then(() => $.clock.sleep(SESSION_END_BUDGET_MS)).catch(() => undefined),   // no Node timers in a mod
464          ])
465          inFlight = false
466        }
467      }
468    } catch { /* report only */ }
469    return next(e)
470  })
471}
472