@xzzpig/pi-openspec-x 0.1.0 → 0.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.
@@ -0,0 +1,955 @@
1
+ /**
2
+ * Child report attempts and the child-only tool wrapper (design D1), plus the
3
+ * canonical-session readers the parent-side ingestion uses (design D2).
4
+ *
5
+ * Why this module exists: the three structured reporting tools used to be
6
+ * registered into the main session, so they leaked into the parent's tool
7
+ * declaration and prompt rules. They are now registered only inside the
8
+ * matching subagent's child process, each entry wrapping exactly one tool
9
+ * (design D1). A wrapper result carries an *envelope* —
10
+ * `{ version, attemptId, agent, report }` — where `report` is the unchanged
11
+ * business payload. The envelope is result metadata consumed by the parent
12
+ * ingestion; it never adds a model-fillable parameter, and the parent
13
+ * re-validates `report` against the original tool schema.
14
+ *
15
+ * The attempt is the anti-forgery anchor: for each user dispatch,
16
+ * `before_agent_start` records a fresh random `attemptId` plus the SHA-256 of
17
+ * the actual prompt in a `pi-openspec-x/child-report-attempt:v1` session entry.
18
+ * A report is attributable only to the dispatch whose attempt it names, so a
19
+ * resumed child that reuses the same session file cannot smuggle an earlier
20
+ * run's report into the current dispatch.
21
+ *
22
+ * Deliberately NOT here: any reading of pi-subagents' private `AsyncStatus`
23
+ * or lifecycle constants (design D3). This module declares the minimal v3
24
+ * structure it supports and validates real fields at runtime.
25
+ */
26
+ import { createHash, randomUUID } from "node:crypto";
27
+ import * as fs from "node:fs";
28
+ import * as path from "node:path";
29
+
30
+ import { validateToolArguments } from "@earendil-works/pi-ai";
31
+ import type {
32
+ BeforeAgentStartEvent,
33
+ ExtensionAPI,
34
+ ExtensionContext,
35
+ ExtensionToolContext,
36
+ ToolDefinition,
37
+ } from "@earendil-works/pi-coding-agent";
38
+
39
+ import { createReportTools } from "./report-tools.ts";
40
+
41
+ /** Session entry type holding one child report attempt. */
42
+ export const CHILD_REPORT_ATTEMPT_ENTRY = "pi-openspec-x/child-report-attempt";
43
+
44
+ /** Version of the attempt entry payload (design D1). */
45
+ export const CHILD_REPORT_ATTEMPT_VERSION = 1;
46
+
47
+ /** Version of the tool-result envelope wrapping a business report. */
48
+ export const CHILD_REPORT_ENVELOPE_VERSION = 1;
49
+
50
+ /** Prefix of the worker dispatch task marker carried in the Task section. */
51
+ export const DISPATCH_TASK_MARKER_PREFIX = "[OPSX WORKER DISPATCH task=";
52
+
53
+ /** Closing delimiter of the worker dispatch task marker. */
54
+ export const DISPATCH_TASK_MARKER_SUFFIX = "]";
55
+
56
+ /**
57
+ * Persisted attempt metadata. `taskMarker` is present only when the prompt
58
+ * named exactly one distinct dispatch task (design D3: uniqueness, not
59
+ * line-anchoring, is the safety property).
60
+ */
61
+ export interface ChildReportAttempt {
62
+ version: typeof CHILD_REPORT_ATTEMPT_VERSION;
63
+ attemptId: string;
64
+ agent: string;
65
+ sessionId?: string;
66
+ startedAt: string;
67
+ promptSha256: string;
68
+ taskMarker?: string;
69
+ }
70
+
71
+ /** Distinct dispatch task ids found in one prompt, in first-seen order. */
72
+ export function scanDispatchMarkers(prompt: string): string[] {
73
+ const found: string[] = [];
74
+ // Tolerant of the shapes a model actually writes: casing, an optional colon
75
+ // after DISPATCH, and whitespace around `=`. Attribution still requires
76
+ // exactly one distinct marker, so widening the syntax cannot make a run
77
+ // ambiguous (design D3) — it only stops a well-meant marker from being
78
+ // silently ignored, which left every live dispatch `taskUnknown`.
79
+ const pattern = /\[OPSX\s+WORKER\s+DISPATCH\s*:?\s*task\s*=\s*([^\]\n]+)\]/gi;
80
+ for (const match of prompt.matchAll(pattern)) {
81
+ const encoded = match[1]?.trim();
82
+ if (!encoded) continue;
83
+ let decoded = encoded;
84
+ try {
85
+ decoded = decodeURIComponent(encoded);
86
+ } catch {
87
+ // A malformed escape sequence stays as written; the value is still
88
+ // compared verbatim against the report's taskId.
89
+ decoded = encoded;
90
+ }
91
+ if (!found.includes(decoded)) found.push(decoded);
92
+ }
93
+ return found;
94
+ }
95
+
96
+ /** SHA-256 of the actual prompt text, hex encoded. */
97
+ export function promptDigest(prompt: string): string {
98
+ return createHash("sha256").update(prompt, "utf8").digest("hex");
99
+ }
100
+
101
+ /** Build the attempt payload for one prompt. */
102
+ export function buildChildReportAttempt(input: {
103
+ agent: string;
104
+ prompt: string;
105
+ sessionId?: string | undefined;
106
+ now?: Date | undefined;
107
+ attemptId?: string | undefined;
108
+ }): ChildReportAttempt {
109
+ const markers = scanDispatchMarkers(input.prompt);
110
+ const attempt: ChildReportAttempt = {
111
+ version: CHILD_REPORT_ATTEMPT_VERSION,
112
+ attemptId: input.attemptId ?? randomUUID(),
113
+ agent: input.agent,
114
+ startedAt: (input.now ?? new Date()).toISOString(),
115
+ promptSha256: promptDigest(input.prompt),
116
+ };
117
+ if (input.sessionId) attempt.sessionId = input.sessionId;
118
+ // Exactly one distinct marker names the task; zero or several do not.
119
+ if (markers.length === 1) attempt.taskMarker = markers[0];
120
+ return attempt;
121
+ }
122
+
123
+ /** Outcome of recording an attempt: either persisted or explicitly refused. */
124
+ export type ChildReportAttemptOutcome =
125
+ | { ok: true; attempt: ChildReportAttempt }
126
+ | { ok: false; reason: string };
127
+
128
+ /**
129
+ * Record a fresh attempt entry for the prompt about to run. Persistence
130
+ * failure is reported, never swallowed: an attempt that is not on the branch
131
+ * cannot authorize anything, and the wrapper must fail loudly instead of
132
+ * returning a fake success (design D1).
133
+ */
134
+ export function beginChildReportAttempt(
135
+ pi: ExtensionAPI,
136
+ ctx: ExtensionContext,
137
+ input: {
138
+ agent: string;
139
+ prompt: string;
140
+ now?: Date | undefined;
141
+ attemptId?: string | undefined;
142
+ },
143
+ ): ChildReportAttemptOutcome {
144
+ const attempt = buildChildReportAttempt({
145
+ agent: input.agent,
146
+ prompt: input.prompt,
147
+ sessionId: ctx.sessionManager.getSessionId(),
148
+ now: input.now,
149
+ attemptId: input.attemptId,
150
+ });
151
+ try {
152
+ pi.appendEntry(CHILD_REPORT_ATTEMPT_ENTRY, attempt);
153
+ } catch (error) {
154
+ return {
155
+ ok: false,
156
+ reason: `could not record the child report attempt: ${
157
+ error instanceof Error ? error.message : String(error)
158
+ }`,
159
+ };
160
+ }
161
+ return { ok: true, attempt };
162
+ }
163
+
164
+ /** Human-readable refusal text for a report that cannot be attributed. */
165
+ export function attemptUnavailableMessage(agent: string): string {
166
+ return (
167
+ `Report rejected: no child report attempt is recorded for ${agent} in this dispatch. ` +
168
+ `The parent cannot attribute this report to a verified run, so it would be ignored; ` +
169
+ `the attempt entry is written by this extension at the start of each user dispatch.`
170
+ );
171
+ }
172
+
173
+ /**
174
+ * Wrap one role's reporting tool with the attempt envelope and register it.
175
+ *
176
+ * The wrapped tool keeps the original name, schema, confirmation text and
177
+ * `sequential` execution mode; only `details` changes into the envelope. When
178
+ * the current dispatch has no persisted attempt, the call fails explicitly instead
179
+ * of returning a report the parent would have to discard.
180
+ */
181
+ export function registerChildReportTool(
182
+ pi: ExtensionAPI,
183
+ options: { agent: string; tool: ToolDefinition },
184
+ ): void {
185
+ let current: ChildReportAttemptOutcome | undefined;
186
+
187
+ pi.on("before_agent_start", (event: BeforeAgentStartEvent, ctx) => {
188
+ // Pi 1.2+ also fires this hook when a custom message wakes the same child.
189
+ // It is not a new user dispatch: keep its existing attempt, or continue to
190
+ // reject reports if no dispatch has been recorded. Pi 1.1 never fired this
191
+ // hook for such a wake and has no messageType field.
192
+ if ("messageType" in event && event.messageType === "custom") return;
193
+ current = undefined;
194
+ if (!("prompt" in event) || typeof event.prompt !== "string") return;
195
+ current = beginChildReportAttempt(pi, ctx, {
196
+ agent: options.agent,
197
+ prompt: event.prompt,
198
+ });
199
+ });
200
+
201
+ const base = options.tool;
202
+ // Derive the parameter and callback types from the wrapped definition so
203
+ // the wrapper is exactly as typed as the tool it wraps — no `unknown`
204
+ // escapes, and the model-visible schema is untouched.
205
+ type BaseParams = Parameters<typeof base.execute>[1];
206
+ type BaseOnUpdate = Parameters<typeof base.execute>[3];
207
+
208
+ const wrapped: ToolDefinition = {
209
+ ...base,
210
+ async execute(
211
+ toolCallId: string,
212
+ params: BaseParams,
213
+ signal: AbortSignal | undefined,
214
+ onUpdate: BaseOnUpdate,
215
+ ctx: ExtensionToolContext,
216
+ ) {
217
+ if (!current || !current.ok) {
218
+ return {
219
+ content: [
220
+ {
221
+ type: "text" as const,
222
+ text: attemptUnavailableMessage(options.agent),
223
+ },
224
+ ],
225
+ details: {
226
+ version: CHILD_REPORT_ENVELOPE_VERSION,
227
+ agent: options.agent,
228
+ error: "attempt-unavailable",
229
+ },
230
+ isError: true,
231
+ };
232
+ }
233
+ const attemptId = current.attempt.attemptId;
234
+ const result = await base.execute(
235
+ toolCallId,
236
+ params,
237
+ signal,
238
+ onUpdate,
239
+ ctx,
240
+ );
241
+ return {
242
+ ...result,
243
+ details: {
244
+ version: CHILD_REPORT_ENVELOPE_VERSION,
245
+ attemptId,
246
+ agent: options.agent,
247
+ report: params,
248
+ },
249
+ };
250
+ },
251
+ };
252
+
253
+ pi.registerTool(wrapped);
254
+ }
255
+
256
+ // ---------------------------------------------------------------------------
257
+ // D2: reading a structured report out of a run's messages
258
+ // ---------------------------------------------------------------------------
259
+
260
+ /** The only three roles that carry a report tool, and the tool each carries. */
261
+ export const CHILD_REPORT_TOOL_BY_AGENT: Record<string, string> = {
262
+ "opsx-gap-analysis": "report_gap_analysis",
263
+ "opsx-plan-review": "report_plan_review",
264
+ "opsx-worker": "report_work",
265
+ };
266
+
267
+ /** Result of reading one run's messages for a report. */
268
+ export type ChildReportReadResult =
269
+ | {
270
+ status: "ready";
271
+ toolCallId: string;
272
+ toolName: string;
273
+ report: Record<string, unknown>;
274
+ }
275
+ | { status: "none"; reason: string }
276
+ | { status: "rejected"; reason: string };
277
+
278
+ /**
279
+ * Minimal structural view of the message fields this reader consumes. The
280
+ * real values come from pi-ai's `Message` union; only these fields are ever
281
+ * read, and `content` is never parsed as a delivery channel (design D2: free
282
+ * text and previews are not evidence).
283
+ */
284
+ interface MessageLike {
285
+ role?: string;
286
+ content?: unknown;
287
+ toolCallId?: string;
288
+ toolName?: string;
289
+ isError?: boolean;
290
+ details?: unknown;
291
+ }
292
+
293
+ interface FoundCall {
294
+ id: string;
295
+ arguments: unknown;
296
+ order: number;
297
+ }
298
+
299
+ /** The report tool definitions keyed by name, for host-identical validation. */
300
+ const REPORT_TOOLS_BY_NAME = new Map(
301
+ createReportTools().map((tool) => [tool.name, tool as unknown]),
302
+ );
303
+
304
+ /**
305
+ * Validate a business report against the original tool schema using the same
306
+ * validator the host applies to tool calls, so a report the host would have
307
+ * refused can never be accepted here.
308
+ */
309
+ export function validateChildReport(
310
+ toolName: string,
311
+ candidate: unknown,
312
+ ):
313
+ | { ok: true; report: Record<string, unknown> }
314
+ | { ok: false; reason: string } {
315
+ const tool = REPORT_TOOLS_BY_NAME.get(toolName);
316
+ if (!tool) return { ok: false, reason: `unknown report tool ${toolName}` };
317
+ try {
318
+ const validated = validateToolArguments(
319
+ tool as never,
320
+ {
321
+ type: "toolCall",
322
+ id: "child-report-validation",
323
+ name: toolName,
324
+ arguments: candidate,
325
+ } as never,
326
+ ) as unknown;
327
+ if (!validated || typeof validated !== "object") {
328
+ return { ok: false, reason: "the report is not an object" };
329
+ }
330
+ // The report budget is enforced, not merely declared: an oversized payload
331
+ // must be refused rather than truncated and then treated as complete
332
+ // (design D4).
333
+ const size = Buffer.byteLength(JSON.stringify(validated), "utf8");
334
+ if (size > MAX_REPORT_BYTES) {
335
+ return {
336
+ ok: false,
337
+ reason: `the report is ${size} bytes, which exceeds the ${MAX_REPORT_BYTES} byte report budget`,
338
+ };
339
+ }
340
+ return { ok: true, report: validated as Record<string, unknown> };
341
+ } catch (error) {
342
+ return {
343
+ ok: false,
344
+ reason: error instanceof Error ? error.message : String(error),
345
+ };
346
+ }
347
+ }
348
+
349
+ function assistantReportCalls(
350
+ message: MessageLike,
351
+ toolName: string,
352
+ ): FoundCall[] {
353
+ if (message.role !== "assistant" || !Array.isArray(message.content)) {
354
+ return [];
355
+ }
356
+ const calls: FoundCall[] = [];
357
+ for (const part of message.content) {
358
+ if (!part || typeof part !== "object") continue;
359
+ const candidate = part as {
360
+ type?: unknown;
361
+ id?: unknown;
362
+ name?: unknown;
363
+ arguments?: unknown;
364
+ };
365
+ if (candidate.type !== "toolCall") continue;
366
+ if (candidate.name !== toolName) continue;
367
+ if (typeof candidate.id !== "string") continue;
368
+ calls.push({ id: candidate.id, arguments: candidate.arguments, order: -1 });
369
+ }
370
+ return calls;
371
+ }
372
+
373
+ function readEnvelope(
374
+ details: unknown,
375
+ expected: { agent: string; attemptId: string },
376
+ ): { ok: true; report: unknown } | { ok: false; reason: string } {
377
+ if (!details || typeof details !== "object") {
378
+ return { ok: false, reason: "the tool result carries no envelope details" };
379
+ }
380
+ const envelope = details as {
381
+ version?: unknown;
382
+ attemptId?: unknown;
383
+ agent?: unknown;
384
+ report?: unknown;
385
+ };
386
+ if (envelope.version !== CHILD_REPORT_ENVELOPE_VERSION) {
387
+ return {
388
+ ok: false,
389
+ reason: `unsupported envelope version ${String(envelope.version)}`,
390
+ };
391
+ }
392
+ if (envelope.agent !== expected.agent) {
393
+ return {
394
+ ok: false,
395
+ reason: `the envelope belongs to ${String(envelope.agent)}, not ${expected.agent}`,
396
+ };
397
+ }
398
+ if (envelope.attemptId !== expected.attemptId) {
399
+ return {
400
+ ok: false,
401
+ reason: "the envelope names a different attempt than this turn's",
402
+ };
403
+ }
404
+ return { ok: true, report: envelope.report };
405
+ }
406
+
407
+ /**
408
+ * Read the report a run submitted, from that run's own messages.
409
+ *
410
+ * Ordering follows the *call* order, never the arrival order of duplicated
411
+ * results: the last report call decides. A last call that is missing, failed,
412
+ * mis-addressed or schema-invalid makes the run unreportable — it never falls
413
+ * back to an earlier success (design D2 rule 4).
414
+ */
415
+ export function readChildReportMessages(
416
+ messages: readonly unknown[],
417
+ expected: {
418
+ agent: string;
419
+ toolName: string;
420
+ attemptId: string;
421
+ taskId?: string | undefined;
422
+ },
423
+ ): ChildReportReadResult {
424
+ const calls: FoundCall[] = [];
425
+ const resultsByCallId = new Map<string, MessageLike>();
426
+
427
+ messages.forEach((raw, order) => {
428
+ const message = raw as MessageLike;
429
+ if (!message || typeof message !== "object") return;
430
+ if (message.role === "assistant") {
431
+ for (const call of assistantReportCalls(message, expected.toolName)) {
432
+ // Idempotent dedupe: one call id counts once, at its first position.
433
+ if (calls.some((existing) => existing.id === call.id)) continue;
434
+ calls.push({ ...call, order });
435
+ }
436
+ return;
437
+ }
438
+ if (
439
+ message.role === "toolResult" &&
440
+ typeof message.toolCallId === "string"
441
+ ) {
442
+ if (!resultsByCallId.has(message.toolCallId)) {
443
+ resultsByCallId.set(message.toolCallId, message);
444
+ }
445
+ }
446
+ });
447
+
448
+ if (calls.length === 0) {
449
+ return {
450
+ status: "none",
451
+ reason: `this run made no ${expected.toolName} call`,
452
+ };
453
+ }
454
+
455
+ // More calls than the budget allows is a refusal, never a truncation: the
456
+ // "last call wins" rule must not be applied to a run we cannot fully read.
457
+ if (calls.length > MAX_REPORT_CALLS) {
458
+ return {
459
+ status: "rejected",
460
+ reason: `this run made more than ${MAX_REPORT_CALLS} ${expected.toolName} calls, which exceeds the report-call budget`,
461
+ };
462
+ }
463
+
464
+ const last = calls[calls.length - 1];
465
+
466
+ // The call arguments must themselves be a valid report; a call the host
467
+ // would have rejected is not delivery (design D2: arguments alone are not a
468
+ // report either).
469
+ const callValidation = validateChildReport(expected.toolName, last.arguments);
470
+ if (!callValidation.ok) {
471
+ return {
472
+ status: "rejected",
473
+ reason: `the latest ${expected.toolName} call arguments are invalid: ${callValidation.reason}`,
474
+ };
475
+ }
476
+
477
+ const result = resultsByCallId.get(last.id);
478
+ if (!result) {
479
+ return {
480
+ status: "rejected",
481
+ reason: `the latest ${expected.toolName} call has no paired tool result`,
482
+ };
483
+ }
484
+ if (result.isError === true) {
485
+ return {
486
+ status: "rejected",
487
+ reason: `the latest ${expected.toolName} call failed`,
488
+ };
489
+ }
490
+ if (result.toolName !== expected.toolName) {
491
+ return {
492
+ status: "rejected",
493
+ reason: `the paired result belongs to ${String(result.toolName)}`,
494
+ };
495
+ }
496
+
497
+ // A report that cannot be bound to an attempt nonce is not delivery: the
498
+ // caller (readChildReportSession) adopts the newest attempt on the branch
499
+ // and passes it here, so an absent nonce means the caller never resolved
500
+ // one (design D2).
501
+ if (expected.attemptId === undefined) {
502
+ return {
503
+ status: "rejected",
504
+ reason: "the report cannot be bound to an attempt nonce",
505
+ };
506
+ }
507
+ const envelope = readEnvelope(result.details, {
508
+ agent: expected.agent,
509
+ attemptId: expected.attemptId,
510
+ });
511
+ if (!envelope.ok) {
512
+ return { status: "rejected", reason: envelope.reason };
513
+ }
514
+
515
+ const report = validateChildReport(expected.toolName, envelope.report);
516
+ if (!report.ok) {
517
+ return {
518
+ status: "rejected",
519
+ reason: `the reported payload is invalid: ${report.reason}`,
520
+ };
521
+ }
522
+ if (
523
+ expected.taskId !== undefined &&
524
+ report.report.taskId !== expected.taskId
525
+ ) {
526
+ return {
527
+ status: "rejected",
528
+ reason: `the report names task ${String(report.report.taskId)}, not ${expected.taskId}`,
529
+ };
530
+ }
531
+
532
+ return {
533
+ status: "ready",
534
+ toolCallId: last.id,
535
+ toolName: expected.toolName,
536
+ report: report.report,
537
+ };
538
+ }
539
+
540
+ // ---------------------------------------------------------------------------
541
+ // D2/D4: reading the canonical Pi session record
542
+ // ---------------------------------------------------------------------------
543
+
544
+ /** Pi session format version this reader understands. */
545
+ export const SUPPORTED_SESSION_VERSION = 3;
546
+
547
+ /** Resource budgets (design D4). Exceeding one makes a run unusable, not truncated. */
548
+ export const MAX_SESSION_BYTES = 32 * 1024 * 1024;
549
+ export const MAX_SESSION_LINE_BYTES = 4 * 1024 * 1024;
550
+ export const MAX_SESSION_ENTRIES = 32768;
551
+ export const MAX_REPORT_BYTES = 512 * 1024;
552
+ export const MAX_REPORT_CALLS = 128;
553
+
554
+ /** Native child runs observed for one flow at once (design D4). */
555
+ export const MAX_ACTIVE_CHILDREN = 32;
556
+
557
+ /** One raw session line, structurally validated before use. */
558
+ interface RawEntry {
559
+ type?: unknown;
560
+ id?: unknown;
561
+ parentId?: unknown;
562
+ timestamp?: unknown;
563
+ version?: unknown;
564
+ customType?: unknown;
565
+ data?: unknown;
566
+ message?: unknown;
567
+ }
568
+
569
+ /** What the caller knows about the run from its own launch records. */
570
+ export interface ChildSessionExpectation {
571
+ agent: string;
572
+ toolName: string;
573
+ /**
574
+ * The nonce to bind to. Omit it to adopt the newest attempt entry on the
575
+ * current branch: the child writes the nonce into its own session, so the
576
+ * parent only learns it by reading that session, and requiring it up front
577
+ * would make readiness unreachable. The step window and the branch scan
578
+ * still bound which attempt may be adopted.
579
+ */
580
+ attemptId?: string | undefined;
581
+ taskId?: string | undefined;
582
+ runId: string;
583
+ stepIndex: number;
584
+ sessionRoot: string;
585
+ sessionFile: string;
586
+ stepStartedAt?: string | undefined;
587
+ stepEndedAt?: string | undefined;
588
+ /** True once the native step reached a successful terminal state. */
589
+ stepTerminal: boolean;
590
+ }
591
+
592
+ /** Outcome of reading a child's canonical session record. */
593
+ export type ChildSessionOutcome =
594
+ | {
595
+ status: "ready";
596
+ toolCallId: string;
597
+ toolName: string;
598
+ report: Record<string, unknown>;
599
+ attempt: ChildReportAttempt;
600
+ }
601
+ | { status: "pending"; reason: string }
602
+ | { status: "failed"; reason: string };
603
+
604
+ function fail(reason: string): ChildSessionOutcome {
605
+ return { status: "failed", reason };
606
+ }
607
+
608
+ /** True when `child` resolves to a location inside `root` (both realpaths). */
609
+ export function isPathWithinRoot(child: string, root: string): boolean {
610
+ const relative = path.relative(root, child);
611
+ return (
612
+ relative === "" ||
613
+ (!relative.startsWith("..") && !path.isAbsolute(relative))
614
+ );
615
+ }
616
+
617
+ /**
618
+ * Parse the session file strictly: every line must be valid JSON, the header
619
+ * must be a supported v3 header, ids must be unique and every `parentId` must
620
+ * resolve. The public parser tolerates skipped lines; tolerating them here
621
+ * would let a dropped line silently erase the evidence that a later report
622
+ * failed (design D2 constraint 1).
623
+ */
624
+ function parseStrictSession(
625
+ content: string,
626
+ ): { ok: true; entries: RawEntry[] } | { ok: false; reason: string } {
627
+ const lines = content.split("\n");
628
+ // A trailing newline produces one empty tail element, which is expected.
629
+ while (lines.length > 0 && lines[lines.length - 1] === "") lines.pop();
630
+ if (lines.length === 0)
631
+ return { ok: false, reason: "the session file is empty" };
632
+
633
+ const entries: RawEntry[] = [];
634
+ for (let index = 0; index < lines.length; index += 1) {
635
+ const line = lines[index];
636
+ if (Buffer.byteLength(line, "utf8") > MAX_SESSION_LINE_BYTES) {
637
+ return {
638
+ ok: false,
639
+ reason: `session line ${index + 1} exceeds the ${MAX_SESSION_LINE_BYTES} byte line budget`,
640
+ };
641
+ }
642
+ let parsed: unknown;
643
+ try {
644
+ parsed = JSON.parse(line);
645
+ } catch {
646
+ return {
647
+ ok: false,
648
+ reason: `session line ${index + 1} is not valid JSON`,
649
+ };
650
+ }
651
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
652
+ return {
653
+ ok: false,
654
+ reason: `session line ${index + 1} is not an object`,
655
+ };
656
+ }
657
+ entries.push(parsed as RawEntry);
658
+ }
659
+
660
+ const header = entries[0];
661
+ if (header.type !== "session") {
662
+ return { ok: false, reason: "the session file has no session header" };
663
+ }
664
+ if (header.version !== SUPPORTED_SESSION_VERSION) {
665
+ return {
666
+ ok: false,
667
+ reason: `unsupported session version ${String(header.version)}`,
668
+ };
669
+ }
670
+
671
+ const ids = new Set<string>();
672
+ for (let index = 1; index < entries.length; index += 1) {
673
+ const entry = entries[index];
674
+ if (typeof entry.id !== "string" || entry.id === "") {
675
+ return { ok: false, reason: `session entry ${index} has no id` };
676
+ }
677
+ if (ids.has(entry.id)) {
678
+ return { ok: false, reason: `duplicate session entry id ${entry.id}` };
679
+ }
680
+ ids.add(entry.id);
681
+ if (typeof entry.type !== "string") {
682
+ return { ok: false, reason: `session entry ${entry.id} has no type` };
683
+ }
684
+ if (entry.parentId !== null && typeof entry.parentId !== "string") {
685
+ return { ok: false, reason: `session entry ${entry.id} has no parentId` };
686
+ }
687
+ if (typeof entry.parentId === "string" && !ids.has(entry.parentId)) {
688
+ return {
689
+ ok: false,
690
+ reason: `session entry ${entry.id} points at a missing parent ${entry.parentId}`,
691
+ };
692
+ }
693
+ }
694
+ if (entries.length - 1 > MAX_SESSION_ENTRIES) {
695
+ return {
696
+ ok: false,
697
+ reason: `the session holds more than ${MAX_SESSION_ENTRIES} entries`,
698
+ };
699
+ }
700
+ return { ok: true, entries };
701
+ }
702
+
703
+ /** The current leaf branch, root-first, derived from the last appended entry. */
704
+ function branchOf(entries: readonly RawEntry[]): RawEntry[] {
705
+ const byId = new Map<string, RawEntry>();
706
+ for (const entry of entries) {
707
+ if (typeof entry.id === "string") byId.set(entry.id, entry);
708
+ }
709
+ const branch: RawEntry[] = [];
710
+ let cursor: RawEntry | undefined = entries[entries.length - 1];
711
+ while (cursor) {
712
+ branch.push(cursor);
713
+ cursor =
714
+ typeof cursor.parentId === "string"
715
+ ? byId.get(cursor.parentId)
716
+ : undefined;
717
+ }
718
+ return branch.reverse();
719
+ }
720
+
721
+ // ---------------------------------------------------------------------------
722
+ // Incremental reading (design D4)
723
+ // ---------------------------------------------------------------------------
724
+
725
+ function unresolvedToolCalls(messages: readonly MessageLike[]): number {
726
+ const resolved = new Set<string>();
727
+ for (const message of messages) {
728
+ if (
729
+ message.role === "toolResult" &&
730
+ typeof message.toolCallId === "string"
731
+ ) {
732
+ resolved.add(message.toolCallId);
733
+ }
734
+ }
735
+ let pending = 0;
736
+ for (const message of messages) {
737
+ if (message.role !== "assistant" || !Array.isArray(message.content))
738
+ continue;
739
+ for (const part of message.content) {
740
+ if (!part || typeof part !== "object") continue;
741
+ const candidate = part as { type?: unknown; id?: unknown };
742
+ if (candidate.type !== "toolCall") continue;
743
+ if (typeof candidate.id === "string" && !resolved.has(candidate.id)) {
744
+ pending += 1;
745
+ }
746
+ }
747
+ }
748
+ return pending;
749
+ }
750
+
751
+ /**
752
+ * Read a child's report from its canonical Pi session record.
753
+ *
754
+ * The record is the authority (design D2): the best-effort diagnostic log is
755
+ * never consulted, and a missing line, broken parent chain, absent tail or
756
+ * changed file keeps the run unreportable rather than falling back to an
757
+ * earlier success. `SessionManager.open` is deliberately not used — it may
758
+ * repair the file it reads.
759
+ */
760
+ export function readChildReportSession(
761
+ expected: ChildSessionExpectation,
762
+ ): ChildSessionOutcome {
763
+ let root: string;
764
+ let file: string;
765
+ try {
766
+ root = fs.realpathSync(expected.sessionRoot);
767
+ file = fs.realpathSync(expected.sessionFile);
768
+ } catch (error) {
769
+ return fail(
770
+ `the attributed session path cannot be resolved: ${
771
+ error instanceof Error ? error.message : String(error)
772
+ }`,
773
+ );
774
+ }
775
+ if (!isPathWithinRoot(file, root)) {
776
+ return fail(
777
+ "the session file resolves outside the attributed session root",
778
+ );
779
+ }
780
+
781
+ let before: fs.Stats;
782
+ let content: string;
783
+ try {
784
+ before = fs.statSync(file);
785
+ if (before.size > MAX_SESSION_BYTES) {
786
+ return fail(
787
+ `the session file exceeds the ${MAX_SESSION_BYTES} byte budget`,
788
+ );
789
+ }
790
+ content = fs.readFileSync(file, "utf8");
791
+ } catch (error) {
792
+ const code = (error as { code?: string }).code;
793
+ if (code === "ENOENT") {
794
+ return {
795
+ status: "pending",
796
+ reason: "the child session file does not exist yet",
797
+ };
798
+ }
799
+ return fail(
800
+ `the child session file cannot be read: ${
801
+ error instanceof Error ? error.message : String(error)
802
+ }`,
803
+ );
804
+ }
805
+
806
+ const parsed = parseStrictSession(content);
807
+ if (!parsed.ok) return fail(parsed.reason);
808
+ const branch = branchOf(parsed.entries);
809
+
810
+ // The latest recorded attempt on this branch must be this turn's.
811
+ let attemptIndex = -1;
812
+ let attempt: ChildReportAttempt | undefined;
813
+ for (let index = branch.length - 1; index >= 0; index -= 1) {
814
+ const entry = branch[index];
815
+ if (
816
+ entry.type !== "custom" ||
817
+ entry.customType !== CHILD_REPORT_ATTEMPT_ENTRY
818
+ ) {
819
+ continue;
820
+ }
821
+ const data = entry.data as ChildReportAttempt | undefined;
822
+ if (!data || data.version !== CHILD_REPORT_ATTEMPT_VERSION) continue;
823
+ if (
824
+ expected.attemptId !== undefined &&
825
+ data.attemptId !== expected.attemptId
826
+ ) {
827
+ continue;
828
+ }
829
+ if (data.agent !== expected.agent) continue;
830
+ attemptIndex = index;
831
+ attempt = data;
832
+ break;
833
+ }
834
+ if (!attempt || attemptIndex === -1) {
835
+ return fail("the current branch carries no matching child report attempt");
836
+ }
837
+ const attemptId = attempt.attemptId;
838
+
839
+ if (
840
+ expected.stepStartedAt !== undefined &&
841
+ attempt.startedAt < expected.stepStartedAt
842
+ ) {
843
+ return fail("the attempt predates the attributed native step");
844
+ }
845
+ if (
846
+ expected.stepEndedAt !== undefined &&
847
+ attempt.startedAt > expected.stepEndedAt
848
+ ) {
849
+ return fail("the attempt postdates the attributed native step");
850
+ }
851
+
852
+ // The attempt must follow a real user dispatch on this branch: a report can
853
+ // never be attributed to an inherited turn.
854
+ let dispatchIndex = -1;
855
+ for (let index = attemptIndex - 1; index >= 0; index -= 1) {
856
+ const entry = branch[index];
857
+ if (entry.type !== "message") continue;
858
+ const message = entry.message as MessageLike | undefined;
859
+ if (message?.role === "user") {
860
+ dispatchIndex = index;
861
+ break;
862
+ }
863
+ }
864
+ if (dispatchIndex === -1) {
865
+ // The attempt entry is written by `before_agent_start`, which the host runs
866
+ // just before the user message is persisted, so the dispatch for this turn
867
+ // legitimately lands immediately *after* the attempt. What matters is that
868
+ // a real dispatch exists and the report follows it — not the attempt's
869
+ // position relative to it. Live, requiring "preceded by" rejected every
870
+ // genuine report.
871
+ for (let index = attemptIndex + 1; index < branch.length; index += 1) {
872
+ const entry = branch[index];
873
+ if (entry.type !== "message") continue;
874
+ const message = entry.message as MessageLike | undefined;
875
+ if (message?.role === "user") {
876
+ dispatchIndex = index;
877
+ break;
878
+ }
879
+ }
880
+ }
881
+ if (dispatchIndex === -1) {
882
+ return fail(
883
+ "the attempt is not preceded by a user dispatch on this branch",
884
+ );
885
+ }
886
+
887
+ const turnMessages: MessageLike[] = [];
888
+ for (let index = attemptIndex + 1; index < branch.length; index += 1) {
889
+ const entry = branch[index];
890
+ if (entry.type !== "message") continue;
891
+ if (entry.message && typeof entry.message === "object") {
892
+ turnMessages.push(entry.message as MessageLike);
893
+ }
894
+ }
895
+
896
+ // A normal ending: every tool call was answered, so the turn actually
897
+ // finished rather than being cut off mid-flight.
898
+ if (unresolvedToolCalls(turnMessages) > 0) {
899
+ return {
900
+ status: "pending",
901
+ reason: "the child turn still has an unresolved tool call",
902
+ };
903
+ }
904
+
905
+ const read = readChildReportMessages(turnMessages, {
906
+ agent: expected.agent,
907
+ toolName: expected.toolName,
908
+ attemptId,
909
+ taskId: expected.taskId,
910
+ });
911
+
912
+ // Reading is only trustworthy if the file did not move under us.
913
+ let after: fs.Stats;
914
+ try {
915
+ after = fs.statSync(file);
916
+ } catch (error) {
917
+ return fail(
918
+ `the child session file disappeared while reading: ${
919
+ error instanceof Error ? error.message : String(error)
920
+ }`,
921
+ );
922
+ }
923
+ if (after.size !== before.size || after.mtimeMs !== before.mtimeMs) {
924
+ return fail("the child session file changed while it was being read");
925
+ }
926
+
927
+ if (read.status !== "ready") {
928
+ if (read.status === "none" && !expected.stepTerminal) {
929
+ return { status: "pending", reason: read.reason };
930
+ }
931
+ return fail(read.reason);
932
+ }
933
+
934
+ if (!expected.stepTerminal) {
935
+ // A report observed while the child is still running is a candidate, never
936
+ // a receipt (design D3).
937
+ return {
938
+ status: "pending",
939
+ reason: "the child run has not reached a successful terminal state",
940
+ };
941
+ }
942
+ if (expected.stepEndedAt === undefined) {
943
+ return fail(
944
+ "the terminal native step carries no end boundary, so the report cannot be bounded to it",
945
+ );
946
+ }
947
+
948
+ return {
949
+ status: "ready",
950
+ toolCallId: read.toolCallId,
951
+ toolName: read.toolName,
952
+ report: read.report,
953
+ attempt,
954
+ };
955
+ }