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…

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.
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).
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):
| file | content |
|---|---|
<session-id>.json | the current snapshot, rewritten on every change and every 30 s |
<session-id>.jsonl | the 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:
| signal | value |
|---|---|
draft | 1 while text waits in the prompt box (never the text) |
suggest | 1 while the dim autocomplete suggestion shows (never its text) |
command | the slash command running, by name only, '' when it returned |
turn_end | how the last turn ended (answer, aborted, ...) |
quota_5h, quota_7d | the rate-limit window fill, whole percent |
context | the context window fill, whole percent |
background, background_proc | background agent work / background processes still running |
session_end | why the session ended (clear, logout, ...) |
api_error, notice | see 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.
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:
cwd), which can carry your user name and a client or project folder name;The files are created with your default file permissions, so anyone who can read your home folder can read them too.
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: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);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.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.prompt.suggest); only Tab or the right arrow puts it in the box, and only then does draft turn 1.claude plugin validate .
claude plugin test .
claude --plugin-dir .
MIT, see LICENSE.
hooks/register.ts 472 lines1import 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