@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
package/AGENTS.md CHANGED
@@ -84,7 +84,7 @@ Canonical event:
84
84
  {"id":"…","ts":"…","kind":"…","summary":"…","data":{},"level":"info","attention":"notify"}
85
85
  ```
86
86
 
87
- Trace is a bounded retained suffix, not an audit archive. Every first-party writer must call `appendRunTraceEvent`; under the canonical token-owned lock it appends within both fixed bounds or atomically retains the newest suffix plus one cumulative warning-only `runtime.trace_compacted` marker. The marker means older history was discarded; terminal/result/execution/artifact files remain authoritative independently. Reads are newline-safe and order equal timestamps by same-source ordinal, fixed source rank, then stable id without exposing ordering metadata. Never write `trace.jsonl` directly. Persist durable state or an artifact before attention; `attention: "followup"` remains rare.
87
+ Trace is a bounded retained suffix, not an audit archive. Every first-party writer must call `appendRunTraceEvent`; under the canonical token-owned lock it appends within both fixed bounds or atomically retains the newest suffix plus one cumulative warning-only `runtime.trace_compacted` marker. The marker means older history was discarded; terminal/result/execution/artifact files remain authoritative independently. Reads are newline-safe and order equal timestamps by same-source ordinal, fixed source rank, then stable id without exposing ordering metadata. Never write `trace.jsonl` directly. Generic command lifecycle is Trace-only: `command.done` preserves complete bounded execution evidence but never requests or projects attention, and Recipe grammar has no command-completion delivery switch. Attention is an explicit semantic wake hint: persist durable state or an artifact first; `attention: "followup"` remains rare. Reconcile root terminal transitions before attention. One normal finite Run defaults to one automatic agent turn from its root terminal result; sequence, parallel, repeat, and import branches remain internal, while each separately launched Run owns a separate generation.
88
88
 
89
89
  ### Control
90
90
 
package/BACKLOG.md CHANGED
@@ -1,16 +1,6 @@
1
1
  # Project Backlog
2
2
 
3
- - [ ] `0.50.0 hardening`: Close the confirmed authorization, bounded-inspection, and operator-truth gaps from the v0.49.1 production-readiness review.
4
- - [ ] `Run boundary checkpoint`: Public Run operations remain owner-safe and bounded under missing identity and adversarial evidence sizes.
5
- - [ ] Make Run-specific `inspect` and `message` operations fail closed when the caller session identity is unavailable, keep runtime inventory owner-filtered, and add missing-context plus cross-owner regressions.
6
- - [ ] Bound session-evidence and artifact-manifest inspection before full-file materialization; use capped/streaming reads or hashing as appropriate and add adversarial-size regressions.
7
- - [ ] `Operator truth checkpoint`: Public status and recovery guidance exactly match supported runtime behavior.
8
- - [ ] Route automatic-review status through the canonical policy parser so `0`, `false`, and `off` are reported consistently without case sensitivity.
9
- - [ ] Replace the removed `session:<id>` and `session:all` recovery hints with supported inspection guidance.
10
- - [ ] Document exact `message target=run:<id>` examples for runtime-owned `kill`, `archive`, and `prune`, including their state and artifact-preservation constraints.
11
- - [ ] Pass focused boundary and operator-contract regressions plus full package validation before release.
12
-
13
- - [ ] `Future minor — Linux MPRIS media integration`: Expose the active `music-player/playback` singleton as one optional generation-fenced MPRIS2 player so GNOME and compatible desktop shells can show current media and native controls without making D-Bus a second playback authority; keep this feature outside the 0.49.2 and 0.50.0 cohorts.
3
+ - [ ] `Future minor — Linux MPRIS media integration`: Expose the active `music-player/playback` singleton as one optional generation-fenced MPRIS2 player so GNOME and compatible desktop shells can show current media and native controls without making D-Bus a second playback authority.
14
4
  - [ ] Publish `PlaybackStatus`, track metadata, duration, read-time position, volume, and supported capabilities under one stable session-scoped bus identity; disappear cleanly when the Run stops or its generation is replaced, and fail soft when the user D-Bus session is unavailable.
15
5
  - [ ] Map `Play`, `Pause`, `PlayPause`, `Next`, `Previous`, `Stop`, `Seek`, `SetPosition`, and `Volume` back into the existing generation-fenced music-player Control/helper contract rather than signaling the backend or editing Run state directly.
16
6
  - [ ] Validate deterministic D-Bus contract behavior plus a live GNOME smoke showing the media surface, metadata, progress, volume, and controls while preserving backend independence and exact Actor ownership.
package/CHANGELOG.md CHANGED
@@ -2,7 +2,23 @@
2
2
 
3
3
  > Each release keeps at most 8 outcome records of at most 512 characters.
4
4
 
5
- ## Unreleased
5
+ ## 0.51.0: Monotonic Run Follow-Ups
6
+
7
+ - `Root-Owned Follow-Up`: A normal finite Run now produces one automatic agent turn from its root terminal result. Sequence, parallel, repeat, and imported branches remain internal execution topology; each separately launched Run still owns a separate generation and terminal lifecycle.
8
+ - `Trace-Only Command Lifecycle`: Consolidates each command completion into one complete bounded `command.done` observation with level, captures, session provenance, artifacts, and execution evidence, but no attention. Legacy `command.done` attention remains non-projectable, and the removed Recipe-level delivery grammar stays removed.
9
+ - `Monotonic Reconciliation`: Delivers terminal transitions before explicit semantic attention, preserves silent Runs, synchronous stop behavior, generation fencing, terminal retry evidence, and semantic checkpoints, and removes branch exit-code heuristics that could wake the coordinator from stale process-level events.
10
+ - `Settled Pi Baseline`: Requires Pi and Pi TUI 0.84.4 or newer and schedules automatic Recipe review on `agent_settled`, after queued follow-ups, retries, and compaction complete. Source and packed-package regressions pin the lifecycle and minimum peer contract.
11
+
12
+ ## 0.50.0: Hardened Actor Baseline
13
+
14
+ - `Template Recipe Standard`: Rebuilt the Recipe authoring guide around one current-state contract: formats, identity, uniformly detailed file-level and command-node field tables, resolution precedence, imports, async/singleton lifecycle, Control, artifacts, runtime origins, provenance, authoring workflow, and validation. Removed migration history and legacy-reference narration from the normative document.
15
+ - `Documentation Contract Reconciliation`: Audited every human-facing document against package metadata, public schemas, implementation, maintained Skills, and tests. Added the canonical Inspect target/view matrix and exact-owner authorization boundary; completed installation, runtime triage, registered-tool lifecycle/schema, detached-build, and bounded-evidence guidance; corrected lifecycle ownership, command-template shapes, Inspector zero-based navigation/focus behavior, and remaining historical or ambiguous prose.
16
+ - `Owner-Safe Public Runs`: Run-specific Inspect and Control now require a live coordinator identity, reject ownerless and cross-owner state, and keep runtime Run inventory exact-owner filtered. Missing-session and mismatch failures provide supported runtime inspection guidance instead of removed session targets.
17
+ - `Bounded Evidence Inspection`: Session evidence rejects oversized JSONL before materialization with explicit truncation diagnostics, while artifact manifests compute exact size and SHA-256 through fixed-size chunks instead of loading complete artifacts into memory.
18
+ - `Operator Truth`: Runtime status now uses the canonical automatic-review policy for `0`, `false`, and case-insensitive `off`. Maintained guidance documents exact runtime-owned `kill`, `archive`, and artifact-preserving `prune` calls with their running/terminal state constraints.
19
+ - `Consistent Inspector Indexing`: Actor Inspector now uses zero-based numbering for Runs, Trace rows, and complex array items. Structured items render as accent keys such as `#0: {` on one line, removing list bullets and redundant opening-brace rows while preserving nested indentation, commas, wrapping, and object-label grammar.
20
+ - `Inspector Focus Semantics`: Alternating content stripes retain `customMessageBg`, while focused Run controls, tabs, Trace rows, selector options, and confirmation choices use `selectedBg`. Trace focus gains symmetric trailing padding and composes its selected span beside—rather than around—the independent full-row stripe, avoiding nested ANSI background resets.
21
+ - `Layered Inspector Selectors`: Run choices open directly below the focused Run control, and Trace-source choices below the focused tabs. Each menu is ANSI-aware composited over only its bounded rectangle while the underlying tabs, separator, documents, Trace rows, stripes, overlay height, and footer remain rendered and restore unchanged on close. The parent Run control or Trace tab retains `selectedBg` while its child selector is active. Trace-source choices use the compact `Trace: <source>` label, begin twelve cells into the base layer beneath the tab region, and preserve distinct label/value colors under selection. A non-`all` source projects onto the parent tab with the same colon grammar and value color while `Trace:`, including the colon, and its focus brackets retain their established tab accent. Run choices use aligned zero-based sequence, name, and status columns without a redundant `run:` prefix, preserve the same semantic status colors as the parent control, and begin four cells inside the outer frame. Both insets preserve—rather than blank—the underlying base-layer prefix before the composited menu rectangle.
6
22
 
7
23
  ## 0.49.2: Stale-Context Lifecycle Hotfix
8
24
 
package/README.md CHANGED
@@ -19,6 +19,8 @@ This topology does not require every task to become a subagent. Short work with
19
19
 
20
20
  ## Install
21
21
 
22
+ Requires Node.js 22.19.0 or newer and Pi 0.84.4 or newer.
23
+
22
24
  ```bash
23
25
  pi install npm:@llblab/pi-actors
24
26
  ```
@@ -59,7 +61,7 @@ Send one exact Control:
59
61
  }
60
62
  ```
61
63
 
62
- Run targets accept only actions declared by the captured Recipe. Runtime targets accept only reserved review actions:
64
+ Run targets accept Recipe-declared actor-local actions plus runtime-owned `kill`, `archive`, and `prune`, subject to Run state. Runtime targets accept only reserved review actions:
63
65
 
64
66
  ```text
65
67
  message target=runtime action=review.retry input={"scope":"draft"}
@@ -79,12 +81,14 @@ inspect target=run:test view=recipe
79
81
  inspect target=run:test view=trace source=lifecycle lines=40
80
82
  inspect target=run:test view=control
81
83
  inspect target=runtime view=status
84
+ inspect target=runtime view=runs status=failed
85
+ inspect target=runtime view=triage
82
86
  inspect target=recipes view=status
83
87
  inspect target=recipes view=doctor identity=music-player/playback
84
88
  inspect target=tool:my_tool view=status
85
89
  ```
86
90
 
87
- A Run exposes exactly `recipe`, `trace`, and `control` views.
91
+ A Run exposes exactly `recipe`, `trace`, and `control` views. Run-specific `inspect` and `message` require a current coordinator session whose id exactly matches the persisted non-empty Run owner; possessing `run:<id>` alone is not authorization. Missing-session, ownerless, and cross-owner access fails closed. Use `inspect target=runtime view=runs` for the current session's exact-owner inventory. See the complete [management inspection matrix](./docs/inspection.md).
88
92
 
89
93
  ### `register_tool`
90
94
 
@@ -147,11 +151,23 @@ String command-template leaves execute directly without shell interpretation. Us
147
151
 
148
152
  Trace fields are exact: `id`, `ts`, `kind`, and optional `summary`, `data`, `level`, `attention`. Address, sender, recipient, reply, and routing fields fail validation. Trace is a bounded retained suffix, not an audit archive: the canonical authority appends within 2,048 events and 4 MiB or atomically keeps the newest suffix plus one warning-only `runtime.trace_compacted` marker. That marker means older history was discarded; `result.json`, `execution.json`, terminal state, and declared artifacts retain their own authority. `inspect view=trace` reports whether retained history is complete. Reads are newline-safe and deterministic; first-party scripts never write this file directly.
149
153
 
150
- Attention is a live wake hint, not a durable queue: persist recovery state or an artifact first. Use `attention: "notify"` for visible status and `attention: "followup"` only when the coordinator needs semantic follow-up context; compaction may discard older hints. Store large evidence in artifacts or bounded execution captures.
154
+ Generic command lifecycle is Trace-only: runner-owned `command.done` records preserve completion and execution evidence but never request or project attention. There is no Recipe-level command-completion delivery switch. Attention is an explicit semantic opt-in and a live wake hint, not a durable queue: persist recovery state or an artifact first, use `attention: "notify"` for visible status and `attention: "followup"` only when the coordinator needs semantic follow-up context, and expect compaction to discard older hints. Store large evidence in artifacts or bounded execution captures.
155
+
156
+ By default, one normal finite Run produces one automatic agent turn from its root terminal result. Sequence, parallel, repeat, and imported branches remain internal to that Run and do not create branch-level turns; each separately launched Run owns its own generation and terminal follow-up.
151
157
 
152
158
  ## Control
153
159
 
154
- Controls persist to `controls.jsonl` before transport. One token-owned lock rejects a 65th pending Control or 1 MiB rewrite before admission, fails closed on malformed or stale-generation evidence, and atomically admits one record. Canonical transitions retain a 128-terminal tail, expected-state fencing, and 4 KiB errors. Admitted nonterminal Controls never expire automatically. Every record carries immutable `run_instance_id`. `inspect view=control` reports pending capacity, saturation, stale work, journal bytes, and bounded diagnostics. If a stuck Run is saturated, use runtime-owned `kill`; it bypasses actor-local capacity and creates no synthetic Control.
160
+ Controls persist to `controls.jsonl` before transport. One token-owned lock rejects a 65th pending Control or 1 MiB rewrite before admission, fails closed on malformed or stale-generation evidence, and atomically admits one record. Canonical transitions retain a 128-terminal tail, expected-state fencing, and 4 KiB errors. Admitted nonterminal Controls never expire automatically. Every record carries immutable `run_instance_id`. `inspect view=control` reports pending capacity, saturation, stale work, journal bytes, and bounded diagnostics.
161
+
162
+ Runtime-owned lifecycle and retention actions use the same `message` tool:
163
+
164
+ ```text
165
+ message target=run:<id> action=kill
166
+ message target=run:<id> action=archive
167
+ message target=run:<id> action=prune input={"preserve_artifacts":true}
168
+ ```
169
+
170
+ `kill` accepts only a currently running owned generation, bypasses actor-local capacity, and creates no synthetic Control. `archive` and `prune` accept only terminal owned Runs. Archive moves the entire Run state directory and leaves a tombstone. Prune removes Run state; declared existing artifacts survive only when `preserve_artifacts` is explicitly true, in which case they are copied to retained artifact storage before removal.
155
171
 
156
172
  Long-lived services publish `control-endpoint.json` only when ready:
157
173
 
@@ -222,6 +238,8 @@ npm run validate
222
238
  npm run test:preservation
223
239
  ```
224
240
 
241
+ The build produces the JavaScript runtime used by detached Actor processes in npm installations. Pi can load a TypeScript extension entrypoint, but standalone Node processes cannot type-strip modules under `node_modules`; keeping compiled Run modules preserves process isolation without a runtime TypeScript loader.
242
+
225
243
  See the [documentation index](./docs/README.md), [Run lifecycle](./docs/async-runs.md), and [Recipe library](./docs/recipe-library.md).
226
244
 
227
245
  Project context: [AGENTS.md](./AGENTS.md) · [BACKLOG.md](./BACKLOG.md) · [CHANGELOG.md](./CHANGELOG.md).
package/dist/index.js CHANGED
@@ -9,7 +9,7 @@ export default function toolRegistryExtension(pi) {
9
9
  const runtime = ExtensionRuntime.createActorExtensionRuntime(pi);
10
10
  pi.on("resources_discover", async () => runtime.discoverResources(import.meta.url));
11
11
  pi.on("session_start", async (_event, ctx) => runtime.onSessionStart(ctx));
12
- pi.on("agent_end", async (_event, ctx) => runtime.onAgentEnd(ctx));
12
+ pi.on("agent_settled", async (_event, ctx) => runtime.onAgentSettled(ctx));
13
13
  pi.on("session_shutdown", async (event, ctx) => runtime.onSessionShutdown(event.reason, ctx));
14
14
  pi.on("before_agent_start", async (event, ctx) => runtime.beforeAgentStart(event.systemPrompt, event.systemPromptOptions.skills ?? [], ctx));
15
15
  InspectorCommand.registerActorInspectorCommand(pi, runtime.getRunOwnerId);
@@ -13,7 +13,7 @@ export interface ActorExtensionRuntime {
13
13
  skillPaths: string[];
14
14
  } | undefined;
15
15
  getRunOwnerId(ctx: Pi.ExtensionContext): string;
16
- onAgentEnd(ctx: Pi.ExtensionContext): void;
16
+ onAgentSettled(ctx: Pi.ExtensionContext): void;
17
17
  onSessionShutdown(reason: string, ctx: Pi.ExtensionContext): void;
18
18
  onSessionStart(ctx: Pi.ExtensionContext): Promise<void>;
19
19
  registerCoreTools(): void;
@@ -112,7 +112,7 @@ export function createActorExtensionRuntime(pi) {
112
112
  return skillPaths.length > 0 ? { skillPaths } : undefined;
113
113
  },
114
114
  getRunOwnerId,
115
- onAgentEnd(ctx) {
115
+ onAgentSettled(ctx) {
116
116
  if (activeRunContext === ctx)
117
117
  automaticReview.schedule();
118
118
  },
@@ -32,6 +32,7 @@ export declare class ActorInspectorOverlay {
32
32
  private readonly theme;
33
33
  private readonly tui;
34
34
  private readonly refreshTimer;
35
+ private contentSelectedRows;
35
36
  private contentStripeIndices;
36
37
  private documentScroll;
37
38
  private detailScroll;
@@ -71,6 +72,7 @@ export declare class ActorInspectorOverlay {
71
72
  private renderTabs;
72
73
  private renderContent;
73
74
  private renderSelector;
75
+ private runSelectorOptions;
74
76
  private renderMenuBox;
75
77
  private renderTraceList;
76
78
  private renderTraceDetail;
@@ -91,6 +93,7 @@ export declare class ActorInspectorOverlay {
91
93
  private renderKeyHints;
92
94
  private statusColor;
93
95
  private footerBorder;
96
+ private compositeMenuLine;
94
97
  private stripeBackground;
95
98
  private stripedRow;
96
99
  private row;
@@ -5,7 +5,7 @@
5
5
  */
6
6
  import { readFileSync, statSync } from "node:fs";
7
7
  import * as path from "node:path";
8
- import { matchesKey, truncateToWidth, visibleWidth, } from "@earendil-works/pi-tui";
8
+ import { matchesKey, sliceByColumn, truncateToWidth, visibleWidth, } from "@earendil-works/pi-tui";
9
9
  import * as ActorInspector from "./inspector.js";
10
10
  import * as ControlProjection from "./control-projection.js";
11
11
  import * as RunsControls from "./runs-controls.js";
@@ -35,6 +35,7 @@ export class ActorInspectorOverlay {
35
35
  theme;
36
36
  tui;
37
37
  refreshTimer;
38
+ contentSelectedRows = new Set();
38
39
  contentStripeIndices = [];
39
40
  documentScroll = 0;
40
41
  detailScroll = 0;
@@ -219,9 +220,10 @@ export class ActorInspectorOverlay {
219
220
  if (this.killConfirmation)
220
221
  return this.renderKillDialog(innerWidth);
221
222
  const lines = [this.border("╭", " Actor Inspector ", "╮", innerWidth)];
222
- lines.push(this.row(this.renderRunControl(run, runs.length - this.runIndex), innerWidth));
223
+ lines.push(this.row(this.renderRunControl(run, runs.length - this.runIndex - 1), innerWidth));
223
224
  lines.push(this.row(this.renderTabs(run), innerWidth));
224
225
  lines.push(this.border("├", "", "┤", innerWidth));
226
+ this.contentSelectedRows.clear();
225
227
  this.contentStripeIndices = [];
226
228
  let content = this.renderContent(run, innerWidth);
227
229
  if (this.feedback) {
@@ -231,14 +233,28 @@ export class ActorInspectorOverlay {
231
233
  ? "error"
232
234
  : "warning";
233
235
  content = [this.theme.fg(color, ` ${this.feedback.message}`), ...content];
236
+ this.contentSelectedRows = new Set([...this.contentSelectedRows].map((index) => index + 1));
234
237
  this.contentStripeIndices = [0, ...this.contentStripeIndices];
235
238
  }
236
239
  const viewportRows = this.contentViewportRows();
237
240
  content = content.slice(0, viewportRows);
238
241
  for (let index = 0; index < viewportRows; index += 1) {
239
- lines.push(this.stripedRow(content[index] ?? "", innerWidth, this.contentStripeIndices[index] ?? index));
242
+ lines.push(this.stripedRow(content[index] ?? "", innerWidth, this.contentStripeIndices[index] ?? index, this.contentSelectedRows.has(index)));
240
243
  }
241
244
  lines.push(this.footerBorder(this.renderKeyHints(), innerWidth));
245
+ if (this.focus === "select") {
246
+ const runSelector = this.selectorMode === "run";
247
+ const startRow = runSelector ? 2 : 3;
248
+ const horizontalOffset = runSelector ? 4 : 12;
249
+ const availableRows = Math.max(3, lines.length - startRow - 1);
250
+ const menu = this.renderSelector(run, availableRows);
251
+ for (const [offset, menuLine] of menu.entries()) {
252
+ const target = startRow + offset;
253
+ if (target >= lines.length - 1)
254
+ break;
255
+ lines[target] = this.compositeMenuLine(lines[target], menuLine, innerWidth, horizontalOffset);
256
+ }
257
+ }
242
258
  return lines;
243
259
  }
244
260
  get tab() {
@@ -399,10 +415,10 @@ export class ActorInspectorOverlay {
399
415
  renderKillDialog(innerWidth) {
400
416
  const confirmation = this.killConfirmation;
401
417
  const cancel = this.killDialogChoice === "cancel"
402
- ? this.theme.bg("customMessageBg", this.theme.fg("accent", " Cancel "))
418
+ ? this.theme.bg("selectedBg", this.theme.fg("accent", " Cancel "))
403
419
  : this.theme.fg("muted", " Cancel ");
404
420
  const kill = this.killDialogChoice === "kill"
405
- ? this.theme.bg("customMessageBg", this.theme.fg("error", " Kill actor "))
421
+ ? this.theme.bg("selectedBg", this.theme.fg("error", " Kill actor "))
406
422
  : this.theme.fg("error", " Kill actor ");
407
423
  const body = [
408
424
  "",
@@ -433,30 +449,36 @@ export class ActorInspectorOverlay {
433
449
  const suffix = active ? this.theme.fg("accent", " → ") : " ";
434
450
  if (!run) {
435
451
  const empty = this.theme.fg("muted", `${prefix}Run: none${suffix}`);
436
- return this.focus === "runs" ? this.theme.bg("customMessageBg", empty) : empty;
452
+ return active ? this.theme.bg("selectedBg", empty) : empty;
437
453
  }
438
454
  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}`;
439
- return this.focus === "runs" ? this.theme.bg("customMessageBg", value) : value;
455
+ return active ? this.theme.bg("selectedBg", value) : value;
440
456
  }
441
457
  renderTabs(run) {
442
458
  const source = this.traceSource(run);
443
459
  return TABS.map((tab, index) => {
444
460
  const base = `${tab[0].toUpperCase()}${tab.slice(1)}`;
445
- const label = tab === "trace" && source !== "all" ? `${base} (${source})` : base;
461
+ const label = tab === "trace" && source !== "all" ? `${base}: ${source}` : base;
446
462
  const selected = index === this.tabIndex;
447
- const display = selected && this.focus !== "runs" ? `[ ${label} ]` : ` ${label} `;
448
- const value = ` ${display} `;
449
- if (!selected)
450
- return this.theme.fg("muted", value);
451
- const colored = this.theme.fg("accent", value);
452
- return this.focus === "tabs" ? this.theme.bg("customMessageBg", colored) : colored;
463
+ const bracketed = selected && this.focus !== "runs";
464
+ if (!selected) {
465
+ const display = bracketed ? `[ ${label} ]` : ` ${label} `;
466
+ return this.theme.fg("muted", ` ${display} `);
467
+ }
468
+ const coloredLabel = tab === "trace" && source !== "all"
469
+ ? `${this.theme.fg("accent", `${base}:`)} ${this.theme.fg("text", source)}`
470
+ : this.theme.fg("accent", label);
471
+ const colored = bracketed
472
+ ? ` ${this.theme.fg("accent", "[ ")}${coloredLabel}${this.theme.fg("accent", " ]")} `
473
+ : ` ${coloredLabel} `;
474
+ const active = this.focus === "tabs"
475
+ || (this.focus === "select" && this.selectorMode === "source");
476
+ return active ? this.theme.bg("selectedBg", colored) : colored;
453
477
  }).join(" ");
454
478
  }
455
479
  renderContent(run, width) {
456
480
  if (!run)
457
481
  return [this.theme.fg("muted", " No owned actor runs")];
458
- if (this.focus === "select")
459
- return this.renderSelector(run);
460
482
  if (this.focus === "detail")
461
483
  return this.renderTraceDetail(width);
462
484
  if (this.tab === "trace")
@@ -471,30 +493,49 @@ export class ActorInspectorOverlay {
471
493
  this.contentStripeIndices = projection.stripes.slice(this.documentScroll, this.documentScroll + this.contentViewportRows());
472
494
  return projection.lines.slice(this.documentScroll, this.documentScroll + this.contentViewportRows());
473
495
  }
474
- renderSelector(run) {
496
+ renderSelector(run, availableRows) {
475
497
  const options = this.selectorMode === "run"
476
- ? this.runs().map((item) => `run:${item.run} ${item.status}`)
477
- : this.traceSources(run).map((source) => `Trace source: ${source}`);
478
- const lines = this.renderMenuBox(options, this.selectorIndex);
479
- this.contentStripeIndices = lines.map(() => 0);
480
- return lines;
481
- }
482
- renderMenuBox(options, focusedIndex) {
498
+ ? this.runSelectorOptions()
499
+ : this.traceSources(run).map((source) => ({
500
+ content: `${this.theme.fg("accent", "Trace:")} ${this.theme.fg("text", source)}`,
501
+ preserveColors: true,
502
+ }));
503
+ return this.renderMenuBox(options, this.selectorIndex, availableRows);
504
+ }
505
+ runSelectorOptions() {
506
+ const runs = this.runs();
507
+ const sequenceWidth = Math.max(2, ...runs.map((_item, index) => `#${runs.length - index - 1}`.length));
508
+ const runWidth = Math.max(1, ...runs.map((item) => visibleWidth(item.run)));
509
+ return runs.map((item, index) => ({
510
+ content: [
511
+ this.theme.fg("text", this.fit(`#${runs.length - index - 1}`, sequenceWidth)),
512
+ this.theme.fg("accent", this.fit(item.run, runWidth)),
513
+ this.theme.fg(this.statusColor(item.status), item.status),
514
+ ].join(" "),
515
+ preserveColors: true,
516
+ }));
517
+ }
518
+ renderMenuBox(options, focusedIndex, availableRows) {
483
519
  if (options.length === 0)
484
520
  return [this.theme.fg("muted", " No options")];
485
- const visibleLimit = Math.max(1, Math.min(this.contentViewportRows(), options.length));
521
+ const visibleLimit = Math.max(1, Math.min(availableRows - 2, options.length));
486
522
  const maxStart = Math.max(0, options.length - visibleLimit);
487
523
  const start = Math.max(0, Math.min(focusedIndex - Math.floor(visibleLimit / 2), maxStart));
488
524
  const visible = options.slice(start, start + visibleLimit);
489
- const width = Math.max(8, ...options.map((option) => visibleWidth(option) + 4));
525
+ const width = Math.max(8, ...options.map((option) => visibleWidth(option.content) + 4));
490
526
  const border = (left, right, marker = "") => this.theme.fg("borderAccent", `${left}${marker}${"─".repeat(Math.max(0, width - visibleWidth(marker)))}${right}`);
491
527
  return [
492
528
  border("╭", "╮", start > 0 ? "↑" : ""),
493
529
  ...visible.map((option, offset) => {
494
530
  const index = start + offset;
495
- const row = this.fit(`${index === focusedIndex ? " ▶ " : " "}${option}`, width);
496
- const colored = index === focusedIndex ? this.theme.fg("accent", row) : row;
497
- const styled = index === focusedIndex ? this.theme.bg("customMessageBg", colored) : colored;
531
+ const marker = index === focusedIndex
532
+ ? this.theme.fg("accent", " ▶ ")
533
+ : " ";
534
+ const row = this.fit(`${marker}${option.content}`, width);
535
+ const colored = index === focusedIndex && !option.preserveColors
536
+ ? this.theme.fg("accent", row)
537
+ : row;
538
+ const styled = index === focusedIndex ? this.theme.bg("selectedBg", colored) : colored;
498
539
  return `${this.theme.fg("borderAccent", "│")}${styled}${this.theme.fg("borderAccent", "│")}`;
499
540
  }),
500
541
  border("╰", "╯", start + visible.length < options.length ? "↓" : ""),
@@ -523,6 +564,12 @@ export class ActorInspectorOverlay {
523
564
  this.contentStripeIndices = items
524
565
  .slice(start, start + viewportRows)
525
566
  .map((_item, offset) => start + offset);
567
+ const selectedViewportRow = banner.length + this.rowIndex - start;
568
+ if (this.focus === "list" &&
569
+ selectedViewportRow >= banner.length &&
570
+ selectedViewportRow < viewportRows) {
571
+ this.contentSelectedRows.add(selectedViewportRow);
572
+ }
526
573
  return [...banner, ...items.slice(start, start + viewportRows - banner.length).map((item, offset) => {
527
574
  const index = start + offset;
528
575
  const detail = item.detail && typeof item.detail === "object"
@@ -536,7 +583,7 @@ export class ActorInspectorOverlay {
536
583
  const prefix = this.focus === "list" && index === this.rowIndex
537
584
  ? this.theme.fg("accent", " ▶ ")
538
585
  : " ";
539
- 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)}`;
586
+ 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)}`;
540
587
  return this.focus === "list" && index === this.rowIndex
541
588
  ? this.theme.fg("accent", row)
542
589
  : row;
@@ -676,11 +723,27 @@ export class ActorInspectorOverlay {
676
723
  return [
677
724
  `${indent}[`,
678
725
  ...value.flatMap((item, index) => {
679
- const marker = `${indent} - #${index + 1}`;
726
+ const itemDepth = depth + 1;
727
+ const itemIndent = " ".repeat(itemDepth);
728
+ const marker = `#${index}`;
680
729
  const itemInline = this.inlineDocumentValue(item);
681
- return itemInline !== undefined
682
- ? [`${marker}: ${itemInline}`]
683
- : [marker, ...this.document(item, depth + 2)];
730
+ let lines;
731
+ if (itemInline !== undefined) {
732
+ lines = [`${itemIndent}${marker}: ${itemInline}`];
733
+ }
734
+ else if (item && typeof item === "object") {
735
+ const nested = this.document(item, itemDepth);
736
+ lines = [
737
+ `${itemIndent}${marker}: ${nested[0].slice(itemIndent.length)}`,
738
+ ...nested.slice(1),
739
+ ];
740
+ }
741
+ else {
742
+ lines = this.labeledScalarLines(marker, item, itemDepth);
743
+ }
744
+ if (index < value.length - 1)
745
+ lines[lines.length - 1] += ",";
746
+ return lines;
684
747
  }),
685
748
  `${indent}]`,
686
749
  ];
@@ -731,9 +794,13 @@ export class ActorInspectorOverlay {
731
794
  styleDocumentLine(line) {
732
795
  const indent = line.match(/^ */u)?.[0] ?? "";
733
796
  const content = line.slice(indent.length);
734
- if (/^- #\d+$/u.test(content) || content.endsWith(":")) {
797
+ if (content.endsWith(":")) {
735
798
  return `${indent}${this.theme.fg("accent", content)}`;
736
799
  }
800
+ const indexed = /^(#\d+:)(.*)$/u.exec(content);
801
+ if (indexed) {
802
+ return `${indent}${this.theme.fg("accent", indexed[1])}${this.theme.fg("text", indexed[2])}`;
803
+ }
737
804
  const labeled = /^([^:]+:)(.*)$/u.exec(content);
738
805
  if (labeled) {
739
806
  return `${indent}${this.theme.fg("muted", labeled[1])}${this.theme.fg("text", labeled[2])}`;
@@ -799,12 +866,27 @@ export class ActorInspectorOverlay {
799
866
  const fill = "─".repeat(Math.max(0, width - visibleWidth(prefix) - visibleWidth(suffix) - visibleWidth(content)));
800
867
  return `${this.theme.fg("borderAccent", `╰${prefix}`)}${content}${this.theme.fg("borderAccent", `${suffix}${fill}╯`)}`;
801
868
  }
869
+ compositeMenuLine(baseLine, menuLine, innerWidth, horizontalOffset) {
870
+ const offset = Math.max(0, Math.min(horizontalOffset, innerWidth));
871
+ const menuWidth = Math.min(innerWidth - offset, visibleWidth(menuLine));
872
+ const baseInner = sliceByColumn(baseLine, 1, innerWidth, true);
873
+ const prefix = sliceByColumn(baseInner, 0, offset, true);
874
+ const suffixStart = offset + menuWidth;
875
+ const suffix = sliceByColumn(baseInner, suffixStart, Math.max(0, innerWidth - suffixStart), true);
876
+ const remainder = this.fit(suffix, innerWidth - suffixStart);
877
+ return `${this.theme.fg("borderAccent", "│")}${this.fit(prefix, offset)}${truncateToWidth(menuLine, menuWidth, "")}${remainder}${this.theme.fg("borderAccent", "│")}`;
878
+ }
802
879
  stripeBackground(content, index) {
803
880
  return index % 2 === 0 ? content : this.theme.bg("customMessageBg", content);
804
881
  }
805
- stripedRow(content, width, index) {
806
- const fitted = this.fit(content, width);
807
- return `${this.theme.fg("borderAccent", "│")}${this.stripeBackground(fitted, index)}${this.theme.fg("borderAccent", "│")}`;
882
+ stripedRow(content, width, index, selected) {
883
+ if (!selected) {
884
+ const fitted = this.fit(content, width);
885
+ return `${this.theme.fg("borderAccent", "│")}${this.stripeBackground(fitted, index)}${this.theme.fg("borderAccent", "│")}`;
886
+ }
887
+ const selectedContent = truncateToWidth(`${content} `, width, "");
888
+ const remainder = " ".repeat(Math.max(0, width - visibleWidth(selectedContent)));
889
+ return `${this.theme.fg("borderAccent", "│")}${this.theme.bg("selectedBg", selectedContent)}${this.stripeBackground(remainder, index)}${this.theme.fg("borderAccent", "│")}`;
808
890
  }
809
891
  row(content, width) {
810
892
  return `${this.theme.fg("borderAccent", "│")}${this.fit(content, width)}${this.theme.fg("borderAccent", "│")}`;
@@ -11,6 +11,7 @@ export declare const CONTROL_INPUT_MAX_BYTES = 380;
11
11
  export declare const CONTROL_WIRE_MAX_BYTES = 512;
12
12
  export declare const INSPECTOR_BODY_PREVIEW_CHARS = 320;
13
13
  export declare const DOCTOR_ACTION_PREVIEW_CHARS = 72;
14
+ export declare const SESSION_EVIDENCE_MAX_BYTES: number;
14
15
  export declare const SESSION_EVIDENCE_MAX_TURNS = 100;
15
16
  export declare const SESSION_EVIDENCE_TEXT_CHARS = 4000;
16
17
  export declare const SESSION_EVIDENCE_MAX_TOOL_CALLS = 100;
@@ -11,6 +11,7 @@ export const CONTROL_INPUT_MAX_BYTES = 380;
11
11
  export const CONTROL_WIRE_MAX_BYTES = 512;
12
12
  export const INSPECTOR_BODY_PREVIEW_CHARS = 320;
13
13
  export const DOCTOR_ACTION_PREVIEW_CHARS = 72;
14
+ export const SESSION_EVIDENCE_MAX_BYTES = 4 * 1024 * 1024;
14
15
  export const SESSION_EVIDENCE_MAX_TURNS = 100;
15
16
  export const SESSION_EVIDENCE_TEXT_CHARS = 4_000;
16
17
  export const SESSION_EVIDENCE_MAX_TOOL_CALLS = 100;
@@ -75,9 +75,9 @@ export function reconcileRunTerminalNotifications(input) {
75
75
  includeAttention: input.includeAttention,
76
76
  stateRoot: input.stateRoot,
77
77
  });
78
+ deliverRunTransitionNotifications(snapshot.transitions, input.sink, input.inFlight);
78
79
  if (input.includeAttention)
79
80
  deliverRunAttentionNotifications(snapshot.attentionEvents, input.sink);
80
- deliverRunTransitionNotifications(snapshot.transitions, input.sink, input.inFlight);
81
81
  pruneRunUiObservationState(input.state, snapshot);
82
82
  return snapshot;
83
83
  }
@@ -838,6 +838,8 @@ export function getRunAttentionNotificationType(event) {
838
838
  return event.level;
839
839
  }
840
840
  export function shouldNotifyRunAttentionEvent(event) {
841
+ if (event.kind === "command.done")
842
+ return false;
841
843
  return event.attention === "notify" || event.attention === "followup";
842
844
  }
843
845
  export function shouldSendRunAttentionFollowUp(event) {
@@ -3,8 +3,28 @@
3
3
  * Owns: artifact path template expansion and filesystem-backed artifact metadata.
4
4
  */
5
5
  import { createHash } from "node:crypto";
6
- import { readFileSync } from "node:fs";
6
+ import { closeSync, fstatSync, openSync, readSync, } from "node:fs";
7
7
  import { substituteCommandTemplateToken } from "./command-templates.js";
8
+ function hashArtifactFile(path) {
9
+ const fd = openSync(path, "r");
10
+ try {
11
+ const size = fstatSync(fd).size;
12
+ const hash = createHash("sha256");
13
+ const chunk = Buffer.allocUnsafe(64 * 1024);
14
+ let position = 0;
15
+ while (position < size) {
16
+ const bytesRead = readSync(fd, chunk, 0, Math.min(chunk.byteLength, size - position), position);
17
+ if (bytesRead === 0)
18
+ break;
19
+ hash.update(chunk.subarray(0, bytesRead));
20
+ position += bytesRead;
21
+ }
22
+ return { sha256: hash.digest("hex"), size: position };
23
+ }
24
+ finally {
25
+ closeSync(fd);
26
+ }
27
+ }
8
28
  export function resolveArtifactPaths(artifacts, values) {
9
29
  if (!artifacts)
10
30
  return undefined;
@@ -35,7 +55,7 @@ export function resolveArtifactManifest(artifacts) {
35
55
  if (!declaration?.path)
36
56
  continue;
37
57
  try {
38
- const content = readFileSync(declaration.path);
58
+ const hashed = hashArtifactFile(declaration.path);
39
59
  manifest[name] = {
40
60
  exists: true,
41
61
  ...(declaration.kind ? { kind: declaration.kind } : {}),
@@ -46,8 +66,8 @@ export function resolveArtifactManifest(artifacts) {
46
66
  ...(declaration.required !== undefined
47
67
  ? { required: declaration.required }
48
68
  : {}),
49
- sha256: createHash("sha256").update(content).digest("hex"),
50
- size: content.byteLength,
69
+ sha256: hashed.sha256,
70
+ size: hashed.size,
51
71
  };
52
72
  }
53
73
  catch {
@@ -37,6 +37,7 @@ export interface SessionEvidence {
37
37
  turns: SessionEvidenceTurn[];
38
38
  }
39
39
  export interface SessionEvidenceReadOptions {
40
+ maxBytes?: number;
40
41
  maxTextChars?: number;
41
42
  maxToolCalls?: number;
42
43
  maxTurns?: number;
@@ -105,10 +105,11 @@ function toolCalls(message, maxTextChars, maxToolCalls) {
105
105
  }));
106
106
  }
107
107
  export function readSessionEvidence(path, options = {}) {
108
+ const maxBytes = Math.max(1, options.maxBytes ?? Limits.SESSION_EVIDENCE_MAX_BYTES);
108
109
  const maxTextChars = Math.max(1, options.maxTextChars ?? Limits.SESSION_EVIDENCE_TEXT_CHARS);
109
110
  const maxToolCalls = Math.max(1, options.maxToolCalls ?? Limits.SESSION_EVIDENCE_MAX_TOOL_CALLS);
110
111
  const maxTurns = Math.max(1, options.maxTurns ?? Limits.SESSION_EVIDENCE_MAX_TURNS);
111
- const read = readJsonlFileResilient(path);
112
+ const read = readJsonlFileResilient(path, { maxBytes });
112
113
  const diagnostics = [...read.diagnostics];
113
114
  const header = read.records.find((entry) => entry.type === "session");
114
115
  const branch = activeBranch(read.records, path, diagnostics);
@@ -198,7 +199,7 @@ export function readSessionEvidence(path, options = {}) {
198
199
  path,
199
200
  ...(header ? { session: asRecord(header) } : {}),
200
201
  totalTurns: turns.length,
201
- truncated: turns.length > visibleTurns.length,
202
+ truncated: read.truncated === true || turns.length > visibleTurns.length,
202
203
  turns: visibleTurns,
203
204
  };
204
205
  }
@@ -15,7 +15,11 @@ export interface JsonReadResult<T> {
15
15
  export interface JsonlReadResult<T> {
16
16
  diagnostics: StateReadDiagnostic[];
17
17
  records: T[];
18
+ truncated?: boolean;
19
+ }
20
+ export interface JsonlReadOptions {
21
+ maxBytes?: number;
18
22
  }
19
23
  export declare function readJsonFileResilient<T>(path: string, fallback: T): JsonReadResult<T>;
20
- export declare function readJsonlFileResilient<T>(path: string): JsonlReadResult<T>;
24
+ export declare function readJsonlFileResilient<T>(path: string, options?: JsonlReadOptions): JsonlReadResult<T>;
21
25
  export declare function formatStateReadDiagnostics(diagnostics: StateReadDiagnostic[], limit?: number): string[];