pi-better-subagents 0.1.0
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/README.md +420 -0
- package/batch.mjs +208 -0
- package/capacity.mjs +112 -0
- package/completion.mjs +165 -0
- package/completion.ts +11 -0
- package/config.json +14 -0
- package/config.ts +104 -0
- package/extensions.mjs +147 -0
- package/extensions.ts +19 -0
- package/finalization.ts +145 -0
- package/git-remotes.ts +413 -0
- package/git-workspace.ts +430 -0
- package/health-observation.ts +670 -0
- package/health-surface.mjs +276 -0
- package/health.ts +303 -0
- package/index.ts +1235 -0
- package/lifecycle.ts +333 -0
- package/list.mjs +123 -0
- package/list.ts +17 -0
- package/navigator.mjs +1188 -0
- package/navigator.ts +38 -0
- package/package.json +43 -0
- package/parse.ts +1144 -0
- package/registry.ts +236 -0
- package/sandbox.ts +164 -0
- package/spawn.ts +78 -0
- package/stop.ts +155 -0
- package/tools.ts +399 -0
- package/widget.mjs +218 -0
- package/widget.ts +28 -0
package/parse.ts
ADDED
|
@@ -0,0 +1,1144 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Parse a child run's `--mode json` NDJSON log into clean, human-facing text.
|
|
3
|
+
*
|
|
4
|
+
* The child streams one JSON event per line (message lifecycle + token deltas).
|
|
5
|
+
* Non-JSON lines — pi's `[pi-warp] …` banner, `Warning: No project session …`,
|
|
6
|
+
* any stray stderr — simply fail to parse and are skipped, so the noise that
|
|
7
|
+
* polluted `--mode text` output never reaches the caller.
|
|
8
|
+
*
|
|
9
|
+
* Large logs: reading the whole file can exceed Node's max string length
|
|
10
|
+
* (~536 MB) and makes live output expensive. parseRun() therefore reads only a
|
|
11
|
+
* bounded tail; final answers live at the end of completed logs, and recent
|
|
12
|
+
* activity is what a live caller needs.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import {
|
|
16
|
+
closeSync,
|
|
17
|
+
openSync,
|
|
18
|
+
readFileSync,
|
|
19
|
+
readSync,
|
|
20
|
+
statSync,
|
|
21
|
+
} from "node:fs";
|
|
22
|
+
import { logPathFor } from "./registry.ts";
|
|
23
|
+
|
|
24
|
+
interface ContentBlock { type: string; text?: string; name?: string }
|
|
25
|
+
interface Cost { total?: number }
|
|
26
|
+
interface MsgUsage { input?: number; output?: number; cacheRead?: number; cost?: Cost }
|
|
27
|
+
interface Msg { role?: string; content?: string | ContentBlock[]; usage?: MsgUsage }
|
|
28
|
+
|
|
29
|
+
/** Cumulative token + cost spend across a run's turns. */
|
|
30
|
+
export interface Usage {
|
|
31
|
+
input: number;
|
|
32
|
+
output: number;
|
|
33
|
+
cacheRead: number;
|
|
34
|
+
costUSD: number;
|
|
35
|
+
/** input + output, the headline "tokens" number. */
|
|
36
|
+
total: number;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const DEFAULT_PARSE_TAIL_BYTES = 32 * 1024 * 1024; // 32 MiB
|
|
40
|
+
const DEFAULT_RAW_TAIL_BYTES = 256 * 1024; // 256 KiB
|
|
41
|
+
|
|
42
|
+
function envBytes(name: string, fallback: number): number {
|
|
43
|
+
const raw = process.env[name];
|
|
44
|
+
if (!raw) return fallback;
|
|
45
|
+
const n = Number(raw);
|
|
46
|
+
return Number.isFinite(n) && n > 0 ? n : fallback;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function maxParseBytes(): number {
|
|
50
|
+
return envBytes("PI_SUBAGENT_MAX_LOG_PARSE_BYTES", DEFAULT_PARSE_TAIL_BYTES);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function maxRawTailBytes(): number {
|
|
54
|
+
return envBytes("PI_SUBAGENT_MAX_RAW_TAIL_BYTES", DEFAULT_RAW_TAIL_BYTES);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function fmtBytes(n: number): string {
|
|
58
|
+
if (n < 1024) return `${n} B`;
|
|
59
|
+
const units = ["KB", "MB", "GB"];
|
|
60
|
+
let i = 0;
|
|
61
|
+
let size = n / 1024;
|
|
62
|
+
while (size >= 1024 && i < units.length - 1) {
|
|
63
|
+
size /= 1024;
|
|
64
|
+
i++;
|
|
65
|
+
}
|
|
66
|
+
return `${size.toFixed(1)} ${units[i]}`;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
interface TailRead {
|
|
70
|
+
text: string;
|
|
71
|
+
truncated: boolean;
|
|
72
|
+
totalBytes: number;
|
|
73
|
+
/** Set when the file could not be opened/read; text is empty. */
|
|
74
|
+
error?: string;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Read at most `maxBytes` from the end of `path`. Avoids `readFileSync` so logs
|
|
79
|
+
* larger than Node's max string length can still be tailed for live output.
|
|
80
|
+
*/
|
|
81
|
+
function readTail(path: string, maxBytes: number): TailRead {
|
|
82
|
+
let totalBytes = 0;
|
|
83
|
+
try {
|
|
84
|
+
totalBytes = statSync(path).size;
|
|
85
|
+
} catch {
|
|
86
|
+
return { text: "", truncated: false, totalBytes: 0, error: "log not found" };
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
if (totalBytes === 0) {
|
|
90
|
+
return { text: "", truncated: false, totalBytes: 0 };
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
if (totalBytes <= maxBytes) {
|
|
94
|
+
try {
|
|
95
|
+
return { text: readFileSync(path, "utf-8"), truncated: false, totalBytes };
|
|
96
|
+
} catch (e) {
|
|
97
|
+
return {
|
|
98
|
+
text: "",
|
|
99
|
+
truncated: false,
|
|
100
|
+
totalBytes,
|
|
101
|
+
error: `read failed: ${(e as Error).message}`,
|
|
102
|
+
};
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
let fd: number;
|
|
107
|
+
try {
|
|
108
|
+
fd = openSync(path, "r");
|
|
109
|
+
} catch (e) {
|
|
110
|
+
return {
|
|
111
|
+
text: "",
|
|
112
|
+
truncated: true,
|
|
113
|
+
totalBytes,
|
|
114
|
+
error: `open failed: ${(e as Error).message}`,
|
|
115
|
+
};
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
const buf = Buffer.alloc(maxBytes);
|
|
119
|
+
const offset = totalBytes - maxBytes;
|
|
120
|
+
let read = 0;
|
|
121
|
+
try {
|
|
122
|
+
read = readSync(fd, buf, 0, maxBytes, offset);
|
|
123
|
+
} catch (e) {
|
|
124
|
+
closeSync(fd);
|
|
125
|
+
return {
|
|
126
|
+
text: "",
|
|
127
|
+
truncated: true,
|
|
128
|
+
totalBytes,
|
|
129
|
+
error: `tail read failed: ${(e as Error).message}`,
|
|
130
|
+
};
|
|
131
|
+
}
|
|
132
|
+
closeSync(fd);
|
|
133
|
+
|
|
134
|
+
const text = buf.toString("utf-8", 0, read);
|
|
135
|
+
|
|
136
|
+
return { text, truncated: true, totalBytes };
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/** Last `n` lines of a run's log, or a placeholder if empty/unreadable. */
|
|
140
|
+
export function tailLog(id: string, n: number, maxBytes = maxRawTailBytes()): string {
|
|
141
|
+
const tail = readTail(logPathFor(id), maxBytes);
|
|
142
|
+
if (tail.error || tail.text.trim() === "") return "(no output yet)";
|
|
143
|
+
const lines = tail.text.split("\n");
|
|
144
|
+
const kept = lines.slice(Math.max(0, lines.length - n));
|
|
145
|
+
const out = kept.join("\n").trim();
|
|
146
|
+
return out === "" ? "(no output yet)" : out;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/** Join the text blocks of a message into a plain string. */
|
|
150
|
+
function messageText(msg: Msg | undefined): string {
|
|
151
|
+
if (!msg) return "";
|
|
152
|
+
const c = msg.content;
|
|
153
|
+
if (typeof c === "string") return c;
|
|
154
|
+
if (!Array.isArray(c)) return "";
|
|
155
|
+
return c.filter((b) => b?.type === "text" && typeof b.text === "string").map((b) => b.text).join("").trim();
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
export interface UnmatchedToolCall {
|
|
159
|
+
/** Child tool-call id when the event stream provides one. */
|
|
160
|
+
id?: string;
|
|
161
|
+
/** Child tool name, retained for human diagnostics. */
|
|
162
|
+
toolName: string;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
export interface ParsedRun {
|
|
166
|
+
/** Final assistant answer (empty until the run produces one). */
|
|
167
|
+
finalText: string;
|
|
168
|
+
/** Latest streamed text/thinking, for a live progress peek. */
|
|
169
|
+
lastActivity: string;
|
|
170
|
+
/** Names of tools the child invoked, in order (deduped-adjacent). */
|
|
171
|
+
toolCalls: string[];
|
|
172
|
+
/** Tool starts that were not matched by a tool end before parsing stopped. */
|
|
173
|
+
unmatchedToolCalls: UnmatchedToolCall[];
|
|
174
|
+
/** True if we saw the terminal `agent_end`/`agent_settled` event. */
|
|
175
|
+
sawEnd: boolean;
|
|
176
|
+
/** Cumulative token + cost spend so far. */
|
|
177
|
+
usage: Usage;
|
|
178
|
+
/** Diagnostics about truncation or parse failure, surfaced to the user. */
|
|
179
|
+
diagnostics: string[];
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/**
|
|
183
|
+
* Authoritative lifecycle evidence scanned from the complete NDJSON stream.
|
|
184
|
+
* Kept separate from parseRun()'s bounded tail so large-log result parsing stays
|
|
185
|
+
* memory-safe while clean completion still requires full-stream tool balance.
|
|
186
|
+
*/
|
|
187
|
+
export interface LifecycleEvidence {
|
|
188
|
+
sawEnd: boolean;
|
|
189
|
+
unmatchedToolCalls: UnmatchedToolCall[];
|
|
190
|
+
/** True when the full file was readable end-to-end. */
|
|
191
|
+
complete: boolean;
|
|
192
|
+
diagnostics: string[];
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/** Fixed read size for lifecycle authority scans. Exported for memory-bound tests. */
|
|
196
|
+
export const LIFECYCLE_SCAN_CHUNK_BYTES = 64 * 1024;
|
|
197
|
+
/**
|
|
198
|
+
* Historical per-record prefix bound used by fixtures/tests. The structural
|
|
199
|
+
* scanner no longer retains a record prefix; it streams with O(1) state and
|
|
200
|
+
* only keeps top-level lifecycle field values after complete JSON grammar validity.
|
|
201
|
+
*/
|
|
202
|
+
export const LIFECYCLE_RECORD_PREFIX_BYTES = 4 * 1024;
|
|
203
|
+
|
|
204
|
+
/** Bound captured top-level lifecycle string values (type / toolCallId / toolName). */
|
|
205
|
+
const LIFECYCLE_FIELD_VALUE_MAX_CHARS = 1024;
|
|
206
|
+
const LIFECYCLE_FIELD_KEY_MAX_CHARS = 64;
|
|
207
|
+
const LIFECYCLE_TOP_LEVEL_KEYS = new Set(["type", "toolCallId", "toolName"]);
|
|
208
|
+
|
|
209
|
+
function emptyLifecycleEvidence(diagnostics: string[] = []): LifecycleEvidence {
|
|
210
|
+
return { sawEnd: false, unmatchedToolCalls: [], complete: false, diagnostics };
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
function applyLifecycleFields(
|
|
214
|
+
fields: { type?: string; toolCallId?: string; toolName?: string },
|
|
215
|
+
openToolCalls: Map<string, UnmatchedToolCall>,
|
|
216
|
+
state: { sawEnd: boolean; anonymousToolCall: number },
|
|
217
|
+
): void {
|
|
218
|
+
const type = fields.type;
|
|
219
|
+
if (!type) return;
|
|
220
|
+
if (type === "agent_end" || type === "agent_settled") state.sawEnd = true;
|
|
221
|
+
|
|
222
|
+
const toolCallId = fields.toolCallId;
|
|
223
|
+
if (type === "tool_execution_start") {
|
|
224
|
+
const toolName = fields.toolName ?? "unknown";
|
|
225
|
+
openToolCalls.set(toolCallId ?? `anonymous:${state.anonymousToolCall++}`, {
|
|
226
|
+
id: toolCallId,
|
|
227
|
+
toolName,
|
|
228
|
+
});
|
|
229
|
+
}
|
|
230
|
+
if (type === "tool_execution_end") {
|
|
231
|
+
if (toolCallId) {
|
|
232
|
+
openToolCalls.delete(toolCallId);
|
|
233
|
+
} else if (fields.toolName) {
|
|
234
|
+
const matching = [...openToolCalls].find(([, call]) => call.toolName === fields.toolName);
|
|
235
|
+
if (matching) openToolCalls.delete(matching[0]);
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
function unescapeJsonStringContent(raw: string): string | null {
|
|
241
|
+
try {
|
|
242
|
+
return JSON.parse(`"${raw}"`) as string;
|
|
243
|
+
} catch {
|
|
244
|
+
return null;
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
/** Fail closed on pathological nesting; keeps container-stack memory bounded. */
|
|
249
|
+
const LIFECYCLE_JSON_MAX_DEPTH = 1024;
|
|
250
|
+
|
|
251
|
+
type JsonContainer = "object" | "array";
|
|
252
|
+
/**
|
|
253
|
+
* Grammar expectation inside the current container.
|
|
254
|
+
* - objectKeyOrEnd: after `{` — `"key"` or `}`
|
|
255
|
+
* - objectKey: after `,` in object — `"key"` required (trailing comma invalid)
|
|
256
|
+
* - objectColon: after key — `:`
|
|
257
|
+
* - value: expecting any JSON value
|
|
258
|
+
* - valueOrEnd: after `[` — value or `]`
|
|
259
|
+
* - commaOrEnd: after a value — `,` or container end
|
|
260
|
+
*/
|
|
261
|
+
type JsonExpect =
|
|
262
|
+
| "objectKeyOrEnd"
|
|
263
|
+
| "objectKey"
|
|
264
|
+
| "objectColon"
|
|
265
|
+
| "value"
|
|
266
|
+
| "valueOrEnd"
|
|
267
|
+
| "commaOrEnd";
|
|
268
|
+
|
|
269
|
+
type NumberState =
|
|
270
|
+
| "start"
|
|
271
|
+
| "minus"
|
|
272
|
+
| "int"
|
|
273
|
+
| "intZero"
|
|
274
|
+
| "fracDot"
|
|
275
|
+
| "frac"
|
|
276
|
+
| "expE"
|
|
277
|
+
| "expSign"
|
|
278
|
+
| "expDigit";
|
|
279
|
+
|
|
280
|
+
type PrimitiveKind = "true" | "false" | "null" | "number";
|
|
281
|
+
|
|
282
|
+
/**
|
|
283
|
+
* Bounded streaming JSON-grammar scanner for one NDJSON object record.
|
|
284
|
+
* Validates complete JSON grammar (trailing commas, delimiter matching,
|
|
285
|
+
* primitives, escapes) without retaining payloads. Lifecycle fields are
|
|
286
|
+
* collected only for depth-1 keys and applied only after the whole record
|
|
287
|
+
* is grammar-valid.
|
|
288
|
+
*/
|
|
289
|
+
interface StructuralRecordScan {
|
|
290
|
+
/** Stack of open containers; length is current depth. */
|
|
291
|
+
containers: JsonContainer[];
|
|
292
|
+
expect: JsonExpect;
|
|
293
|
+
inString: boolean;
|
|
294
|
+
/** True when the active string is an object key (not a value). */
|
|
295
|
+
stringIsKey: boolean;
|
|
296
|
+
/**
|
|
297
|
+
* Escape state inside a string:
|
|
298
|
+
* 0 = normal, 1 = saw backslash, 2..5 = collecting \uXXXX (2 + digitsSeen).
|
|
299
|
+
*/
|
|
300
|
+
escapeMode: number;
|
|
301
|
+
inPrimitive: boolean;
|
|
302
|
+
primitiveKind: PrimitiveKind | null;
|
|
303
|
+
/** Matched keyword length so far. */
|
|
304
|
+
primitiveIndex: number;
|
|
305
|
+
numberState: NumberState;
|
|
306
|
+
started: boolean;
|
|
307
|
+
finished: boolean;
|
|
308
|
+
malformed: boolean;
|
|
309
|
+
skipLine: boolean;
|
|
310
|
+
currentKey: string | null;
|
|
311
|
+
capturingKey: boolean;
|
|
312
|
+
keyBuf: string;
|
|
313
|
+
capturingValue: boolean;
|
|
314
|
+
valueBuf: string;
|
|
315
|
+
type?: string;
|
|
316
|
+
toolCallId?: string;
|
|
317
|
+
toolName?: string;
|
|
318
|
+
/** Semantic lifecycle keys already observed at depth 1 (decoded). */
|
|
319
|
+
seenLifecycleKeys: Set<string>;
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
function createStructuralRecordScan(): StructuralRecordScan {
|
|
323
|
+
return {
|
|
324
|
+
containers: [],
|
|
325
|
+
expect: "value",
|
|
326
|
+
inString: false,
|
|
327
|
+
stringIsKey: false,
|
|
328
|
+
escapeMode: 0,
|
|
329
|
+
inPrimitive: false,
|
|
330
|
+
primitiveKind: null,
|
|
331
|
+
primitiveIndex: 0,
|
|
332
|
+
numberState: "start",
|
|
333
|
+
started: false,
|
|
334
|
+
finished: false,
|
|
335
|
+
malformed: false,
|
|
336
|
+
skipLine: false,
|
|
337
|
+
currentKey: null,
|
|
338
|
+
capturingKey: false,
|
|
339
|
+
keyBuf: "",
|
|
340
|
+
capturingValue: false,
|
|
341
|
+
valueBuf: "",
|
|
342
|
+
seenLifecycleKeys: new Set(),
|
|
343
|
+
};
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
function depthOf(scan: StructuralRecordScan): number {
|
|
347
|
+
return scan.containers.length;
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
function markMalformed(scan: StructuralRecordScan): void {
|
|
351
|
+
scan.malformed = true;
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
/**
|
|
355
|
+
* Decode a captured object-key buffer (escape sequences already retained as
|
|
356
|
+
* JSON source fragments) into its semantic string. Returns null on bad escapes.
|
|
357
|
+
*/
|
|
358
|
+
function decodeCapturedKey(raw: string): string | null {
|
|
359
|
+
return unescapeJsonStringContent(raw);
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
/**
|
|
363
|
+
* Record a top-level lifecycle key. Duplicate semantic keys (including escaped
|
|
364
|
+
* equivalent spellings) fail the record closed — no field evidence from an
|
|
365
|
+
* ambiguous record may authorize terminal/tool balance.
|
|
366
|
+
*/
|
|
367
|
+
function noteTopLevelLifecycleKey(scan: StructuralRecordScan, decodedKey: string): void {
|
|
368
|
+
if (!LIFECYCLE_TOP_LEVEL_KEYS.has(decodedKey)) return;
|
|
369
|
+
if (scan.seenLifecycleKeys.has(decodedKey)) {
|
|
370
|
+
markMalformed(scan);
|
|
371
|
+
return;
|
|
372
|
+
}
|
|
373
|
+
scan.seenLifecycleKeys.add(decodedKey);
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
function assignTopLevelLifecycleValue(scan: StructuralRecordScan): void {
|
|
377
|
+
const key = scan.currentKey;
|
|
378
|
+
if (!key || !LIFECYCLE_TOP_LEVEL_KEYS.has(key)) return;
|
|
379
|
+
// Duplicates are rejected when the key is observed; defensive guard here.
|
|
380
|
+
if (!scan.seenLifecycleKeys.has(key)) {
|
|
381
|
+
markMalformed(scan);
|
|
382
|
+
return;
|
|
383
|
+
}
|
|
384
|
+
const value = unescapeJsonStringContent(scan.valueBuf);
|
|
385
|
+
if (value === null) {
|
|
386
|
+
markMalformed(scan);
|
|
387
|
+
return;
|
|
388
|
+
}
|
|
389
|
+
if (key === "type") scan.type = value;
|
|
390
|
+
else if (key === "toolCallId") scan.toolCallId = value;
|
|
391
|
+
else if (key === "toolName") scan.toolName = value;
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
function appendCaptured(scan: StructuralRecordScan, chunk: string): void {
|
|
395
|
+
if (scan.capturingKey && scan.keyBuf.length < LIFECYCLE_FIELD_KEY_MAX_CHARS) {
|
|
396
|
+
scan.keyBuf += chunk;
|
|
397
|
+
} else if (scan.capturingValue && scan.valueBuf.length < LIFECYCLE_FIELD_VALUE_MAX_CHARS) {
|
|
398
|
+
scan.valueBuf += chunk;
|
|
399
|
+
}
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
function finishPrimitive(scan: StructuralRecordScan): boolean {
|
|
403
|
+
if (!scan.inPrimitive || !scan.primitiveKind) return false;
|
|
404
|
+
if (scan.primitiveKind === "number") {
|
|
405
|
+
// Number must end on a complete state (not after bare '-', '.', 'e', or sign).
|
|
406
|
+
if (
|
|
407
|
+
scan.numberState === "minus" ||
|
|
408
|
+
scan.numberState === "fracDot" ||
|
|
409
|
+
scan.numberState === "expE" ||
|
|
410
|
+
scan.numberState === "expSign" ||
|
|
411
|
+
scan.numberState === "start"
|
|
412
|
+
) {
|
|
413
|
+
markMalformed(scan);
|
|
414
|
+
return false;
|
|
415
|
+
}
|
|
416
|
+
} else {
|
|
417
|
+
const expected =
|
|
418
|
+
scan.primitiveKind === "true" ? 4 : scan.primitiveKind === "false" ? 5 : 4;
|
|
419
|
+
if (scan.primitiveIndex !== expected) {
|
|
420
|
+
markMalformed(scan);
|
|
421
|
+
return false;
|
|
422
|
+
}
|
|
423
|
+
}
|
|
424
|
+
scan.inPrimitive = false;
|
|
425
|
+
scan.primitiveKind = null;
|
|
426
|
+
scan.primitiveIndex = 0;
|
|
427
|
+
scan.numberState = "start";
|
|
428
|
+
scan.expect = "commaOrEnd";
|
|
429
|
+
scan.currentKey = null;
|
|
430
|
+
return true;
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
function startPrimitive(scan: StructuralRecordScan, ch: string): void {
|
|
434
|
+
scan.inPrimitive = true;
|
|
435
|
+
scan.primitiveIndex = 1;
|
|
436
|
+
if (ch === "t") {
|
|
437
|
+
scan.primitiveKind = "true";
|
|
438
|
+
return;
|
|
439
|
+
}
|
|
440
|
+
if (ch === "f") {
|
|
441
|
+
scan.primitiveKind = "false";
|
|
442
|
+
return;
|
|
443
|
+
}
|
|
444
|
+
if (ch === "n") {
|
|
445
|
+
scan.primitiveKind = "null";
|
|
446
|
+
return;
|
|
447
|
+
}
|
|
448
|
+
// number
|
|
449
|
+
scan.primitiveKind = "number";
|
|
450
|
+
if (ch === "-") {
|
|
451
|
+
scan.numberState = "minus";
|
|
452
|
+
return;
|
|
453
|
+
}
|
|
454
|
+
if (ch === "0") {
|
|
455
|
+
scan.numberState = "intZero";
|
|
456
|
+
return;
|
|
457
|
+
}
|
|
458
|
+
if (ch >= "1" && ch <= "9") {
|
|
459
|
+
scan.numberState = "int";
|
|
460
|
+
return;
|
|
461
|
+
}
|
|
462
|
+
markMalformed(scan);
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
function feedPrimitiveChar(scan: StructuralRecordScan, ch: string): void {
|
|
466
|
+
if (!scan.primitiveKind) {
|
|
467
|
+
markMalformed(scan);
|
|
468
|
+
return;
|
|
469
|
+
}
|
|
470
|
+
if (scan.primitiveKind !== "number") {
|
|
471
|
+
const target =
|
|
472
|
+
scan.primitiveKind === "true" ? "true" : scan.primitiveKind === "false" ? "false" : "null";
|
|
473
|
+
if (scan.primitiveIndex >= target.length || ch !== target[scan.primitiveIndex]) {
|
|
474
|
+
markMalformed(scan);
|
|
475
|
+
return;
|
|
476
|
+
}
|
|
477
|
+
scan.primitiveIndex++;
|
|
478
|
+
return;
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
// Streaming number grammar (JSON).
|
|
482
|
+
switch (scan.numberState) {
|
|
483
|
+
case "minus":
|
|
484
|
+
if (ch === "0") {
|
|
485
|
+
scan.numberState = "intZero";
|
|
486
|
+
return;
|
|
487
|
+
}
|
|
488
|
+
if (ch >= "1" && ch <= "9") {
|
|
489
|
+
scan.numberState = "int";
|
|
490
|
+
return;
|
|
491
|
+
}
|
|
492
|
+
markMalformed(scan);
|
|
493
|
+
return;
|
|
494
|
+
case "intZero":
|
|
495
|
+
// Leading zero may only be followed by fraction/exponent, not more digits.
|
|
496
|
+
if (ch === ".") {
|
|
497
|
+
scan.numberState = "fracDot";
|
|
498
|
+
return;
|
|
499
|
+
}
|
|
500
|
+
if (ch === "e" || ch === "E") {
|
|
501
|
+
scan.numberState = "expE";
|
|
502
|
+
return;
|
|
503
|
+
}
|
|
504
|
+
markMalformed(scan);
|
|
505
|
+
return;
|
|
506
|
+
case "int":
|
|
507
|
+
if (ch >= "0" && ch <= "9") return;
|
|
508
|
+
if (ch === ".") {
|
|
509
|
+
scan.numberState = "fracDot";
|
|
510
|
+
return;
|
|
511
|
+
}
|
|
512
|
+
if (ch === "e" || ch === "E") {
|
|
513
|
+
scan.numberState = "expE";
|
|
514
|
+
return;
|
|
515
|
+
}
|
|
516
|
+
markMalformed(scan);
|
|
517
|
+
return;
|
|
518
|
+
case "fracDot":
|
|
519
|
+
if (ch >= "0" && ch <= "9") {
|
|
520
|
+
scan.numberState = "frac";
|
|
521
|
+
return;
|
|
522
|
+
}
|
|
523
|
+
markMalformed(scan);
|
|
524
|
+
return;
|
|
525
|
+
case "frac":
|
|
526
|
+
if (ch >= "0" && ch <= "9") return;
|
|
527
|
+
if (ch === "e" || ch === "E") {
|
|
528
|
+
scan.numberState = "expE";
|
|
529
|
+
return;
|
|
530
|
+
}
|
|
531
|
+
markMalformed(scan);
|
|
532
|
+
return;
|
|
533
|
+
case "expE":
|
|
534
|
+
if (ch === "+" || ch === "-") {
|
|
535
|
+
scan.numberState = "expSign";
|
|
536
|
+
return;
|
|
537
|
+
}
|
|
538
|
+
if (ch >= "0" && ch <= "9") {
|
|
539
|
+
scan.numberState = "expDigit";
|
|
540
|
+
return;
|
|
541
|
+
}
|
|
542
|
+
markMalformed(scan);
|
|
543
|
+
return;
|
|
544
|
+
case "expSign":
|
|
545
|
+
if (ch >= "0" && ch <= "9") {
|
|
546
|
+
scan.numberState = "expDigit";
|
|
547
|
+
return;
|
|
548
|
+
}
|
|
549
|
+
markMalformed(scan);
|
|
550
|
+
return;
|
|
551
|
+
case "expDigit":
|
|
552
|
+
if (ch >= "0" && ch <= "9") return;
|
|
553
|
+
markMalformed(scan);
|
|
554
|
+
return;
|
|
555
|
+
default:
|
|
556
|
+
markMalformed(scan);
|
|
557
|
+
}
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
function isValueStartChar(ch: string): boolean {
|
|
561
|
+
return (
|
|
562
|
+
ch === "\"" ||
|
|
563
|
+
ch === "{" ||
|
|
564
|
+
ch === "[" ||
|
|
565
|
+
ch === "t" ||
|
|
566
|
+
ch === "f" ||
|
|
567
|
+
ch === "n" ||
|
|
568
|
+
ch === "-" ||
|
|
569
|
+
(ch >= "0" && ch <= "9")
|
|
570
|
+
);
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
function canStartValue(scan: StructuralRecordScan): boolean {
|
|
574
|
+
return scan.expect === "value" || scan.expect === "valueOrEnd";
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
function afterValueClosed(scan: StructuralRecordScan): void {
|
|
578
|
+
scan.expect = "commaOrEnd";
|
|
579
|
+
scan.currentKey = null;
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
function pushContainer(scan: StructuralRecordScan, kind: JsonContainer): void {
|
|
583
|
+
if (scan.containers.length >= LIFECYCLE_JSON_MAX_DEPTH) {
|
|
584
|
+
markMalformed(scan);
|
|
585
|
+
return;
|
|
586
|
+
}
|
|
587
|
+
scan.containers.push(kind);
|
|
588
|
+
scan.expect = kind === "object" ? "objectKeyOrEnd" : "valueOrEnd";
|
|
589
|
+
scan.currentKey = null;
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
function popContainer(scan: StructuralRecordScan, kind: JsonContainer): void {
|
|
593
|
+
if (scan.containers.length === 0 || scan.containers[scan.containers.length - 1] !== kind) {
|
|
594
|
+
markMalformed(scan);
|
|
595
|
+
return;
|
|
596
|
+
}
|
|
597
|
+
scan.containers.pop();
|
|
598
|
+
if (scan.containers.length === 0) {
|
|
599
|
+
scan.finished = true;
|
|
600
|
+
scan.expect = "commaOrEnd";
|
|
601
|
+
return;
|
|
602
|
+
}
|
|
603
|
+
afterValueClosed(scan);
|
|
604
|
+
}
|
|
605
|
+
|
|
606
|
+
function feedStructuralRecordChar(scan: StructuralRecordScan, ch: string): void {
|
|
607
|
+
if (scan.malformed || scan.finished || scan.skipLine) return;
|
|
608
|
+
|
|
609
|
+
// ── String body (including escape grammar) ─────────────────────────────
|
|
610
|
+
if (scan.inString) {
|
|
611
|
+
if (scan.escapeMode >= 2) {
|
|
612
|
+
// Collecting remaining \uXXXX hex digits (escapeMode = 2 + digitsSeen).
|
|
613
|
+
if (!/[0-9a-fA-F]/.test(ch)) {
|
|
614
|
+
markMalformed(scan);
|
|
615
|
+
return;
|
|
616
|
+
}
|
|
617
|
+
appendCaptured(scan, ch);
|
|
618
|
+
scan.escapeMode++;
|
|
619
|
+
// After 4 hex digits escapeMode reaches 6.
|
|
620
|
+
if (scan.escapeMode >= 6) scan.escapeMode = 0;
|
|
621
|
+
return;
|
|
622
|
+
}
|
|
623
|
+
if (scan.escapeMode === 1) {
|
|
624
|
+
// Character immediately after backslash.
|
|
625
|
+
if (ch === "u") {
|
|
626
|
+
appendCaptured(scan, "\\u");
|
|
627
|
+
scan.escapeMode = 2; // need 4 hex digits
|
|
628
|
+
return;
|
|
629
|
+
}
|
|
630
|
+
if (
|
|
631
|
+
ch === "\"" ||
|
|
632
|
+
ch === "\\" ||
|
|
633
|
+
ch === "/" ||
|
|
634
|
+
ch === "b" ||
|
|
635
|
+
ch === "f" ||
|
|
636
|
+
ch === "n" ||
|
|
637
|
+
ch === "r" ||
|
|
638
|
+
ch === "t"
|
|
639
|
+
) {
|
|
640
|
+
appendCaptured(scan, `\\${ch}`);
|
|
641
|
+
scan.escapeMode = 0;
|
|
642
|
+
return;
|
|
643
|
+
}
|
|
644
|
+
markMalformed(scan);
|
|
645
|
+
return;
|
|
646
|
+
}
|
|
647
|
+
if (ch === "\\") {
|
|
648
|
+
scan.escapeMode = 1;
|
|
649
|
+
return;
|
|
650
|
+
}
|
|
651
|
+
if (ch === "\"") {
|
|
652
|
+
scan.inString = false;
|
|
653
|
+
scan.escapeMode = 0;
|
|
654
|
+
if (scan.stringIsKey) {
|
|
655
|
+
scan.stringIsKey = false;
|
|
656
|
+
if (scan.capturingKey) {
|
|
657
|
+
scan.capturingKey = false;
|
|
658
|
+
const decoded = decodeCapturedKey(scan.keyBuf);
|
|
659
|
+
if (decoded === null) {
|
|
660
|
+
markMalformed(scan);
|
|
661
|
+
return;
|
|
662
|
+
}
|
|
663
|
+
// Fail closed on duplicate semantic lifecycle keys before any
|
|
664
|
+
// value is applied (escaped equivalents normalize first).
|
|
665
|
+
noteTopLevelLifecycleKey(scan, decoded);
|
|
666
|
+
if (scan.malformed) return;
|
|
667
|
+
scan.currentKey = decoded;
|
|
668
|
+
} else {
|
|
669
|
+
scan.currentKey = null;
|
|
670
|
+
}
|
|
671
|
+
scan.expect = "objectColon";
|
|
672
|
+
return;
|
|
673
|
+
}
|
|
674
|
+
if (scan.capturingValue) {
|
|
675
|
+
scan.capturingValue = false;
|
|
676
|
+
assignTopLevelLifecycleValue(scan);
|
|
677
|
+
if (scan.malformed) return;
|
|
678
|
+
}
|
|
679
|
+
afterValueClosed(scan);
|
|
680
|
+
return;
|
|
681
|
+
}
|
|
682
|
+
// Unescaped control characters are invalid JSON.
|
|
683
|
+
if (ch.charCodeAt(0) < 0x20) {
|
|
684
|
+
markMalformed(scan);
|
|
685
|
+
return;
|
|
686
|
+
}
|
|
687
|
+
appendCaptured(scan, ch);
|
|
688
|
+
return;
|
|
689
|
+
}
|
|
690
|
+
|
|
691
|
+
// ── Finish primitive on whitespace / delimiter ─────────────────────────
|
|
692
|
+
if (scan.inPrimitive) {
|
|
693
|
+
if (ch === " " || ch === "\t" || ch === "\r") {
|
|
694
|
+
finishPrimitive(scan);
|
|
695
|
+
return;
|
|
696
|
+
}
|
|
697
|
+
if (ch === "," || ch === "}" || ch === "]") {
|
|
698
|
+
if (!finishPrimitive(scan)) return;
|
|
699
|
+
// Fall through to delimiter handling.
|
|
700
|
+
} else {
|
|
701
|
+
feedPrimitiveChar(scan, ch);
|
|
702
|
+
return;
|
|
703
|
+
}
|
|
704
|
+
}
|
|
705
|
+
|
|
706
|
+
// ── Insignificant whitespace outside strings/primitives ────────────────
|
|
707
|
+
if (ch === " " || ch === "\t" || ch === "\r") return;
|
|
708
|
+
|
|
709
|
+
if (!scan.started) {
|
|
710
|
+
if (ch === "{") {
|
|
711
|
+
scan.started = true;
|
|
712
|
+
pushContainer(scan, "object");
|
|
713
|
+
return;
|
|
714
|
+
}
|
|
715
|
+
// Non-object NDJSON noise — ignore until the next record boundary.
|
|
716
|
+
scan.skipLine = true;
|
|
717
|
+
return;
|
|
718
|
+
}
|
|
719
|
+
|
|
720
|
+
// ── Structural tokens ──────────────────────────────────────────────────
|
|
721
|
+
if (ch === "\"") {
|
|
722
|
+
const d = depthOf(scan);
|
|
723
|
+
if (
|
|
724
|
+
d >= 1 &&
|
|
725
|
+
scan.containers[d - 1] === "object" &&
|
|
726
|
+
(scan.expect === "objectKeyOrEnd" || scan.expect === "objectKey")
|
|
727
|
+
) {
|
|
728
|
+
scan.inString = true;
|
|
729
|
+
scan.stringIsKey = true;
|
|
730
|
+
scan.escapeMode = 0;
|
|
731
|
+
if (d === 1) {
|
|
732
|
+
scan.capturingKey = true;
|
|
733
|
+
scan.keyBuf = "";
|
|
734
|
+
}
|
|
735
|
+
return;
|
|
736
|
+
}
|
|
737
|
+
if (canStartValue(scan)) {
|
|
738
|
+
scan.inString = true;
|
|
739
|
+
scan.stringIsKey = false;
|
|
740
|
+
scan.escapeMode = 0;
|
|
741
|
+
if (
|
|
742
|
+
d === 1 &&
|
|
743
|
+
scan.currentKey !== null &&
|
|
744
|
+
LIFECYCLE_TOP_LEVEL_KEYS.has(scan.currentKey)
|
|
745
|
+
) {
|
|
746
|
+
scan.capturingValue = true;
|
|
747
|
+
scan.valueBuf = "";
|
|
748
|
+
}
|
|
749
|
+
return;
|
|
750
|
+
}
|
|
751
|
+
markMalformed(scan);
|
|
752
|
+
return;
|
|
753
|
+
}
|
|
754
|
+
|
|
755
|
+
if (ch === ":") {
|
|
756
|
+
if (scan.expect !== "objectColon") {
|
|
757
|
+
markMalformed(scan);
|
|
758
|
+
return;
|
|
759
|
+
}
|
|
760
|
+
scan.expect = "value";
|
|
761
|
+
return;
|
|
762
|
+
}
|
|
763
|
+
|
|
764
|
+
if (ch === "{") {
|
|
765
|
+
if (!canStartValue(scan)) {
|
|
766
|
+
markMalformed(scan);
|
|
767
|
+
return;
|
|
768
|
+
}
|
|
769
|
+
pushContainer(scan, "object");
|
|
770
|
+
return;
|
|
771
|
+
}
|
|
772
|
+
|
|
773
|
+
if (ch === "[") {
|
|
774
|
+
if (!canStartValue(scan)) {
|
|
775
|
+
markMalformed(scan);
|
|
776
|
+
return;
|
|
777
|
+
}
|
|
778
|
+
pushContainer(scan, "array");
|
|
779
|
+
return;
|
|
780
|
+
}
|
|
781
|
+
|
|
782
|
+
if (ch === "}") {
|
|
783
|
+
// Valid only for object containers in key-or-end or comma-or-end (not after bare comma).
|
|
784
|
+
if (
|
|
785
|
+
depthOf(scan) === 0 ||
|
|
786
|
+
scan.containers[depthOf(scan) - 1] !== "object" ||
|
|
787
|
+
(scan.expect !== "objectKeyOrEnd" && scan.expect !== "commaOrEnd")
|
|
788
|
+
) {
|
|
789
|
+
markMalformed(scan);
|
|
790
|
+
return;
|
|
791
|
+
}
|
|
792
|
+
popContainer(scan, "object");
|
|
793
|
+
return;
|
|
794
|
+
}
|
|
795
|
+
|
|
796
|
+
if (ch === "]") {
|
|
797
|
+
if (
|
|
798
|
+
depthOf(scan) === 0 ||
|
|
799
|
+
scan.containers[depthOf(scan) - 1] !== "array" ||
|
|
800
|
+
(scan.expect !== "valueOrEnd" && scan.expect !== "commaOrEnd")
|
|
801
|
+
) {
|
|
802
|
+
markMalformed(scan);
|
|
803
|
+
return;
|
|
804
|
+
}
|
|
805
|
+
popContainer(scan, "array");
|
|
806
|
+
// Top-level arrays are not lifecycle objects (started only on `{`).
|
|
807
|
+
if (depthOf(scan) === 0) {
|
|
808
|
+
markMalformed(scan);
|
|
809
|
+
}
|
|
810
|
+
return;
|
|
811
|
+
}
|
|
812
|
+
|
|
813
|
+
if (ch === ",") {
|
|
814
|
+
if (scan.expect !== "commaOrEnd") {
|
|
815
|
+
markMalformed(scan);
|
|
816
|
+
return;
|
|
817
|
+
}
|
|
818
|
+
const cur = scan.containers[depthOf(scan) - 1];
|
|
819
|
+
if (cur === "object") {
|
|
820
|
+
scan.expect = "objectKey"; // key required — trailing comma before } is invalid
|
|
821
|
+
scan.currentKey = null;
|
|
822
|
+
return;
|
|
823
|
+
}
|
|
824
|
+
if (cur === "array") {
|
|
825
|
+
scan.expect = "value"; // value required — trailing comma before ] is invalid
|
|
826
|
+
return;
|
|
827
|
+
}
|
|
828
|
+
markMalformed(scan);
|
|
829
|
+
return;
|
|
830
|
+
}
|
|
831
|
+
|
|
832
|
+
// Primitive value start.
|
|
833
|
+
if (canStartValue(scan) && isValueStartChar(ch)) {
|
|
834
|
+
startPrimitive(scan, ch);
|
|
835
|
+
return;
|
|
836
|
+
}
|
|
837
|
+
|
|
838
|
+
markMalformed(scan);
|
|
839
|
+
}
|
|
840
|
+
|
|
841
|
+
|
|
842
|
+
/**
|
|
843
|
+
* Stream the complete child log for lifecycle authority only.
|
|
844
|
+
* Reads fixed-size chunks and walks each NDJSON record with bounded structural
|
|
845
|
+
* state (no per-record payload retention). Top-level lifecycle fields are applied
|
|
846
|
+
* only after a record is grammar-valid; unfinished/malformed records fail closed.
|
|
847
|
+
*/
|
|
848
|
+
export function scanLifecycleEvidence(id: string): LifecycleEvidence {
|
|
849
|
+
const path = logPathFor(id);
|
|
850
|
+
let totalBytes = 0;
|
|
851
|
+
try {
|
|
852
|
+
totalBytes = statSync(path).size;
|
|
853
|
+
} catch {
|
|
854
|
+
return emptyLifecycleEvidence(["Lifecycle scan failed: log not found"]);
|
|
855
|
+
}
|
|
856
|
+
if (totalBytes === 0) {
|
|
857
|
+
return { sawEnd: false, unmatchedToolCalls: [], complete: true, diagnostics: [] };
|
|
858
|
+
}
|
|
859
|
+
|
|
860
|
+
let fd: number;
|
|
861
|
+
try {
|
|
862
|
+
fd = openSync(path, "r");
|
|
863
|
+
} catch (e) {
|
|
864
|
+
return emptyLifecycleEvidence([`Lifecycle scan failed: open failed: ${(e as Error).message}`]);
|
|
865
|
+
}
|
|
866
|
+
|
|
867
|
+
const openToolCalls = new Map<string, UnmatchedToolCall>();
|
|
868
|
+
const state = { sawEnd: false, anonymousToolCall: 0 };
|
|
869
|
+
const diagnostics: string[] = [];
|
|
870
|
+
const buf = Buffer.alloc(Math.min(LIFECYCLE_SCAN_CHUNK_BYTES, totalBytes));
|
|
871
|
+
let offset = 0;
|
|
872
|
+
let complete = true;
|
|
873
|
+
let scan = createStructuralRecordScan();
|
|
874
|
+
|
|
875
|
+
const markUntrusted = (msg: string): void => {
|
|
876
|
+
complete = false;
|
|
877
|
+
if (!diagnostics.includes(msg)) diagnostics.push(msg);
|
|
878
|
+
};
|
|
879
|
+
|
|
880
|
+
const finishRecord = (): void => {
|
|
881
|
+
if (scan.skipLine || !scan.started) {
|
|
882
|
+
scan = createStructuralRecordScan();
|
|
883
|
+
return;
|
|
884
|
+
}
|
|
885
|
+
if (
|
|
886
|
+
scan.malformed ||
|
|
887
|
+
scan.inString ||
|
|
888
|
+
scan.escapeMode !== 0 ||
|
|
889
|
+
scan.inPrimitive ||
|
|
890
|
+
scan.containers.length !== 0 ||
|
|
891
|
+
!scan.finished
|
|
892
|
+
) {
|
|
893
|
+
markUntrusted("Lifecycle scan found unfinished or malformed NDJSON record");
|
|
894
|
+
scan = createStructuralRecordScan();
|
|
895
|
+
return;
|
|
896
|
+
}
|
|
897
|
+
// Grammar-valid object: apply only top-level lifecycle ownership.
|
|
898
|
+
applyLifecycleFields(
|
|
899
|
+
{
|
|
900
|
+
type: scan.type,
|
|
901
|
+
toolCallId: scan.toolCallId,
|
|
902
|
+
toolName: scan.toolName,
|
|
903
|
+
},
|
|
904
|
+
openToolCalls,
|
|
905
|
+
state,
|
|
906
|
+
);
|
|
907
|
+
scan = createStructuralRecordScan();
|
|
908
|
+
};
|
|
909
|
+
|
|
910
|
+
try {
|
|
911
|
+
while (offset < totalBytes) {
|
|
912
|
+
const toRead = Math.min(buf.length, totalBytes - offset);
|
|
913
|
+
let read = 0;
|
|
914
|
+
try {
|
|
915
|
+
read = readSync(fd, buf, 0, toRead, offset);
|
|
916
|
+
} catch (e) {
|
|
917
|
+
return emptyLifecycleEvidence([
|
|
918
|
+
`Lifecycle scan failed: read failed: ${(e as Error).message}`,
|
|
919
|
+
]);
|
|
920
|
+
}
|
|
921
|
+
if (read <= 0) break;
|
|
922
|
+
offset += read;
|
|
923
|
+
|
|
924
|
+
// Decode chunk; structural state is O(1) so the chunk string is dropped each loop.
|
|
925
|
+
const chunk = buf.toString("utf-8", 0, read);
|
|
926
|
+
for (let i = 0; i < chunk.length; i++) {
|
|
927
|
+
const ch = chunk[i];
|
|
928
|
+
if (ch === "\n") {
|
|
929
|
+
finishRecord();
|
|
930
|
+
continue;
|
|
931
|
+
}
|
|
932
|
+
if (scan.finished) {
|
|
933
|
+
// Trailing junk after a closed object before newline is malformed.
|
|
934
|
+
if (ch !== " " && ch !== "\t" && ch !== "\r") {
|
|
935
|
+
scan.malformed = true;
|
|
936
|
+
}
|
|
937
|
+
continue;
|
|
938
|
+
}
|
|
939
|
+
if (scan.skipLine) continue;
|
|
940
|
+
feedStructuralRecordChar(scan, ch);
|
|
941
|
+
}
|
|
942
|
+
}
|
|
943
|
+
|
|
944
|
+
// EOF: finalize any record lacking a trailing newline.
|
|
945
|
+
if (scan.started || scan.malformed) {
|
|
946
|
+
finishRecord();
|
|
947
|
+
} else if (scan.skipLine) {
|
|
948
|
+
// Noise-only trailing content without a record — ignore.
|
|
949
|
+
}
|
|
950
|
+
} finally {
|
|
951
|
+
closeSync(fd);
|
|
952
|
+
}
|
|
953
|
+
|
|
954
|
+
// Fail closed: any unfinished/malformed record makes the stream untrusted, so
|
|
955
|
+
// terminal lifecycle evidence must not survive even if a later record looked valid.
|
|
956
|
+
return {
|
|
957
|
+
sawEnd: complete ? state.sawEnd : false,
|
|
958
|
+
unmatchedToolCalls: [...openToolCalls.values()],
|
|
959
|
+
complete,
|
|
960
|
+
diagnostics,
|
|
961
|
+
};
|
|
962
|
+
}
|
|
963
|
+
|
|
964
|
+
/** Overlay full-stream lifecycle fields onto a bounded parseRun result. */
|
|
965
|
+
export function withLifecycleEvidence(run: ParsedRun, evidence: LifecycleEvidence): ParsedRun {
|
|
966
|
+
const diagnostics = [...run.diagnostics];
|
|
967
|
+
for (const d of evidence.diagnostics) {
|
|
968
|
+
if (!diagnostics.includes(d)) diagnostics.push(d);
|
|
969
|
+
}
|
|
970
|
+
// When the full stream could not be read, refuse clean completion by clearing
|
|
971
|
+
// terminal evidence even if the bounded tail looked coherent.
|
|
972
|
+
if (!evidence.complete) {
|
|
973
|
+
return {
|
|
974
|
+
...run,
|
|
975
|
+
sawEnd: false,
|
|
976
|
+
unmatchedToolCalls: evidence.unmatchedToolCalls,
|
|
977
|
+
diagnostics,
|
|
978
|
+
};
|
|
979
|
+
}
|
|
980
|
+
return {
|
|
981
|
+
...run,
|
|
982
|
+
sawEnd: evidence.sawEnd,
|
|
983
|
+
unmatchedToolCalls: evidence.unmatchedToolCalls,
|
|
984
|
+
diagnostics,
|
|
985
|
+
};
|
|
986
|
+
}
|
|
987
|
+
|
|
988
|
+
/** Bounded output parse + authoritative full-stream lifecycle evidence. */
|
|
989
|
+
export function parseRunForLifecycle(id: string): ParsedRun {
|
|
990
|
+
return withLifecycleEvidence(parseRun(id), scanLifecycleEvidence(id));
|
|
991
|
+
}
|
|
992
|
+
|
|
993
|
+
/** Parse the log for run `id`. Tolerant of partial/streaming logs. */
|
|
994
|
+
export function parseRun(id: string): ParsedRun {
|
|
995
|
+
const usage: Usage = { input: 0, output: 0, cacheRead: 0, costUSD: 0, total: 0 };
|
|
996
|
+
const tail = readTail(logPathFor(id), maxParseBytes());
|
|
997
|
+
const diagnostics: string[] = [];
|
|
998
|
+
|
|
999
|
+
// For NDJSON parsing we need complete lines; drop an initial partial line
|
|
1000
|
+
// that was split by the byte-boundary read. The raw-tail fallback (tailLog)
|
|
1001
|
+
// keeps those bytes so large single-line events are still visible to users.
|
|
1002
|
+
let parseText = tail.text;
|
|
1003
|
+
if (tail.truncated) {
|
|
1004
|
+
const firstNewline = parseText.indexOf("\n");
|
|
1005
|
+
if (firstNewline !== -1) {
|
|
1006
|
+
parseText = parseText.slice(firstNewline + 1);
|
|
1007
|
+
}
|
|
1008
|
+
}
|
|
1009
|
+
|
|
1010
|
+
if (tail.error) {
|
|
1011
|
+
diagnostics.push(`Log unreadable: ${tail.error}`);
|
|
1012
|
+
return { finalText: "", lastActivity: "", toolCalls: [], unmatchedToolCalls: [], sawEnd: false, usage, diagnostics };
|
|
1013
|
+
}
|
|
1014
|
+
if (tail.totalBytes === 0) {
|
|
1015
|
+
return { finalText: "", lastActivity: "", toolCalls: [], unmatchedToolCalls: [], sawEnd: false, usage, diagnostics };
|
|
1016
|
+
}
|
|
1017
|
+
if (tail.truncated) {
|
|
1018
|
+
diagnostics.push(
|
|
1019
|
+
`Log truncated: parsed last ${fmtBytes(maxParseBytes())} of ${fmtBytes(tail.totalBytes)}. ` +
|
|
1020
|
+
"Only recent activity is reflected in tokens/tools.",
|
|
1021
|
+
);
|
|
1022
|
+
}
|
|
1023
|
+
|
|
1024
|
+
let finalText = "";
|
|
1025
|
+
let lastActivity = "";
|
|
1026
|
+
const toolCalls: string[] = [];
|
|
1027
|
+
const openToolCalls = new Map<string, UnmatchedToolCall>();
|
|
1028
|
+
let anonymousToolCall = 0;
|
|
1029
|
+
let sawEnd = false;
|
|
1030
|
+
|
|
1031
|
+
for (const line of parseText.split("\n")) {
|
|
1032
|
+
const s = line.trim();
|
|
1033
|
+
if (!s || s[0] !== "{") continue; // skip banners / warnings / blanks
|
|
1034
|
+
let e: Record<string, unknown>;
|
|
1035
|
+
try { e = JSON.parse(s); } catch { continue; }
|
|
1036
|
+
|
|
1037
|
+
const type = e.type as string | undefined;
|
|
1038
|
+
|
|
1039
|
+
// Authoritative final answer: the last assistant message at run end.
|
|
1040
|
+
if (type === "agent_end") {
|
|
1041
|
+
if (Array.isArray(e.messages)) {
|
|
1042
|
+
for (let i = e.messages.length - 1; i >= 0; i--) {
|
|
1043
|
+
const m = e.messages[i] as Msg;
|
|
1044
|
+
if (m?.role === "assistant") { const t = messageText(m); if (t) finalText = t; break; }
|
|
1045
|
+
}
|
|
1046
|
+
}
|
|
1047
|
+
sawEnd = true;
|
|
1048
|
+
}
|
|
1049
|
+
if (type === "agent_settled") sawEnd = true;
|
|
1050
|
+
|
|
1051
|
+
// Progress signal + fallback final: finalized assistant turns.
|
|
1052
|
+
// Accumulate spend from `message_end` only (fires once per turn), so
|
|
1053
|
+
// multi-turn tool-using runs sum correctly without double counting.
|
|
1054
|
+
if (type === "message_end") {
|
|
1055
|
+
const m = e.message as Msg | undefined;
|
|
1056
|
+
if (m?.role === "assistant") {
|
|
1057
|
+
const t = messageText(m);
|
|
1058
|
+
// Latest finalized assistant text wins, so a run without a
|
|
1059
|
+
// terminal `agent_end` still yields its LAST answer, not its first.
|
|
1060
|
+
if (t) { lastActivity = t; finalText = t; }
|
|
1061
|
+
const u = m?.usage;
|
|
1062
|
+
if (u) {
|
|
1063
|
+
usage.input += u.input ?? 0;
|
|
1064
|
+
usage.output += u.output ?? 0;
|
|
1065
|
+
usage.cacheRead += u.cacheRead ?? 0;
|
|
1066
|
+
usage.costUSD += u.cost?.total ?? 0;
|
|
1067
|
+
}
|
|
1068
|
+
}
|
|
1069
|
+
}
|
|
1070
|
+
if (type === "turn_end") {
|
|
1071
|
+
const m = e.message as Msg | undefined;
|
|
1072
|
+
if (m?.role === "assistant") {
|
|
1073
|
+
const t = messageText(m);
|
|
1074
|
+
if (t) { lastActivity = t; if (!finalText) finalText = t; }
|
|
1075
|
+
}
|
|
1076
|
+
}
|
|
1077
|
+
|
|
1078
|
+
// Live streaming: latest partial text or thinking.
|
|
1079
|
+
if (type === "message_update") {
|
|
1080
|
+
const m = e.message as Msg | undefined;
|
|
1081
|
+
const t = messageText(m);
|
|
1082
|
+
if (t) lastActivity = t;
|
|
1083
|
+
else if (Array.isArray(m?.content)) {
|
|
1084
|
+
const think = m!.content.find((b) => b?.type === "thinking") as { thinking?: string } | undefined;
|
|
1085
|
+
if (think?.thinking) lastActivity = `(thinking) ${think.thinking}`;
|
|
1086
|
+
}
|
|
1087
|
+
}
|
|
1088
|
+
|
|
1089
|
+
// Tool activity. Pi emits a toolCallId for normal events; fall back to
|
|
1090
|
+
// tool-name matching when replaying older/id-less streams.
|
|
1091
|
+
const toolCallId = typeof e.toolCallId === "string" ? e.toolCallId : undefined;
|
|
1092
|
+
if (type === "tool_execution_start") {
|
|
1093
|
+
const toolName = typeof e.toolName === "string" ? e.toolName : "unknown";
|
|
1094
|
+
if (toolCalls[toolCalls.length - 1] !== toolName) toolCalls.push(toolName);
|
|
1095
|
+
openToolCalls.set(toolCallId ?? `anonymous:${anonymousToolCall++}`, { id: toolCallId, toolName });
|
|
1096
|
+
}
|
|
1097
|
+
if (type === "tool_execution_end") {
|
|
1098
|
+
if (toolCallId) {
|
|
1099
|
+
openToolCalls.delete(toolCallId);
|
|
1100
|
+
} else if (typeof e.toolName === "string") {
|
|
1101
|
+
const matching = [...openToolCalls].find(([, call]) => call.toolName === e.toolName);
|
|
1102
|
+
if (matching) openToolCalls.delete(matching[0]);
|
|
1103
|
+
}
|
|
1104
|
+
}
|
|
1105
|
+
}
|
|
1106
|
+
|
|
1107
|
+
if (!finalText && !lastActivity && toolCalls.length === 0) {
|
|
1108
|
+
diagnostics.push("No parseable assistant/tool events found in the log tail.");
|
|
1109
|
+
}
|
|
1110
|
+
|
|
1111
|
+
usage.total = usage.input + usage.output;
|
|
1112
|
+
return { finalText, lastActivity, toolCalls, unmatchedToolCalls: [...openToolCalls.values()], sawEnd, usage, diagnostics };
|
|
1113
|
+
}
|
|
1114
|
+
/** Build the human-readable body for subagent_output. Exported for unit testing. */
|
|
1115
|
+
export function formatSubagentOutputBody(
|
|
1116
|
+
head: string,
|
|
1117
|
+
tools: string,
|
|
1118
|
+
parsedBody: string | undefined,
|
|
1119
|
+
rawTail: string,
|
|
1120
|
+
diagnostics: string[],
|
|
1121
|
+
): string {
|
|
1122
|
+
let body: string;
|
|
1123
|
+
if (parsedBody) {
|
|
1124
|
+
body = parsedBody;
|
|
1125
|
+
} else {
|
|
1126
|
+
body = rawTail === "(no output yet)"
|
|
1127
|
+
? "(no output yet)"
|
|
1128
|
+
: `(no parsed output yet)\n\n--- raw log tail ---\n${rawTail}`;
|
|
1129
|
+
}
|
|
1130
|
+
const diag = diagnostics.length ? `\n[parser: ${diagnostics.join("; ")}]` : "";
|
|
1131
|
+
return `${head}${tools}${diag}\n${body}`;
|
|
1132
|
+
}
|
|
1133
|
+
|
|
1134
|
+
/** Build the human-readable body for subagent_result. Exported for unit testing. */
|
|
1135
|
+
export function formatSubagentResultBody(
|
|
1136
|
+
head: string,
|
|
1137
|
+
finalText: string | undefined,
|
|
1138
|
+
rawTail: string,
|
|
1139
|
+
diagnostics: string[],
|
|
1140
|
+
): string {
|
|
1141
|
+
let body = finalText || `(no final answer parsed)\n\n--- raw log tail ---\n${rawTail}`;
|
|
1142
|
+
const diag = diagnostics.length ? `\n[parser: ${diagnostics.join("; ")}]` : "";
|
|
1143
|
+
return `${head}${diag}\n${body}`;
|
|
1144
|
+
}
|