@llblab/pi-kit 0.1.5 → 0.1.6

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 (58) hide show
  1. package/CHANGELOG.md +5 -0
  2. package/README.md +3 -3
  3. package/node_modules/@llblab/pi-actors/BACKLOG.md +1 -1
  4. package/node_modules/@llblab/pi-actors/CHANGELOG.md +14 -1
  5. package/node_modules/@llblab/pi-actors/README.md +19 -3
  6. package/node_modules/@llblab/pi-actors/dist/lib/extension-runtime.js +29 -10
  7. package/node_modules/@llblab/pi-actors/dist/lib/inspector-overlay.d.ts +3 -0
  8. package/node_modules/@llblab/pi-actors/dist/lib/inspector-overlay.js +120 -38
  9. package/node_modules/@llblab/pi-actors/dist/lib/limits.d.ts +1 -0
  10. package/node_modules/@llblab/pi-actors/dist/lib/limits.js +1 -0
  11. package/node_modules/@llblab/pi-actors/dist/lib/observability.js +6 -1
  12. package/node_modules/@llblab/pi-actors/dist/lib/run-ui-runtime.d.ts +10 -3
  13. package/node_modules/@llblab/pi-actors/dist/lib/run-ui-runtime.js +109 -52
  14. package/node_modules/@llblab/pi-actors/dist/lib/runs-artifacts.js +24 -4
  15. package/node_modules/@llblab/pi-actors/dist/lib/runtime.d.ts +2 -0
  16. package/node_modules/@llblab/pi-actors/dist/lib/runtime.js +24 -4
  17. package/node_modules/@llblab/pi-actors/dist/lib/session-evidence.d.ts +1 -0
  18. package/node_modules/@llblab/pi-actors/dist/lib/session-evidence.js +3 -2
  19. package/node_modules/@llblab/pi-actors/dist/lib/state-readers.d.ts +5 -1
  20. package/node_modules/@llblab/pi-actors/dist/lib/state-readers.js +30 -3
  21. package/node_modules/@llblab/pi-actors/dist/lib/tools-access.d.ts +1 -0
  22. package/node_modules/@llblab/pi-actors/dist/lib/tools-access.js +13 -11
  23. package/node_modules/@llblab/pi-actors/dist/lib/tools-inspect.js +5 -5
  24. package/node_modules/@llblab/pi-actors/dist/lib/tools-message.d.ts +1 -0
  25. package/node_modules/@llblab/pi-actors/dist/lib/tools-message.js +3 -1
  26. package/node_modules/@llblab/pi-actors/docs/README.md +1 -0
  27. package/node_modules/@llblab/pi-actors/docs/actor-inspector.md +21 -5
  28. package/node_modules/@llblab/pi-actors/docs/async-runs.md +16 -4
  29. package/node_modules/@llblab/pi-actors/docs/command-templates.md +5 -4
  30. package/node_modules/@llblab/pi-actors/docs/inspection.md +83 -0
  31. package/node_modules/@llblab/pi-actors/docs/recipe-library.md +1 -1
  32. package/node_modules/@llblab/pi-actors/docs/template-recipes.md +225 -66
  33. package/node_modules/@llblab/pi-actors/docs/tool-registry.md +24 -3
  34. package/node_modules/@llblab/pi-actors/lib/extension-runtime.ts +26 -10
  35. package/node_modules/@llblab/pi-actors/lib/inspector-overlay.ts +161 -33
  36. package/node_modules/@llblab/pi-actors/lib/limits.ts +1 -0
  37. package/node_modules/@llblab/pi-actors/lib/observability.ts +5 -1
  38. package/node_modules/@llblab/pi-actors/lib/run-ui-runtime.ts +128 -50
  39. package/node_modules/@llblab/pi-actors/lib/runs-artifacts.ts +34 -4
  40. package/node_modules/@llblab/pi-actors/lib/runtime.ts +26 -7
  41. package/node_modules/@llblab/pi-actors/lib/session-evidence.ts +7 -2
  42. package/node_modules/@llblab/pi-actors/lib/state-readers.ts +45 -3
  43. package/node_modules/@llblab/pi-actors/lib/tools-access.ts +26 -12
  44. package/node_modules/@llblab/pi-actors/lib/tools-inspect.ts +9 -5
  45. package/node_modules/@llblab/pi-actors/lib/tools-message.ts +8 -1
  46. package/node_modules/@llblab/pi-actors/package.json +1 -1
  47. package/node_modules/@llblab/pi-telegram/AGENTS.md +1 -1
  48. package/node_modules/@llblab/pi-telegram/CHANGELOG.md +13 -0
  49. package/node_modules/@llblab/pi-telegram/README.md +1 -1
  50. package/node_modules/@llblab/pi-telegram/docs/architecture.md +3 -3
  51. package/node_modules/@llblab/pi-telegram/index.ts +25 -0
  52. package/node_modules/@llblab/pi-telegram/lib/journal.ts +192 -9
  53. package/node_modules/@llblab/pi-telegram/lib/runtime.ts +52 -2
  54. package/node_modules/@llblab/pi-telegram/lib/status.ts +3 -3
  55. package/node_modules/@llblab/pi-telegram/lib/telegram-api.ts +5 -1
  56. package/node_modules/@llblab/pi-telegram/package.json +1 -1
  57. package/node_modules/@llblab/pi-telegram/skills/generated-control-surface/SKILL.md +14 -6
  58. package/package.json +4 -4
@@ -22,9 +22,21 @@ Specialize a maintained Recipe without copying its contract:
22
22
  register_tool name=music_player from=music-player/playback defaults={"source":"~/Music/1MIX"}
23
23
  ```
24
24
 
25
- `from`, `template`, and `draft` are distinct source modes. `from` accepts exact `<skill>/<recipe>` identity or an explicit `.json` / `.md` path and inherits async behavior, args/types, source defaults, artifacts, Control, and runtime-owned origins. `defaults` may set only effective caller-owned args and must satisfy their types. `template` is only for trusted command definitions; public `values` authoring has been removed in favor of caller defaults or an authored Recipe file.
25
+ `from`, `template`, and `draft` are distinct source modes. `from` accepts exact `<skill>/<recipe>` identity or an explicit `.json` / `.md` path and inherits async behavior, args/types, source defaults, artifacts, Control, and runtime-owned origins. `defaults` may set only effective caller-owned args and must satisfy their types. `template` is only for trusted command definitions; specialization accepts caller `defaults`, while authored composition values belong in a Recipe file.
26
26
 
27
- Promote an immutable captured draft only with its draft path and explicit target name. Name collisions require `update=true`. Invalid content fails before active mutation.
27
+ Promote an immutable captured draft only with its draft path and explicit target name:
28
+
29
+ ```text
30
+ register_tool name=repo_check draft=~/.pi/agent/recipes/drafts/<captured-recipe>.json
31
+ ```
32
+
33
+ Delete a persisted definition through the same fenced mutation path:
34
+
35
+ ```text
36
+ register_tool name=repo_check template=null
37
+ ```
38
+
39
+ An empty `template` string is the equivalent deletion form. Do not delete registry files manually. Host-visible dynamic definitions may remain visible until reload because Pi cannot unregister them, but extension-local lookup rejects the deleted definition immediately. Name collisions for creation or replacement require `update=true`. Invalid content fails before active mutation.
28
40
 
29
41
  Registration resolves the effective delegated contract before persistence, then reports logical `source`, effective `required_args` / `optional_args`, and distinct `persisted`, `registry_active`, `host_registered`, `active_tool`, and `callable_now` states. A callable result points to the actual generated tool as the next action. An uncallable result names its `activation_boundary` and status/doctor action without suggesting spawn as a substitute. Diagnose one maintained source with `inspect target=recipes view=doctor identity=<skill>/<recipe>`; diagnose final activation, source, schema summary, and separate spawn/tool usage with `inspect target=tool:<name> view=status`. Treat the tool as callable in the current session only when `callable_now` is true; persistence alone is not activation proof. Registration results omit raw persisted paths and executable template/config payloads.
30
42
 
@@ -41,10 +53,19 @@ inspect target=recipes view=status
41
53
  inspect target=tool:<name> view=status
42
54
  ```
43
55
 
44
- Recipe inspection reports generation, scan/watch state, active, shadowed, invalid, disabled, component rejection, diagnostic, risk, usage, and review evidence. Tool status reports current activation plus separate `tool_calls` and `spawn_calls`; tool schema reports the caller-owned capability contract. A registered tool is not a running actor.
56
+ Recipe inspection reports generation, scan/watch state, active, shadowed, invalid, disabled, component rejection, diagnostic, risk, usage, and review evidence. Tool status reports current activation plus separate `tool_calls` and `spawn_calls`; tool schema reports the caller-owned capability contract. Inspect the complete target/view contract in [Management Inspection](./inspection.md). A registered tool is not a running actor.
45
57
 
46
58
  `spawn recipe=<name>` executes a Recipe and reports `launch_kind: "spawn"`; it does not prove that a registered tool was exposed or invoked. Registered-tool execution reports `launch_kind: "tool"`.
47
59
 
60
+ Async registered tools add runtime-owned optional invocation parameters after resolving the Recipe contract:
61
+
62
+ | Parameter | Availability | Purpose |
63
+ | --- | --- | --- |
64
+ | `run_id` | non-singleton async tools | Overrides the generated logical Run id for this invocation |
65
+ | `transport_context` | all async tools | Preserves an originating transport route for detached terminal follow-up |
66
+
67
+ These parameters are not Recipe-authored args. Singleton tools do not expose `run_id` because their canonical Run identity is owned by the Skill Recipe.
68
+
48
69
  ## Automatic Review
49
70
 
50
71
  Automatic draft/tool review remains silent and mechanically fenced:
@@ -35,6 +35,8 @@ export function createActorExtensionRuntime(
35
35
  pi: Pi.ExtensionAPI,
36
36
  ): ActorExtensionRuntime {
37
37
  let activeRunContext: Pi.ExtensionContext | undefined;
38
+ let activeRunOwnerId: string | undefined;
39
+ const runOwnerIdsByContext = new WeakMap<Pi.ExtensionContext, string>();
38
40
  const recipeResolutionContextsBySession = new Map<
39
41
  string,
40
42
  RecipeResolution.RecipeResolutionContext
@@ -55,9 +57,20 @@ export function createActorExtensionRuntime(
55
57
  getRunOwnerId,
56
58
  getThinkingLevel: () => pi.getThinkingLevel(),
57
59
  });
58
- const runUiRuntime = RunUiRuntime.createRunUiRuntime({
60
+ let recipeReload: Runtime.RecipeToolReloadWatcher | undefined;
61
+ let runUiRuntime: RunUiRuntime.RunUiRuntime;
62
+ const closeActiveSessionRuntimes = (): void => {
63
+ const ownerId = activeRunOwnerId;
64
+ activeRunContext = undefined;
65
+ activeRunOwnerId = undefined;
66
+ runUiRuntime?.close();
67
+ automaticReview.close();
68
+ recipeReload?.close();
69
+ if (ownerId) recipeResolutionContextsBySession.delete(ownerId);
70
+ };
71
+ runUiRuntime = RunUiRuntime.createRunUiRuntime({
59
72
  getActiveContext: () => activeRunContext,
60
- getRunOwnerId,
73
+ onCallbackError: closeActiveSessionRuntimes,
61
74
  onRunEvent: automaticReview.schedule,
62
75
  pi,
63
76
  });
@@ -102,11 +115,12 @@ export function createActorExtensionRuntime(
102
115
  reservedToolNames: Tools.RESERVED_TOOL_NAMES,
103
116
  setActiveTools: (toolNames) => pi.setActiveTools(toolNames),
104
117
  });
105
- const recipeReload = Runtime.createRecipeToolReloadWatcher(runtime, {
118
+ recipeReload = Runtime.createRecipeToolReloadWatcher(runtime, {
106
119
  getResolutionContext: () =>
107
120
  activeRunContext
108
121
  ? getRecipeResolutionContext(activeRunContext)
109
122
  : undefined,
123
+ onCallbackError: closeActiveSessionRuntimes,
110
124
  });
111
125
  return {
112
126
  beforeAgentStart(systemPrompt, skills, ctx) {
@@ -131,11 +145,11 @@ export function createActorExtensionRuntime(
131
145
  if (activeRunContext === ctx) automaticReview.schedule();
132
146
  },
133
147
  onSessionShutdown(reason, ctx) {
134
- recipeResolutionContextsBySession.delete(getRunOwnerId(ctx));
135
- activeRunContext = undefined;
136
- automaticReview.close();
137
- recipeReload.close();
138
- runUiRuntime.shutdown(reason, ctx);
148
+ const ownerId = runOwnerIdsByContext.get(ctx);
149
+ runOwnerIdsByContext.delete(ctx);
150
+ if (activeRunContext === ctx) closeActiveSessionRuntimes();
151
+ if (ownerId) recipeResolutionContextsBySession.delete(ownerId);
152
+ runUiRuntime.shutdown(reason, ownerId, ctx);
139
153
  },
140
154
  async onSessionStart(ctx) {
141
155
  const sessionId = getRunOwnerId(ctx);
@@ -145,13 +159,15 @@ export function createActorExtensionRuntime(
145
159
  );
146
160
  ctx.ui.setWidget("zz-pi-actors-comms", undefined);
147
161
  activeRunContext = ctx;
162
+ activeRunOwnerId = sessionId;
163
+ runOwnerIdsByContext.set(ctx, sessionId);
148
164
  runUiRuntime.close();
149
165
  automaticReview.close();
150
166
  recipeReload.close();
151
167
  await Temp.prepareExtensionTempDir(Paths.EXTENSION_RUNTIME_PATHS.tempDir);
152
- if (activeRunContext !== ctx) return;
168
+ if (activeRunContext !== ctx || activeRunOwnerId !== sessionId) return;
153
169
  automaticReview.start(ctx);
154
- runUiRuntime.start(ctx);
170
+ runUiRuntime.start(ctx, sessionId);
155
171
  recipeReload.watch(ctx);
156
172
  },
157
173
  registerCoreTools() {
@@ -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 {
@@ -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;
@@ -418,7 +418,11 @@ export function createRunTerminalReconciliationLoop(input: {
418
418
  input.refreshWatcher();
419
419
  input.reconcile();
420
420
  } catch (error) {
421
- input.onError?.(error);
421
+ try {
422
+ input.onError?.(error);
423
+ } catch {
424
+ /* reconciliation callbacks must never escape into the host event loop */
425
+ }
422
426
  }
423
427
  };
424
428
  const close = (): void => {