@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.
- package/.claude-plugin/icon.png +0 -0
- package/.claude-plugin/plugin.json +109 -1
- package/CHANGELOG.md +150 -11
- package/bin/interlock +421 -53
- package/bin/interlock-run +119 -7
- package/hooks/guard-relaunch.mjs +115 -0
- package/hooks/guard-tests.mjs +51 -3
- package/hooks/hooks.json +1 -0
- package/hooks/mod.mjs +655 -0
- package/hooks/preflight.mjs +124 -13
- package/hooks/recorder.mjs +189 -0
- package/lib/agent-usage.mjs +524 -0
- package/lib/doctor.mjs +511 -46
- package/lib/eval-history.mjs +233 -0
- package/lib/evals-capture.mjs +5 -2
- package/lib/evals-triage.mjs +1 -1
- package/lib/graph/cli.mjs +28 -5
- package/lib/graph/retrieve/context.mjs +8 -3
- package/lib/host/acp.mjs +77 -14
- package/lib/host/claude-cli.mjs +391 -21
- package/lib/host/claude-env.mjs +179 -0
- package/lib/host/codex.mjs +9 -1
- package/lib/host/effort.mjs +163 -0
- package/lib/host/model-map.mjs +43 -0
- package/lib/host/qwen.mjs +9 -1
- package/lib/host/registry.mjs +44 -10
- package/lib/host.mjs +46 -5
- package/lib/interrupted.mjs +167 -0
- package/lib/launch-ledger.mjs +198 -0
- package/lib/launch-rule.mjs +174 -0
- package/lib/limits.mjs +149 -41
- package/lib/meter-quiet.mjs +45 -0
- package/lib/notify-defaults.mjs +6 -0
- package/lib/notify.mjs +41 -25
- package/lib/plan-fingerprint.mjs +80 -11
- package/lib/prompts/implementer.mjs +1 -1
- package/lib/prompts/verify.mjs +24 -1
- package/lib/receipt.mjs +59 -8
- package/lib/report-html.mjs +89 -6
- package/lib/report.mjs +313 -12
- package/lib/resume-card.mjs +90 -2
- package/lib/run-log.mjs +192 -5
- package/lib/run.mjs +1177 -84
- package/lib/state-home.mjs +298 -0
- package/lib/verify.mjs +37 -0
- package/lib/waves.mjs +53 -16
- package/package.json +4 -2
- package/shared/TEST-PROFILE.md +10 -5
- package/skills/evals/SKILL.md +3 -3
- package/skills/fix-tests/SKILL.md +3 -3
- package/skills/spec/SKILL.md +1 -6
- package/skills/spec/scripts/compact-instructions.mjs +30 -0
- package/types/index.d.ts +28 -0
- package/workflows/ship.js +75 -51
- 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
|
+
}
|