@llblab/pi-actors 0.49.2 → 0.51.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.
Files changed (44) hide show
  1. package/AGENTS.md +1 -1
  2. package/BACKLOG.md +1 -11
  3. package/CHANGELOG.md +17 -1
  4. package/README.md +22 -4
  5. package/dist/index.js +1 -1
  6. package/dist/lib/extension-runtime.d.ts +1 -1
  7. package/dist/lib/extension-runtime.js +1 -1
  8. package/dist/lib/inspector-overlay.d.ts +3 -0
  9. package/dist/lib/inspector-overlay.js +120 -38
  10. package/dist/lib/limits.d.ts +1 -0
  11. package/dist/lib/limits.js +1 -0
  12. package/dist/lib/observability.js +3 -1
  13. package/dist/lib/runs-artifacts.js +24 -4
  14. package/dist/lib/session-evidence.d.ts +1 -0
  15. package/dist/lib/session-evidence.js +3 -2
  16. package/dist/lib/state-readers.d.ts +5 -1
  17. package/dist/lib/state-readers.js +30 -3
  18. package/dist/lib/tools-access.d.ts +1 -0
  19. package/dist/lib/tools-access.js +13 -11
  20. package/dist/lib/tools-inspect.js +5 -5
  21. package/dist/lib/tools-message.d.ts +1 -0
  22. package/dist/lib/tools-message.js +3 -1
  23. package/dist/scripts/async-runner.mjs +5 -19
  24. package/docs/README.md +1 -0
  25. package/docs/actor-inspector.md +21 -5
  26. package/docs/async-runs.md +22 -7
  27. package/docs/command-templates.md +5 -4
  28. package/docs/inspection.md +83 -0
  29. package/docs/recipe-library.md +1 -1
  30. package/docs/template-recipes.md +225 -66
  31. package/docs/tool-registry.md +24 -3
  32. package/index.ts +1 -1
  33. package/lib/extension-runtime.ts +2 -2
  34. package/lib/inspector-overlay.ts +161 -33
  35. package/lib/limits.ts +1 -0
  36. package/lib/observability.ts +2 -1
  37. package/lib/runs-artifacts.ts +34 -4
  38. package/lib/session-evidence.ts +7 -2
  39. package/lib/state-readers.ts +45 -3
  40. package/lib/tools-access.ts +26 -12
  41. package/lib/tools-inspect.ts +9 -5
  42. package/lib/tools-message.ts +8 -1
  43. package/package.json +3 -3
  44. package/scripts/async-runner.mjs +5 -19
@@ -10,6 +10,7 @@ import * as path from "node:path";
10
10
  import type { Theme } from "@earendil-works/pi-coding-agent";
11
11
  import {
12
12
  matchesKey,
13
+ sliceByColumn,
13
14
  truncateToWidth,
14
15
  visibleWidth,
15
16
  type TUI,
@@ -30,6 +31,11 @@ export type ActorInspectorTab = "recipe" | "trace" | "control";
30
31
  type InspectorFocus = "runs" | "tabs" | "document" | "list" | "detail" | "select";
31
32
  type SelectorMode = "run" | "source";
32
33
 
34
+ interface SelectorOption {
35
+ content: string;
36
+ preserveColors?: boolean;
37
+ }
38
+
33
39
  export interface ActorInspectorActionResult {
34
40
  ok: boolean;
35
41
  message: string;
@@ -73,6 +79,7 @@ export class ActorInspectorOverlay {
73
79
  private readonly theme: Theme;
74
80
  private readonly tui: TUI;
75
81
  private readonly refreshTimer: NodeJS.Timeout;
82
+ private contentSelectedRows = new Set<number>();
76
83
  private contentStripeIndices: number[] = [];
77
84
  private documentScroll = 0;
78
85
  private detailScroll = 0;
@@ -244,10 +251,11 @@ export class ActorInspectorOverlay {
244
251
  if (this.killConfirmation) return this.renderKillDialog(innerWidth);
245
252
 
246
253
  const lines = [this.border("╭", " Actor Inspector ", "╮", innerWidth)];
247
- lines.push(this.row(this.renderRunControl(run, runs.length - this.runIndex), innerWidth));
254
+ lines.push(this.row(this.renderRunControl(run, runs.length - this.runIndex - 1), innerWidth));
248
255
  lines.push(this.row(this.renderTabs(run), innerWidth));
249
256
  lines.push(this.border("├", "", "┤", innerWidth));
250
257
 
258
+ this.contentSelectedRows.clear();
251
259
  this.contentStripeIndices = [];
252
260
  let content = this.renderContent(run, innerWidth);
253
261
  if (this.feedback) {
@@ -257,6 +265,9 @@ export class ActorInspectorOverlay {
257
265
  ? "error"
258
266
  : "warning";
259
267
  content = [this.theme.fg(color, ` ${this.feedback.message}`), ...content];
268
+ this.contentSelectedRows = new Set(
269
+ [...this.contentSelectedRows].map((index) => index + 1),
270
+ );
260
271
  this.contentStripeIndices = [0, ...this.contentStripeIndices];
261
272
  }
262
273
  const viewportRows = this.contentViewportRows();
@@ -266,9 +277,27 @@ export class ActorInspectorOverlay {
266
277
  content[index] ?? "",
267
278
  innerWidth,
268
279
  this.contentStripeIndices[index] ?? index,
280
+ this.contentSelectedRows.has(index),
269
281
  ));
270
282
  }
271
283
  lines.push(this.footerBorder(this.renderKeyHints(), innerWidth));
284
+ if (this.focus === "select") {
285
+ const runSelector = this.selectorMode === "run";
286
+ const startRow = runSelector ? 2 : 3;
287
+ const horizontalOffset = runSelector ? 4 : 12;
288
+ const availableRows = Math.max(3, lines.length - startRow - 1);
289
+ const menu = this.renderSelector(run, availableRows);
290
+ for (const [offset, menuLine] of menu.entries()) {
291
+ const target = startRow + offset;
292
+ if (target >= lines.length - 1) break;
293
+ lines[target] = this.compositeMenuLine(
294
+ lines[target]!,
295
+ menuLine,
296
+ innerWidth,
297
+ horizontalOffset,
298
+ );
299
+ }
300
+ }
272
301
  return lines;
273
302
  }
274
303
 
@@ -428,10 +457,10 @@ export class ActorInspectorOverlay {
428
457
  private renderKillDialog(innerWidth: number): string[] {
429
458
  const confirmation = this.killConfirmation!;
430
459
  const cancel = this.killDialogChoice === "cancel"
431
- ? this.theme.bg("customMessageBg", this.theme.fg("accent", " Cancel "))
460
+ ? this.theme.bg("selectedBg", this.theme.fg("accent", " Cancel "))
432
461
  : this.theme.fg("muted", " Cancel ");
433
462
  const kill = this.killDialogChoice === "kill"
434
- ? this.theme.bg("customMessageBg", this.theme.fg("error", " Kill actor "))
463
+ ? this.theme.bg("selectedBg", this.theme.fg("error", " Kill actor "))
435
464
  : this.theme.fg("error", " Kill actor ");
436
465
  const body = [
437
466
  "",
@@ -464,23 +493,32 @@ export class ActorInspectorOverlay {
464
493
  const suffix = active ? this.theme.fg("accent", " → ") : " ";
465
494
  if (!run) {
466
495
  const empty = this.theme.fg("muted", `${prefix}Run: none${suffix}`);
467
- return this.focus === "runs" ? this.theme.bg("customMessageBg", empty) : empty;
496
+ return active ? this.theme.bg("selectedBg", empty) : empty;
468
497
  }
469
498
  const value = `${prefix}${this.theme.fg("muted", "Run: ")}${this.theme.fg("text", `#${sequence}`)} ${this.theme.fg("accent", run.run)} ${this.theme.fg(this.statusColor(run.status), run.status)}${suffix}`;
470
- return this.focus === "runs" ? this.theme.bg("customMessageBg", value) : value;
499
+ return active ? this.theme.bg("selectedBg", value) : value;
471
500
  }
472
501
 
473
502
  private renderTabs(run?: ActorInspector.ActorInspectorRunItem): string {
474
503
  const source = this.traceSource(run);
475
504
  return TABS.map((tab, index) => {
476
505
  const base = `${tab[0]!.toUpperCase()}${tab.slice(1)}`;
477
- const label = tab === "trace" && source !== "all" ? `${base} (${source})` : base;
506
+ const label = tab === "trace" && source !== "all" ? `${base}: ${source}` : base;
478
507
  const selected = index === this.tabIndex;
479
- const display = selected && this.focus !== "runs" ? `[ ${label} ]` : ` ${label} `;
480
- const value = ` ${display} `;
481
- if (!selected) return this.theme.fg("muted", value);
482
- const colored = this.theme.fg("accent", value);
483
- return this.focus === "tabs" ? this.theme.bg("customMessageBg", colored) : colored;
508
+ const bracketed = selected && this.focus !== "runs";
509
+ if (!selected) {
510
+ const display = bracketed ? `[ ${label} ]` : ` ${label} `;
511
+ return this.theme.fg("muted", ` ${display} `);
512
+ }
513
+ const coloredLabel = tab === "trace" && source !== "all"
514
+ ? `${this.theme.fg("accent", `${base}:`)} ${this.theme.fg("text", source)}`
515
+ : this.theme.fg("accent", label);
516
+ const colored = bracketed
517
+ ? ` ${this.theme.fg("accent", "[ ")}${coloredLabel}${this.theme.fg("accent", " ]")} `
518
+ : ` ${coloredLabel} `;
519
+ const active = this.focus === "tabs"
520
+ || (this.focus === "select" && this.selectorMode === "source");
521
+ return active ? this.theme.bg("selectedBg", colored) : colored;
484
522
  }).join(" ");
485
523
  }
486
524
 
@@ -489,7 +527,6 @@ export class ActorInspectorOverlay {
489
527
  width: number,
490
528
  ): string[] {
491
529
  if (!run) return [this.theme.fg("muted", " No owned actor runs")];
492
- if (this.focus === "select") return this.renderSelector(run);
493
530
  if (this.focus === "detail") return this.renderTraceDetail(width);
494
531
  if (this.tab === "trace") return this.renderTraceList(run);
495
532
  const stateDir = path.join(this.stateRoot, run.run);
@@ -509,31 +546,64 @@ export class ActorInspectorOverlay {
509
546
  );
510
547
  }
511
548
 
512
- private renderSelector(run: ActorInspector.ActorInspectorRunItem): string[] {
549
+ private renderSelector(
550
+ run: ActorInspector.ActorInspectorRunItem,
551
+ availableRows: number,
552
+ ): string[] {
513
553
  const options = this.selectorMode === "run"
514
- ? this.runs().map((item) => `run:${item.run} ${item.status}`)
515
- : this.traceSources(run).map((source) => `Trace source: ${source}`);
516
- const lines = this.renderMenuBox(options, this.selectorIndex);
517
- this.contentStripeIndices = lines.map(() => 0);
518
- return lines;
554
+ ? this.runSelectorOptions()
555
+ : this.traceSources(run).map((source) => ({
556
+ content: `${this.theme.fg("accent", "Trace:")} ${this.theme.fg("text", source)}`,
557
+ preserveColors: true,
558
+ }));
559
+ return this.renderMenuBox(options, this.selectorIndex, availableRows);
519
560
  }
520
561
 
521
- private renderMenuBox(options: string[], focusedIndex: number): string[] {
562
+ private runSelectorOptions(): SelectorOption[] {
563
+ const runs = this.runs();
564
+ const sequenceWidth = Math.max(
565
+ 2,
566
+ ...runs.map((_item, index) => `#${runs.length - index - 1}`.length),
567
+ );
568
+ const runWidth = Math.max(1, ...runs.map((item) => visibleWidth(item.run)));
569
+ return runs.map((item, index) => ({
570
+ content: [
571
+ this.theme.fg("text", this.fit(`#${runs.length - index - 1}`, sequenceWidth)),
572
+ this.theme.fg("accent", this.fit(item.run, runWidth)),
573
+ this.theme.fg(this.statusColor(item.status), item.status),
574
+ ].join(" "),
575
+ preserveColors: true,
576
+ }));
577
+ }
578
+
579
+ private renderMenuBox(
580
+ options: SelectorOption[],
581
+ focusedIndex: number,
582
+ availableRows: number,
583
+ ): string[] {
522
584
  if (options.length === 0) return [this.theme.fg("muted", " No options")];
523
- const visibleLimit = Math.max(1, Math.min(this.contentViewportRows(), options.length));
585
+ const visibleLimit = Math.max(
586
+ 1,
587
+ Math.min(availableRows - 2, options.length),
588
+ );
524
589
  const maxStart = Math.max(0, options.length - visibleLimit);
525
590
  const start = Math.max(0, Math.min(focusedIndex - Math.floor(visibleLimit / 2), maxStart));
526
591
  const visible = options.slice(start, start + visibleLimit);
527
- const width = Math.max(8, ...options.map((option) => visibleWidth(option) + 4));
592
+ const width = Math.max(8, ...options.map((option) => visibleWidth(option.content) + 4));
528
593
  const border = (left: string, right: string, marker = "") =>
529
594
  this.theme.fg("borderAccent", `${left}${marker}${"─".repeat(Math.max(0, width - visibleWidth(marker)))}${right}`);
530
595
  return [
531
596
  border("╭", "╮", start > 0 ? "↑" : ""),
532
597
  ...visible.map((option, offset) => {
533
598
  const index = start + offset;
534
- const row = this.fit(`${index === focusedIndex ? " ▶ " : " "}${option}`, width);
535
- const colored = index === focusedIndex ? this.theme.fg("accent", row) : row;
536
- const styled = index === focusedIndex ? this.theme.bg("customMessageBg", colored) : colored;
599
+ const marker = index === focusedIndex
600
+ ? this.theme.fg("accent", " ▶ ")
601
+ : " ";
602
+ const row = this.fit(`${marker}${option.content}`, width);
603
+ const colored = index === focusedIndex && !option.preserveColors
604
+ ? this.theme.fg("accent", row)
605
+ : row;
606
+ const styled = index === focusedIndex ? this.theme.bg("selectedBg", colored) : colored;
537
607
  return `${this.theme.fg("borderAccent", "│")}${styled}${this.theme.fg("borderAccent", "│")}`;
538
608
  }),
539
609
  border("╰", "╯", start + visible.length < options.length ? "↓" : ""),
@@ -562,6 +632,14 @@ export class ActorInspectorOverlay {
562
632
  this.contentStripeIndices = items
563
633
  .slice(start, start + viewportRows)
564
634
  .map((_item, offset) => start + offset);
635
+ const selectedViewportRow = banner.length + this.rowIndex - start;
636
+ if (
637
+ this.focus === "list" &&
638
+ selectedViewportRow >= banner.length &&
639
+ selectedViewportRow < viewportRows
640
+ ) {
641
+ this.contentSelectedRows.add(selectedViewportRow);
642
+ }
565
643
  return [...banner, ...items.slice(start, start + viewportRows - banner.length).map((item, offset) => {
566
644
  const index = start + offset;
567
645
  const detail = item.detail && typeof item.detail === "object"
@@ -575,7 +653,7 @@ export class ActorInspectorOverlay {
575
653
  const prefix = this.focus === "list" && index === this.rowIndex
576
654
  ? this.theme.fg("accent", " ▶ ")
577
655
  : " ";
578
- const row = `${prefix}${this.theme.fg("text", `#${items.length - index}`)} ${marker} ${this.theme.fg("muted", item.source)}/${this.theme.fg(item.level === "error" ? "error" : "accent", item.kind)} ${this.theme.fg("text", item.summary)}`;
656
+ const row = `${prefix}${this.theme.fg("text", `#${items.length - index - 1}`)} ${marker} ${this.theme.fg("muted", item.source)}/${this.theme.fg(item.level === "error" ? "error" : "accent", item.kind)} ${this.theme.fg("text", item.summary)}`;
579
657
  return this.focus === "list" && index === this.rowIndex
580
658
  ? this.theme.fg("accent", row)
581
659
  : row;
@@ -734,11 +812,24 @@ export class ActorInspectorOverlay {
734
812
  return [
735
813
  `${indent}[`,
736
814
  ...value.flatMap((item, index) => {
737
- const marker = `${indent} - #${index + 1}`;
815
+ const itemDepth = depth + 1;
816
+ const itemIndent = " ".repeat(itemDepth);
817
+ const marker = `#${index}`;
738
818
  const itemInline = this.inlineDocumentValue(item);
739
- return itemInline !== undefined
740
- ? [`${marker}: ${itemInline}`]
741
- : [marker, ...this.document(item, depth + 2)];
819
+ let lines: string[];
820
+ if (itemInline !== undefined) {
821
+ lines = [`${itemIndent}${marker}: ${itemInline}`];
822
+ } else if (item && typeof item === "object") {
823
+ const nested = this.document(item, itemDepth);
824
+ lines = [
825
+ `${itemIndent}${marker}: ${nested[0]!.slice(itemIndent.length)}`,
826
+ ...nested.slice(1),
827
+ ];
828
+ } else {
829
+ lines = this.labeledScalarLines(marker, item, itemDepth);
830
+ }
831
+ if (index < value.length - 1) lines[lines.length - 1] += ",";
832
+ return lines;
742
833
  }),
743
834
  `${indent}]`,
744
835
  ];
@@ -792,9 +883,13 @@ export class ActorInspectorOverlay {
792
883
  private styleDocumentLine(line: string): string {
793
884
  const indent = line.match(/^ */u)?.[0] ?? "";
794
885
  const content = line.slice(indent.length);
795
- if (/^- #\d+$/u.test(content) || content.endsWith(":")) {
886
+ if (content.endsWith(":")) {
796
887
  return `${indent}${this.theme.fg("accent", content)}`;
797
888
  }
889
+ const indexed = /^(#\d+:)(.*)$/u.exec(content);
890
+ if (indexed) {
891
+ return `${indent}${this.theme.fg("accent", indexed[1])}${this.theme.fg("text", indexed[2])}`;
892
+ }
798
893
  const labeled = /^([^:]+:)(.*)$/u.exec(content);
799
894
  if (labeled) {
800
895
  return `${indent}${this.theme.fg("muted", labeled[1])}${this.theme.fg("text", labeled[2])}`;
@@ -868,13 +963,46 @@ export class ActorInspectorOverlay {
868
963
  return `${this.theme.fg("borderAccent", `╰${prefix}`)}${content}${this.theme.fg("borderAccent", `${suffix}${fill}╯`)}`;
869
964
  }
870
965
 
966
+ private compositeMenuLine(
967
+ baseLine: string,
968
+ menuLine: string,
969
+ innerWidth: number,
970
+ horizontalOffset: number,
971
+ ): string {
972
+ const offset = Math.max(0, Math.min(horizontalOffset, innerWidth));
973
+ const menuWidth = Math.min(innerWidth - offset, visibleWidth(menuLine));
974
+ const baseInner = sliceByColumn(baseLine, 1, innerWidth, true);
975
+ const prefix = sliceByColumn(baseInner, 0, offset, true);
976
+ const suffixStart = offset + menuWidth;
977
+ const suffix = sliceByColumn(
978
+ baseInner,
979
+ suffixStart,
980
+ Math.max(0, innerWidth - suffixStart),
981
+ true,
982
+ );
983
+ const remainder = this.fit(suffix, innerWidth - suffixStart);
984
+ return `${this.theme.fg("borderAccent", "│")}${this.fit(prefix, offset)}${truncateToWidth(menuLine, menuWidth, "")}${remainder}${this.theme.fg("borderAccent", "│")}`;
985
+ }
986
+
871
987
  private stripeBackground(content: string, index: number): string {
872
988
  return index % 2 === 0 ? content : this.theme.bg("customMessageBg", content);
873
989
  }
874
990
 
875
- private stripedRow(content: string, width: number, index: number): string {
876
- const fitted = this.fit(content, width);
877
- return `${this.theme.fg("borderAccent", "│")}${this.stripeBackground(fitted, index)}${this.theme.fg("borderAccent", "│")}`;
991
+ private stripedRow(
992
+ content: string,
993
+ width: number,
994
+ index: number,
995
+ selected: boolean,
996
+ ): string {
997
+ if (!selected) {
998
+ const fitted = this.fit(content, width);
999
+ return `${this.theme.fg("borderAccent", "│")}${this.stripeBackground(fitted, index)}${this.theme.fg("borderAccent", "│")}`;
1000
+ }
1001
+ const selectedContent = truncateToWidth(`${content} `, width, "");
1002
+ const remainder = " ".repeat(
1003
+ Math.max(0, width - visibleWidth(selectedContent)),
1004
+ );
1005
+ return `${this.theme.fg("borderAccent", "│")}${this.theme.bg("selectedBg", selectedContent)}${this.stripeBackground(remainder, index)}${this.theme.fg("borderAccent", "│")}`;
878
1006
  }
879
1007
 
880
1008
  private row(content: string, width: number): string {
package/lib/limits.ts CHANGED
@@ -12,6 +12,7 @@ export const CONTROL_INPUT_MAX_BYTES = 380;
12
12
  export const CONTROL_WIRE_MAX_BYTES = 512;
13
13
  export const INSPECTOR_BODY_PREVIEW_CHARS = 320;
14
14
  export const DOCTOR_ACTION_PREVIEW_CHARS = 72;
15
+ export const SESSION_EVIDENCE_MAX_BYTES = 4 * 1024 * 1024;
15
16
  export const SESSION_EVIDENCE_MAX_TURNS = 100;
16
17
  export const SESSION_EVIDENCE_TEXT_CHARS = 4_000;
17
18
  export const SESSION_EVIDENCE_MAX_TOOL_CALLS = 100;
@@ -206,9 +206,9 @@ export function reconcileRunTerminalNotifications(input: {
206
206
  includeAttention: input.includeAttention,
207
207
  stateRoot: input.stateRoot,
208
208
  });
209
+ deliverRunTransitionNotifications(snapshot.transitions, input.sink, input.inFlight);
209
210
  if (input.includeAttention)
210
211
  deliverRunAttentionNotifications(snapshot.attentionEvents, input.sink);
211
- deliverRunTransitionNotifications(snapshot.transitions, input.sink, input.inFlight);
212
212
  pruneRunUiObservationState(input.state, snapshot);
213
213
  return snapshot;
214
214
  }
@@ -1193,6 +1193,7 @@ export function getRunAttentionNotificationType(
1193
1193
  }
1194
1194
 
1195
1195
  export function shouldNotifyRunAttentionEvent(event: RunAttentionEvent): boolean {
1196
+ if (event.kind === "command.done") return false;
1196
1197
  return event.attention === "notify" || event.attention === "followup";
1197
1198
  }
1198
1199
 
@@ -4,7 +4,12 @@
4
4
  */
5
5
 
6
6
  import { createHash } from "node:crypto";
7
- import { readFileSync } from "node:fs";
7
+ import {
8
+ closeSync,
9
+ fstatSync,
10
+ openSync,
11
+ readSync,
12
+ } from "node:fs";
8
13
 
9
14
  import { substituteCommandTemplateToken } from "./command-templates.ts";
10
15
 
@@ -22,6 +27,31 @@ export interface RunArtifactManifestEntry {
22
27
  size?: number;
23
28
  }
24
29
 
30
+ function hashArtifactFile(path: string): { sha256: string; size: number } {
31
+ const fd = openSync(path, "r");
32
+ try {
33
+ const size = fstatSync(fd).size;
34
+ const hash = createHash("sha256");
35
+ const chunk = Buffer.allocUnsafe(64 * 1024);
36
+ let position = 0;
37
+ while (position < size) {
38
+ const bytesRead = readSync(
39
+ fd,
40
+ chunk,
41
+ 0,
42
+ Math.min(chunk.byteLength, size - position),
43
+ position,
44
+ );
45
+ if (bytesRead === 0) break;
46
+ hash.update(chunk.subarray(0, bytesRead));
47
+ position += bytesRead;
48
+ }
49
+ return { sha256: hash.digest("hex"), size: position };
50
+ } finally {
51
+ closeSync(fd);
52
+ }
53
+ }
54
+
25
55
  export function resolveArtifactPaths(
26
56
  artifacts: Record<string, RunArtifactDeclaration> | undefined,
27
57
  values: Record<string, unknown>,
@@ -64,7 +94,7 @@ export function resolveArtifactManifest(
64
94
  typeof artifact === "string" ? { path: artifact } : artifact;
65
95
  if (!declaration?.path) continue;
66
96
  try {
67
- const content = readFileSync(declaration.path);
97
+ const hashed = hashArtifactFile(declaration.path);
68
98
  manifest[name] = {
69
99
  exists: true,
70
100
  ...(declaration.kind ? { kind: declaration.kind } : {}),
@@ -75,8 +105,8 @@ export function resolveArtifactManifest(
75
105
  ...(declaration.required !== undefined
76
106
  ? { required: declaration.required }
77
107
  : {}),
78
- sha256: createHash("sha256").update(content).digest("hex"),
79
- size: content.byteLength,
108
+ sha256: hashed.sha256,
109
+ size: hashed.size,
80
110
  };
81
111
  } catch {
82
112
  manifest[name] = {
@@ -55,6 +55,7 @@ export interface SessionEvidence {
55
55
  }
56
56
 
57
57
  export interface SessionEvidenceReadOptions {
58
+ maxBytes?: number;
58
59
  maxTextChars?: number;
59
60
  maxToolCalls?: number;
60
61
  maxTurns?: number;
@@ -195,6 +196,10 @@ export function readSessionEvidence(
195
196
  path: string,
196
197
  options: SessionEvidenceReadOptions = {},
197
198
  ): SessionEvidence {
199
+ const maxBytes = Math.max(
200
+ 1,
201
+ options.maxBytes ?? Limits.SESSION_EVIDENCE_MAX_BYTES,
202
+ );
198
203
  const maxTextChars = Math.max(
199
204
  1,
200
205
  options.maxTextChars ?? Limits.SESSION_EVIDENCE_TEXT_CHARS,
@@ -207,7 +212,7 @@ export function readSessionEvidence(
207
212
  1,
208
213
  options.maxTurns ?? Limits.SESSION_EVIDENCE_MAX_TURNS,
209
214
  );
210
- const read = readJsonlFileResilient<SessionEntry>(path);
215
+ const read = readJsonlFileResilient<SessionEntry>(path, { maxBytes });
211
216
  const diagnostics = [...read.diagnostics];
212
217
  const header = read.records.find((entry) => entry.type === "session");
213
218
  const branch = activeBranch(read.records, path, diagnostics);
@@ -296,7 +301,7 @@ export function readSessionEvidence(
296
301
  path,
297
302
  ...(header ? { session: asRecord(header) } : {}),
298
303
  totalTurns: turns.length,
299
- truncated: turns.length > visibleTurns.length,
304
+ truncated: read.truncated === true || turns.length > visibleTurns.length,
300
305
  turns: visibleTurns,
301
306
  };
302
307
  }
@@ -4,7 +4,13 @@
4
4
  * Owns best-effort JSON and JSONL parsing helpers for operator-facing state reads.
5
5
  */
6
6
 
7
- import { readFileSync } from "node:fs";
7
+ import {
8
+ closeSync,
9
+ fstatSync,
10
+ openSync,
11
+ readFileSync,
12
+ readSync,
13
+ } from "node:fs";
8
14
 
9
15
  export interface StateReadDiagnostic {
10
16
  line?: number;
@@ -20,6 +26,11 @@ export interface JsonReadResult<T> {
20
26
  export interface JsonlReadResult<T> {
21
27
  diagnostics: StateReadDiagnostic[];
22
28
  records: T[];
29
+ truncated?: boolean;
30
+ }
31
+
32
+ export interface JsonlReadOptions {
33
+ maxBytes?: number;
23
34
  }
24
35
 
25
36
  function isEnoent(error: unknown): boolean {
@@ -30,6 +41,31 @@ function diagnosticMessage(error: unknown): string {
30
41
  return error instanceof Error ? error.message : String(error);
31
42
  }
32
43
 
44
+ function readTextFileCapped(path: string, maxBytes: number | undefined): string {
45
+ if (maxBytes === undefined) return readFileSync(path, "utf8");
46
+ const limit = Math.max(0, Math.floor(maxBytes));
47
+ const fd = openSync(path, "r");
48
+ try {
49
+ const size = fstatSync(fd).size;
50
+ if (size > limit) {
51
+ throw Object.assign(
52
+ new Error(`file exceeds bounded read limit (${size} > ${limit} bytes)`),
53
+ { code: "EFBIG" },
54
+ );
55
+ }
56
+ const content = Buffer.allocUnsafe(size);
57
+ let offset = 0;
58
+ while (offset < size) {
59
+ const bytesRead = readSync(fd, content, offset, size - offset, offset);
60
+ if (bytesRead === 0) break;
61
+ offset += bytesRead;
62
+ }
63
+ return content.subarray(0, offset).toString("utf8");
64
+ } finally {
65
+ closeSync(fd);
66
+ }
67
+ }
68
+
33
69
  export function readJsonFileResilient<T>(path: string, fallback: T): JsonReadResult<T> {
34
70
  try {
35
71
  return {
@@ -50,11 +86,14 @@ export function readJsonFileResilient<T>(path: string, fallback: T): JsonReadRes
50
86
  }
51
87
  }
52
88
 
53
- export function readJsonlFileResilient<T>(path: string): JsonlReadResult<T> {
89
+ export function readJsonlFileResilient<T>(
90
+ path: string,
91
+ options: JsonlReadOptions = {},
92
+ ): JsonlReadResult<T> {
54
93
  try {
55
94
  const records: T[] = [];
56
95
  const diagnostics: StateReadDiagnostic[] = [];
57
- const lines = readFileSync(path, "utf8").split("\n");
96
+ const lines = readTextFileCapped(path, options.maxBytes).split("\n");
58
97
  for (const [index, line] of lines.entries()) {
59
98
  if (!line.trim()) continue;
60
99
  try {
@@ -78,6 +117,9 @@ export function readJsonlFileResilient<T>(path: string): JsonlReadResult<T> {
78
117
  },
79
118
  ],
80
119
  records: [],
120
+ ...((error as NodeJS.ErrnoException).code === "EFBIG"
121
+ ? { truncated: true }
122
+ : {}),
81
123
  };
82
124
  }
83
125
  }
@@ -17,8 +17,11 @@ export function getContextSessionId(ctx: unknown): string | undefined {
17
17
  export function requireContextSessionId(ctx: unknown, actor: string): string {
18
18
  const sessionId = getContextSessionId(ctx);
19
19
  if (!sessionId) {
20
- throw new Error(
21
- `${actor} requires a current coordinator session; use session:<id> or session:all for explicit session inventory.`,
20
+ throw Object.assign(
21
+ new Error(
22
+ `${actor} reason=session_unavailable requires a current coordinator session; retry from an active coordinator session.`,
23
+ ),
24
+ { reason: "session_unavailable" },
22
25
  );
23
26
  }
24
27
  return sessionId;
@@ -33,16 +36,13 @@ export function sessionMismatchError(input: {
33
36
  const ownerSession = input.expectedSession ?? "none";
34
37
  const currentSession = input.currentSession ?? "none";
35
38
  const actor = input.run ? `run:${input.run}` : (input.target ?? "session");
36
- const hintTarget = input.expectedSession
37
- ? `session:${input.expectedSession}`
38
- : "session:all";
39
39
  return Object.assign(
40
40
  new Error(
41
- `${actor} reason=session_mismatch owner_session=${ownerSession} current_session=${currentSession} hint=inspect_session:${input.expectedSession ?? "all"}`,
41
+ `${actor} reason=session_mismatch owner_session=${ownerSession} current_session=${currentSession} hint=inspect_runtime_runs`,
42
42
  ),
43
43
  {
44
44
  current_session: input.currentSession,
45
- hint: `inspect target=${hintTarget} view=status`,
45
+ hint: "inspect target=runtime view=runs",
46
46
  owner_session: input.expectedSession,
47
47
  reason: "session_mismatch",
48
48
  run: input.run,
@@ -51,18 +51,32 @@ export function sessionMismatchError(input: {
51
51
  );
52
52
  }
53
53
 
54
- export function assertRunAccessibleToContext(
54
+ export function assertRunStatusAccessibleToContext(
55
55
  runId: string,
56
+ status: Record<string, unknown>,
56
57
  ctx: unknown,
57
58
  ): Record<string, unknown> {
58
- const status = AsyncRuns.getRunStatus(runId);
59
- const sessionId = getContextSessionId(ctx);
60
- if (sessionId && status.ownerId && status.ownerId !== sessionId) {
59
+ const sessionId = requireContextSessionId(ctx, `run:${runId}`);
60
+ const ownerId = typeof status.ownerId === "string" && status.ownerId
61
+ ? status.ownerId
62
+ : undefined;
63
+ if (ownerId !== sessionId) {
61
64
  throw sessionMismatchError({
62
65
  currentSession: sessionId,
63
- expectedSession: String(status.ownerId),
66
+ expectedSession: ownerId,
64
67
  run: runId,
65
68
  });
66
69
  }
67
70
  return status;
68
71
  }
72
+
73
+ export function assertRunAccessibleToContext(
74
+ runId: string,
75
+ ctx: unknown,
76
+ ): Record<string, unknown> {
77
+ return assertRunStatusAccessibleToContext(
78
+ runId,
79
+ AsyncRuns.getRunStatus(runId),
80
+ ctx,
81
+ );
82
+ }
@@ -42,7 +42,7 @@ export interface InspectToolDeps<TContext = unknown> {
42
42
 
43
43
  function runtimeStatus(): Record<string, unknown> {
44
44
  return {
45
- automatic_review: process.env.PI_ACTORS_AUTOMATIC_REVIEW !== "off",
45
+ automatic_review: Paths.isAutomaticRecipeReviewEnabled(),
46
46
  run_root: Paths.getRunStateRoot(),
47
47
  state_schema: RuntimeIdentity.RUN_STATE_SCHEMA,
48
48
  version: RuntimeIdentity.getPackageVersion(),
@@ -54,7 +54,7 @@ function runtimeRuns(
54
54
  deps: InspectToolDeps,
55
55
  status: string | undefined,
56
56
  ): Record<string, unknown> {
57
- const session = ToolsAccess.getContextSessionId(ctx);
57
+ const session = ToolsAccess.requireContextSessionId(ctx, "runtime Run inventory");
58
58
  const listed = deps.listRuns
59
59
  ? deps.listRuns()
60
60
  : AsyncRuns.listRuns(undefined, status);
@@ -68,8 +68,8 @@ function runtimeRuns(
68
68
  return run;
69
69
  }
70
70
  })
71
- .filter((run) => !session || !run.ownerId || run.ownerId === session);
72
- return { ...(session ? { owner_session: session } : {}), runs };
71
+ .filter((run) => run.ownerId === session);
72
+ return { owner_session: session, runs };
73
73
  }
74
74
 
75
75
  function runtimeTriage(
@@ -463,7 +463,11 @@ function inspectRun(
463
463
  throw new Error("inspect run:<id> supports view=recipe, view=trace, or view=control.");
464
464
  }
465
465
  const status = deps.getRunStatus
466
- ? deps.getRunStatus(run)
466
+ ? ToolsAccess.assertRunStatusAccessibleToContext(
467
+ run,
468
+ deps.getRunStatus(run),
469
+ ctx,
470
+ )
467
471
  : ToolsAccess.assertRunAccessibleToContext(run, ctx);
468
472
  const stateDir = String(status.state_dir);
469
473
  if (view === "recipe") {