@renzrollon/interlock 1.1.1 → 1.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (55) hide show
  1. package/.claude-plugin/icon.png +0 -0
  2. package/.claude-plugin/plugin.json +109 -1
  3. package/CHANGELOG.md +150 -11
  4. package/bin/interlock +421 -53
  5. package/bin/interlock-run +119 -7
  6. package/hooks/guard-relaunch.mjs +115 -0
  7. package/hooks/guard-tests.mjs +51 -3
  8. package/hooks/hooks.json +1 -0
  9. package/hooks/mod.mjs +655 -0
  10. package/hooks/preflight.mjs +124 -13
  11. package/hooks/recorder.mjs +189 -0
  12. package/lib/agent-usage.mjs +524 -0
  13. package/lib/doctor.mjs +511 -46
  14. package/lib/eval-history.mjs +233 -0
  15. package/lib/evals-capture.mjs +5 -2
  16. package/lib/evals-triage.mjs +1 -1
  17. package/lib/graph/cli.mjs +28 -5
  18. package/lib/graph/retrieve/context.mjs +8 -3
  19. package/lib/host/acp.mjs +77 -14
  20. package/lib/host/claude-cli.mjs +391 -21
  21. package/lib/host/claude-env.mjs +179 -0
  22. package/lib/host/codex.mjs +9 -1
  23. package/lib/host/effort.mjs +163 -0
  24. package/lib/host/model-map.mjs +43 -0
  25. package/lib/host/qwen.mjs +9 -1
  26. package/lib/host/registry.mjs +44 -10
  27. package/lib/host.mjs +46 -5
  28. package/lib/interrupted.mjs +167 -0
  29. package/lib/launch-ledger.mjs +198 -0
  30. package/lib/launch-rule.mjs +174 -0
  31. package/lib/limits.mjs +149 -41
  32. package/lib/meter-quiet.mjs +45 -0
  33. package/lib/notify-defaults.mjs +6 -0
  34. package/lib/notify.mjs +41 -25
  35. package/lib/plan-fingerprint.mjs +80 -11
  36. package/lib/prompts/implementer.mjs +1 -1
  37. package/lib/prompts/verify.mjs +24 -1
  38. package/lib/receipt.mjs +59 -8
  39. package/lib/report-html.mjs +89 -6
  40. package/lib/report.mjs +313 -12
  41. package/lib/resume-card.mjs +90 -2
  42. package/lib/run-log.mjs +192 -5
  43. package/lib/run.mjs +1177 -84
  44. package/lib/state-home.mjs +298 -0
  45. package/lib/verify.mjs +37 -0
  46. package/lib/waves.mjs +53 -16
  47. package/package.json +4 -2
  48. package/shared/TEST-PROFILE.md +10 -5
  49. package/skills/evals/SKILL.md +3 -3
  50. package/skills/fix-tests/SKILL.md +3 -3
  51. package/skills/spec/SKILL.md +1 -6
  52. package/skills/spec/scripts/compact-instructions.mjs +30 -0
  53. package/types/index.d.ts +28 -0
  54. package/workflows/ship.js +75 -51
  55. package/.claude-plugin/marketplace.json +0 -19
@@ -0,0 +1,524 @@
1
+ // The agent-usage sidecar (design D2, D3) — what a ship run learns about each
2
+ // agent it spawned and each permission event that interrupted it, from host
3
+ // events the run itself cannot see.
4
+ //
5
+ // A Workflow script's `agent()` returns its result and nothing else: no usage,
6
+ // no served model, no word of a permission prompt. The host does fire
7
+ // `SubagentStart`, `SubagentStop`, `PermissionDenied` and `PermissionRequest`
8
+ // hooks, and the stop names the agent's own transcript. The recorder
9
+ // (`hooks/recorder.mjs`) writes one small file per key here, and `run close`
10
+ // joins the agent files to the spawns it dispatched by the briefing sha each
11
+ // transcript carries.
12
+ //
13
+ // ONE FILE PER KEY, WHOLE-FILE WRITES. `.claude/ship/agent-usage/<runId>/
14
+ // <agentId>.json` per agent and `permission-<kind>-<atMs>-<pid>-<seq>.json` per
15
+ // permission event. Never a JSONL append, so a writer that can only replace a
16
+ // file (the Brief 9 mod's `$.fs.write`) writes the same records and the join
17
+ // does not change. The start writes only when the file is absent — the dedup
18
+ // across the start, resume and teammate-message firings the host documents —
19
+ // and the stop replaces it with the summed figures.
20
+ //
21
+ // OUTCOME-CLASS, NOT FATAL. A failed write is a stderr line from the hook and
22
+ // nothing more; the sidecar points at records the close derives, beside the
23
+ // interrupted-run note (`lib/interrupted.mjs`). The run trajectory's failed
24
+ // append exits 1 instead — deliberately; see docs/13-the-guards.md before making
25
+ // the two consistent.
26
+ //
27
+ // UNKNOWN IS NEVER ZERO. A figure any turn omits is `null` for the agent, a
28
+ // transcript that cannot be read is `null` throughout, and an absent directory
29
+ // is `present: false`, not an empty sidecar. A zero here would be read by the
30
+ // close as a measured cache miss.
31
+ //
32
+ // Every function collapses a failure to a value and never throws, because its
33
+ // callers are hooks, and a hook whose own crash is its output is worse than no
34
+ // hook. No subprocess, no git: one `mkdirSync` and one write per record.
35
+
36
+ import { mkdirSync, readFileSync, readdirSync, writeFileSync } from 'node:fs'
37
+ import { join } from 'node:path'
38
+ import { SHIP_DIR } from './ship-stage.mjs'
39
+
40
+ export const AGENT_USAGE_SCHEMA = 'interlock.agent-usage/1'
41
+
42
+ /** Where the sidecar lives, per run, in the working root beside `state.json`. */
43
+ export const AGENT_USAGE_DIR = join(SHIP_DIR, 'agent-usage')
44
+
45
+ /**
46
+ * The one agent type the recorder's subagent branches accept (design D1). The
47
+ * host reports it for every agent a Workflow script spawns, whatever `type` the
48
+ * script named; a plugin agent type reaches a subagent event only as an
49
+ * internal agent of a `claude --agent` session, which must write nothing. The
50
+ * hook and the manifest-registration test import this one definition.
51
+ */
52
+ export const WORKFLOW_AGENT_TYPE = 'workflow-subagent'
53
+
54
+ /**
55
+ * Free text is bounded to the trajectory's own bound (`MAX_TEXT` in
56
+ * `lib/run-log.mjs`; `test/spine/agent-usage.test.mjs` pins the two equal).
57
+ * Restated rather than imported so a hook loads two small modules and not the
58
+ * trajectory writer.
59
+ */
60
+ export const TEXT_MAX = 500
61
+
62
+ /** The bound on the served-model list one agent record carries. */
63
+ export const MODELS_MAX = 8
64
+
65
+ /**
66
+ * The join key: the sentence the bootstrap text ends on
67
+ * (`test/fixtures/prompts/bootstrap.txt`). Not global, so callers share no
68
+ * `lastIndex`; the summary scans with a global copy to find the last match.
69
+ */
70
+ export const BRIEFING_SHA_PATTERN = /Expected sha256: ([0-9a-f]{64})/
71
+
72
+ const BRIEFING_SHA_SCAN = new RegExp(BRIEFING_SHA_PATTERN.source, 'g')
73
+
74
+ /** The model id the host writes on turns it made up itself (errors, interrupts). */
75
+ const SYNTHETIC_MODEL = '<synthetic>'
76
+
77
+ const PERMISSION_KINDS = Object.freeze(['denied', 'request'])
78
+
79
+ /** Agent files share the directory with permission files; an agent id must not read back as one. */
80
+ const PERMISSION_PREFIX = 'permission-'
81
+
82
+ function messageOf(err) {
83
+ return (err && err.message) || String(err)
84
+ }
85
+
86
+ function isObject(value) {
87
+ return Boolean(value) && typeof value === 'object' && !Array.isArray(value)
88
+ }
89
+
90
+ /** A string bounded to `TEXT_MAX`, or `null` for anything that is not a string. */
91
+ function boundedString(value) {
92
+ if (typeof value !== 'string') return null
93
+ return value.length > TEXT_MAX ? `${value.slice(0, TEXT_MAX - 1)}…` : value
94
+ }
95
+
96
+ /** The host's reason, stored as given and bounded; anything non-null that is not a string is stringified. */
97
+ function boundedReason(value) {
98
+ if (value === undefined || value === null) return null
99
+ return boundedString(typeof value === 'string' ? value : String(value))
100
+ }
101
+
102
+ function timestamp(at) {
103
+ return typeof at === 'string' ? at : new Date().toISOString()
104
+ }
105
+
106
+ /**
107
+ * An id safe to name a file or directory after: the shape `createRunState`
108
+ * mints and the host's agent ids, and nothing with a separator — the same rule
109
+ * `lib/interrupted.mjs` holds for run ids.
110
+ */
111
+ export function isSafeSegment(value) {
112
+ return typeof value === 'string' && /^[A-Za-z0-9][A-Za-z0-9._-]*$/.test(value) && value.length <= 200
113
+ }
114
+
115
+ /** The run's sidecar directory, rooted at `root`, or `null` for an unsafe run id. */
116
+ export function agentUsageDir(root, runId) {
117
+ if (!isSafeSegment(runId)) return null
118
+ return join(root, AGENT_USAGE_DIR, runId)
119
+ }
120
+
121
+ /**
122
+ * One agent's file: `<root>/.claude/ship/agent-usage/<runId>/<agentId>.json`,
123
+ * or `null` when either id is not a safe segment — or when the agent id would
124
+ * read back as a permission record.
125
+ */
126
+ export function agentFilePath(root, runId, agentId) {
127
+ const dir = agentUsageDir(root, runId)
128
+ if (dir === null || !isSafeSegment(agentId) || agentId.startsWith(PERMISSION_PREFIX)) return null
129
+ return join(dir, `${agentId}.json`)
130
+ }
131
+
132
+ /** Milliseconds from an ISO string or a number, or `null`. */
133
+ function stampOf(at) {
134
+ if (typeof at === 'number') return Number.isFinite(at) && at >= 0 ? Math.trunc(at) : null
135
+ if (typeof at === 'string') {
136
+ const ms = Date.parse(at)
137
+ return Number.isFinite(ms) && ms >= 0 ? ms : null
138
+ }
139
+ return null
140
+ }
141
+
142
+ function isCounter(value) {
143
+ return Number.isSafeInteger(value) && value >= 0
144
+ }
145
+
146
+ /**
147
+ * One permission event's file:
148
+ * `<root>/.claude/ship/agent-usage/<runId>/permission-<denied|request>-<atMs>-<pid>-<seq>.json`,
149
+ * or `null` on an unsafe run id, a kind that is neither, or a stamp, pid or
150
+ * sequence number that is not a non-negative integer.
151
+ */
152
+ export function permissionFilePath(root, runId, kind, at, pid, seq) {
153
+ const dir = agentUsageDir(root, runId)
154
+ const ms = stampOf(at)
155
+ if (dir === null || !PERMISSION_KINDS.includes(kind) || ms === null || !isCounter(pid) || !isCounter(seq)) return null
156
+ return join(dir, `${PERMISSION_PREFIX}${kind}-${ms}-${pid}-${seq}.json`)
157
+ }
158
+
159
+ // ---------------------------------------------------------------- the transcript
160
+
161
+ /** A usage figure as a finite non-negative number, or `null` for omitted. */
162
+ function figure(value) {
163
+ return typeof value === 'number' && Number.isFinite(value) && value >= 0 ? value : null
164
+ }
165
+
166
+ /** One turn's cache creation: the tier split, a total with the split unknown, or unknown. */
167
+ function cacheCreationOf(usage) {
168
+ const tiers = usage.cache_creation
169
+ if (isObject(tiers)) {
170
+ const m5 = figure(tiers.ephemeral_5m_input_tokens)
171
+ const h1 = figure(tiers.ephemeral_1h_input_tokens)
172
+ if (m5 !== null && h1 !== null) return { ephemeral_5m: m5, ephemeral_1h: h1 }
173
+ }
174
+ const total = figure(usage.cache_creation_input_tokens)
175
+ return total === null ? null : { total }
176
+ }
177
+
178
+ /** A sum over turns that is `null` the moment any turn omitted the figure. */
179
+ function sumOrNull(values) {
180
+ let sum = 0
181
+ for (const v of values) {
182
+ if (v === null) return null
183
+ sum += v
184
+ }
185
+ return sum
186
+ }
187
+
188
+ function sumCacheCreation(turns) {
189
+ const each = turns.map(cacheCreationOf)
190
+ if (each.some(c => c === null)) return null
191
+ if (each.every(c => 'ephemeral_5m' in c)) {
192
+ return {
193
+ ephemeral_5m: each.reduce((s, c) => s + c.ephemeral_5m, 0),
194
+ ephemeral_1h: each.reduce((s, c) => s + c.ephemeral_1h, 0)
195
+ }
196
+ }
197
+ return { total: each.reduce((s, c) => s + ('total' in c ? c.total : c.ephemeral_5m + c.ephemeral_1h), 0) }
198
+ }
199
+
200
+ /** The text a user line carries: string content, or its `text` blocks — never a `tool_result`. */
201
+ function userTexts(message) {
202
+ if (!isObject(message)) return []
203
+ const { content } = message
204
+ if (typeof content === 'string') return [content]
205
+ if (!Array.isArray(content)) return []
206
+ return content.filter(b => isObject(b) && b.type === 'text' && typeof b.text === 'string').map(b => b.text)
207
+ }
208
+
209
+ function lastBriefingSha(texts) {
210
+ let sha = null
211
+ for (const text of texts) {
212
+ for (const m of text.matchAll(BRIEFING_SHA_SCAN)) sha = m[1]
213
+ }
214
+ return sha
215
+ }
216
+
217
+ /**
218
+ * Summarize one agent's transcript (JSON Lines). Pure; never throws.
219
+ *
220
+ * A TURN IS ONE ASSISTANT MESSAGE, NOT ONE LINE. The host writes one
221
+ * `assistant` line per content block (thinking, text, each tool call) and
222
+ * repeats the request's `message.usage` on every one, with the final output
223
+ * count only on the last. Lines whose `message.usage` is an object are grouped
224
+ * by `message.id`, else `requestId`, else the line itself, and the last line of
225
+ * each group is that turn's usage. Summing per line would double every input.
226
+ *
227
+ * THE JOIN KEY is the LAST briefing sha in the `user` lines that precede the
228
+ * FIRST `assistant` line. Last, and over every pre-assistant user line rather
229
+ * than the first: the 2.1.289 engine relays the user's own request as a first
230
+ * harness user turn and puts the computed task, indented, in a second, and the
231
+ * bootstrap's sha line is the last line it writes. Stopping at the first
232
+ * assistant line: tool results are `user` lines too, so an agent that reads a
233
+ * file carrying bootstrap text must not rejoin itself to another spawn.
234
+ *
235
+ * @param {string} text
236
+ * @returns {{
237
+ * usage: null | {inputTokens: number|null, outputTokens: number|null, cacheReadInputTokens: number|null,
238
+ * cacheCreationInputTokens: null | {ephemeral_5m: number, ephemeral_1h: number} | {total: number}},
239
+ * models: string[], turns: number, briefingSha: string|null, skipped: number, reason: string|null
240
+ * }}
241
+ */
242
+ export function summarizeTranscript(text) {
243
+ if (typeof text !== 'string') {
244
+ return { usage: null, models: [], turns: 0, briefingSha: null, skipped: 0, reason: 'transcript is not text' }
245
+ }
246
+ let skipped = 0
247
+ let parsedLines = 0
248
+ let seenAssistant = false
249
+ const briefingTexts = []
250
+ const groups = new Map()
251
+ const lines = text.split(/\r?\n/)
252
+ for (let index = 0; index < lines.length; index++) {
253
+ if (!lines[index].trim()) continue
254
+ let row
255
+ try {
256
+ row = JSON.parse(lines[index])
257
+ } catch {
258
+ skipped++
259
+ continue
260
+ }
261
+ parsedLines++
262
+ if (!isObject(row)) continue
263
+ if (row.type === 'user' && !seenAssistant) briefingTexts.push(...userTexts(row.message))
264
+ if (row.type !== 'assistant') continue
265
+ seenAssistant = true
266
+ const message = row.message
267
+ if (!isObject(message) || !isObject(message.usage)) continue
268
+ const key =
269
+ typeof message.id === 'string' && message.id
270
+ ? `id:${message.id}`
271
+ : typeof row.requestId === 'string' && row.requestId
272
+ ? `request:${row.requestId}`
273
+ : `line:${index}`
274
+ // A Map keeps the first-seen order of its keys; the value is the group's last line.
275
+ groups.set(key, message)
276
+ }
277
+
278
+ const briefingSha = lastBriefingSha(briefingTexts)
279
+ const turns = [...groups.values()]
280
+ const models = []
281
+ for (const message of turns) {
282
+ const model = message.model
283
+ if (typeof model !== 'string' || !model || model === SYNTHETIC_MODEL || models.includes(model)) continue
284
+ if (models.length < MODELS_MAX) models.push(model)
285
+ }
286
+
287
+ if (turns.length === 0) {
288
+ const reason =
289
+ parsedLines === 0
290
+ ? skipped > 0
291
+ ? `no line of the transcript parsed (${skipped} skipped)`
292
+ : 'transcript is empty'
293
+ : 'no assistant turn with usage'
294
+ return { usage: null, models, turns: 0, briefingSha, skipped, reason }
295
+ }
296
+
297
+ const usages = turns.map(m => m.usage)
298
+ const usage = {
299
+ inputTokens: sumOrNull(usages.map(u => figure(u.input_tokens))),
300
+ outputTokens: sumOrNull(usages.map(u => figure(u.output_tokens))),
301
+ cacheReadInputTokens: sumOrNull(usages.map(u => figure(u.cache_read_input_tokens))),
302
+ cacheCreationInputTokens: sumCacheCreation(usages)
303
+ }
304
+ return { usage, models, turns: turns.length, briefingSha, skipped, reason: null }
305
+ }
306
+
307
+ /**
308
+ * Summarize the transcript at `path`. Never throws. A file that cannot be read
309
+ * is unknown throughout — `usage`, `models`, `turns`, `briefingSha` and
310
+ * `skipped` all `null` — never zero or empty. `transcript.parsed` says whether
311
+ * the file was read; `transcript.reason` says why there is no usage, or how
312
+ * many torn lines were skipped when there is.
313
+ *
314
+ * @returns {ReturnType<typeof summarizeTranscript> & {transcript: {path: string|null, parsed: boolean, reason: string|null}}}
315
+ */
316
+ export function summarizeTranscriptFile(path) {
317
+ const unread = reason => ({
318
+ usage: null,
319
+ models: null,
320
+ turns: null,
321
+ briefingSha: null,
322
+ skipped: null,
323
+ reason,
324
+ transcript: { path: typeof path === 'string' ? path : null, parsed: false, reason }
325
+ })
326
+ if (typeof path !== 'string' || !path) return unread('the event named no transcript path')
327
+ let text
328
+ try {
329
+ text = readFileSync(path, 'utf8')
330
+ } catch (err) {
331
+ return unread(`transcript unreadable: ${messageOf(err)}`)
332
+ }
333
+ const summary = summarizeTranscript(text)
334
+ const reason =
335
+ summary.reason !== null
336
+ ? summary.reason
337
+ : summary.skipped > 0
338
+ ? `${summary.skipped} unparseable line${summary.skipped === 1 ? '' : 's'} skipped`
339
+ : null
340
+ return { ...summary, transcript: { path, parsed: true, reason } }
341
+ }
342
+
343
+ // ---------------------------------------------------------------- the writers
344
+
345
+ function writeRecord(dir, path, record, flag) {
346
+ try {
347
+ mkdirSync(dir, { recursive: true })
348
+ writeFileSync(path, JSON.stringify(record, null, 2) + '\n', flag ? { flag } : undefined)
349
+ return { written: true, path }
350
+ } catch (err) {
351
+ if (flag === 'wx' && err && err.code === 'EEXIST') return { written: false, reason: 'already recorded' }
352
+ return { written: false, reason: `${path}: ${messageOf(err)}` }
353
+ }
354
+ }
355
+
356
+ function refusedIds(runId, agentId) {
357
+ if (!isSafeSegment(runId)) return `not a usable run id: ${JSON.stringify(runId)}`
358
+ return `not a usable agent id: ${JSON.stringify(agentId)}`
359
+ }
360
+
361
+ /**
362
+ * Record an agent's start: the file is written only when absent (exclusive
363
+ * create), so the start, resume and teammate-message firings of one agent leave
364
+ * one record, and a start arriving after the stop changes nothing.
365
+ *
366
+ * @param {string} root
367
+ * @param {{runId: string, agentId: string, agentType?: string, at?: string}} event
368
+ * @returns {{written: true, path: string} | {written: false, reason: string}}
369
+ */
370
+ export function writeAgentStart(root, { runId, agentId, agentType, at } = {}) {
371
+ const path = agentFilePath(root, runId, agentId)
372
+ if (path === null) return { written: false, reason: refusedIds(runId, agentId) }
373
+ const record = {
374
+ schema: AGENT_USAGE_SCHEMA,
375
+ agentId,
376
+ agentType: boundedString(agentType),
377
+ startedAt: timestamp(at),
378
+ stoppedAt: null,
379
+ briefingSha: null,
380
+ models: null,
381
+ turns: null,
382
+ usage: null,
383
+ transcript: null
384
+ }
385
+ return writeRecord(agentUsageDir(root, runId), path, record, 'wx')
386
+ }
387
+
388
+ /** The start time an existing record carries, or `null` when there is none to read. */
389
+ function recordedStart(path) {
390
+ try {
391
+ const value = JSON.parse(readFileSync(path, 'utf8'))
392
+ return isObject(value) && typeof value.startedAt === 'string' ? value.startedAt : null
393
+ } catch {
394
+ return null
395
+ }
396
+ }
397
+
398
+ /**
399
+ * Record an agent's stop: summarize its transcript and replace the file with
400
+ * the summed figures, keeping the start time a readable earlier record carries.
401
+ * A transcript that cannot be read is recorded with `usage: null` and the
402
+ * reason, never with zeros.
403
+ *
404
+ * @param {string} root
405
+ * @param {{runId: string, agentId: string, agentType?: string, at?: string, transcriptPath?: string}} event
406
+ * @returns {{written: true, path: string} | {written: false, reason: string}}
407
+ */
408
+ export function writeAgentStop(root, { runId, agentId, agentType, at, transcriptPath } = {}) {
409
+ const path = agentFilePath(root, runId, agentId)
410
+ if (path === null) return { written: false, reason: refusedIds(runId, agentId) }
411
+ const summary = summarizeTranscriptFile(transcriptPath)
412
+ const record = {
413
+ schema: AGENT_USAGE_SCHEMA,
414
+ agentId,
415
+ agentType: boundedString(agentType),
416
+ startedAt: recordedStart(path),
417
+ stoppedAt: timestamp(at),
418
+ briefingSha: summary.briefingSha,
419
+ models: summary.models,
420
+ turns: summary.turns,
421
+ usage: summary.usage,
422
+ transcript: summary.transcript
423
+ }
424
+ return writeRecord(agentUsageDir(root, runId), path, record)
425
+ }
426
+
427
+ /** Distinguishes two events one process records within one millisecond. */
428
+ let nextSeq = 0
429
+
430
+ /**
431
+ * Record one permission event as one new file (exclusive create). Each field is
432
+ * copied by name and bounded; `reason` is `null` when the host gave none, as the
433
+ * captured `PermissionRequest` does. The tool input and the host's permission
434
+ * suggestions are never recorded — this function does not even accept them.
435
+ *
436
+ * @param {string} root
437
+ * @param {{runId: string, kind: 'denied'|'request', at?: string, agentId?: string, tool?: string,
438
+ * reason?: string, pid?: number, seq?: number}} event
439
+ * @returns {{written: true, path: string} | {written: false, reason: string}}
440
+ */
441
+ export function writePermissionEvent(root, { runId, kind, at, agentId, tool, reason, pid, seq } = {}) {
442
+ if (!isSafeSegment(runId)) return { written: false, reason: `not a usable run id: ${JSON.stringify(runId)}` }
443
+ if (!PERMISSION_KINDS.includes(kind)) return { written: false, reason: `not a permission event kind: ${JSON.stringify(kind)}` }
444
+ const when = timestamp(at)
445
+ const path = permissionFilePath(
446
+ root,
447
+ runId,
448
+ kind,
449
+ // The stamp only orders and separates file names; the record keeps `at` as given.
450
+ stampOf(when) ?? Date.now(),
451
+ pid === undefined ? process.pid : pid,
452
+ seq === undefined ? nextSeq++ : seq
453
+ )
454
+ if (path === null) return { written: false, reason: `not a usable pid or sequence number: ${JSON.stringify({ pid, seq })}` }
455
+ const record = {
456
+ schema: AGENT_USAGE_SCHEMA,
457
+ kind,
458
+ at: when,
459
+ agentId: boundedString(agentId),
460
+ tool: boundedString(tool),
461
+ reason: boundedReason(reason)
462
+ }
463
+ return writeRecord(agentUsageDir(root, runId), path, record, 'wx')
464
+ }
465
+
466
+ // ---------------------------------------------------------------- the reader
467
+
468
+ function isAgentRecord(value) {
469
+ return isObject(value) && value.schema === AGENT_USAGE_SCHEMA && typeof value.agentId === 'string' && value.agentId !== ''
470
+ }
471
+
472
+ function isPermissionRecord(value) {
473
+ return isObject(value) && value.schema === AGENT_USAGE_SCHEMA && PERMISSION_KINDS.includes(value.kind)
474
+ }
475
+
476
+ /**
477
+ * Every record of one run. `present: false` only when the run's directory does
478
+ * not exist — that is how the close tells "no sidecar" from "an empty one". A
479
+ * file that does not parse, or parses to something that is not a record, is
480
+ * returned under `unreadable` by its root-relative name and reason — never
481
+ * dropped, because an agent nobody can read is still an agent the run spawned.
482
+ *
483
+ * @returns {{present: boolean, agents: object[], permissions: object[], unreadable: Array<{file: string, reason: string}>}}
484
+ */
485
+ export function readAgentUsage(root, runId) {
486
+ if (!isSafeSegment(runId)) {
487
+ return {
488
+ present: false,
489
+ agents: [],
490
+ permissions: [],
491
+ unreadable: [{ file: AGENT_USAGE_DIR, reason: `not a usable run id: ${JSON.stringify(runId)}` }]
492
+ }
493
+ }
494
+ const relDir = join(AGENT_USAGE_DIR, runId)
495
+ let names
496
+ try {
497
+ names = readdirSync(join(root, relDir)).filter(name => name.endsWith('.json')).sort()
498
+ } catch (err) {
499
+ if (err && err.code === 'ENOENT') return { present: false, agents: [], permissions: [], unreadable: [] }
500
+ return { present: true, agents: [], permissions: [], unreadable: [{ file: relDir, reason: messageOf(err) }] }
501
+ }
502
+ const agents = []
503
+ const permissions = []
504
+ const unreadable = []
505
+ for (const name of names) {
506
+ const file = join(relDir, name)
507
+ let value
508
+ try {
509
+ value = JSON.parse(readFileSync(join(root, file), 'utf8'))
510
+ } catch (err) {
511
+ unreadable.push({ file, reason: messageOf(err) })
512
+ continue
513
+ }
514
+ if (name.startsWith(PERMISSION_PREFIX)) {
515
+ if (isPermissionRecord(value)) permissions.push({ ...value, file })
516
+ else unreadable.push({ file, reason: 'not a permission record: no known kind or schema' })
517
+ } else if (isAgentRecord(value)) {
518
+ agents.push({ ...value, file })
519
+ } else {
520
+ unreadable.push({ file, reason: 'not an agent record: no agentId or schema' })
521
+ }
522
+ }
523
+ return { present: true, agents, permissions, unreadable }
524
+ }