@ian-pascoe/pi-minimal-subagents 0.6.5 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -29,7 +29,7 @@ filter in `~/.pi/agent/settings.json` using the repository-relative path:
29
29
  ```
30
30
 
31
31
  From this package checkout, load the source directly with
32
- `pi -e ./src/index.ts`. Requires Node `>=22.19.0` and Pi `>=0.84.1`.
32
+ `pi -e ./src/index.ts`. Requires Node `>=22.19.0` and Pi `>=0.85.1`.
33
33
 
34
34
  ## Configuration
35
35
 
@@ -251,14 +251,38 @@ source file; it never substitutes the source session's newer head.
251
251
 
252
252
  ## Status and TUI
253
253
 
254
- In TUI mode, `/subagents status` opens a live read-only hierarchy. Rows begin
255
- collapsed; use Up/Down to select, Enter to expand Recent Activity, the configured
256
- tool-expansion key to reveal tool output, page keys to scroll, and Escape to
257
- close. Expanded activity uses Pi's transcript components, updates once per
258
- second, remains bounded, and omits images. The header reports the effective
259
- access source, authored settings, direct running/idle counts, and actual
260
- Coordinator Tool activation. Partial external activation is reported as
261
- `N/6 active`; status does not repair it.
254
+ In TUI mode, `/subagents` or `/subagents status` opens a large, centered,
255
+ framed overlay. Up/Down selects a Child Agent; Enter opens its Child Session
256
+ Transcript. Escape returns to the tree, then Escape closes the overlay. The
257
+ viewer is read-only: Root Agent input and ongoing agent work remain intact.
258
+
259
+ Two consecutive Left Arrow presses within 500 ms open the same viewer when
260
+ the main editor is focused and completely empty. Drafts (including whitespace),
261
+ dialogs, and other overlays retain normal navigation. Explicit key-repeat
262
+ reports are ignored; legacy terminals cannot distinguish holding Left from
263
+ two presses.
264
+
265
+ The viewer and compact widget prioritize sibling subtrees containing running
266
+ Child Agents. Idle ancestors move with active descendants, parents stay above
267
+ their children, and equally active siblings retain their original order. Viewer
268
+ selection follows the Child Agent's identity through live reordering.
269
+
270
+ The transcript includes inherited context, earlier turns, pre-compaction
271
+ messages, and live output on the selected saved branch. Abandoned branches and
272
+ internal bookkeeping are excluded. Saved history can be inspected without
273
+ restoring a runtime; missing or unverified sessions report an explanation rather
274
+ than substituting another branch. Model-facing Recent Activity remains bounded.
275
+
276
+ Transcripts open at the latest output and refresh once per second. Up/Down
277
+ scrolls by line, Page Up/Page Down by page. Scrolling up pauses following; End
278
+ returns to live output. Reading position is retained through resize and tool
279
+ expansion. Reasoning is visible, tool output starts collapsed, and Pi's configured
280
+ tool-expansion key (normally Ctrl+O) reveals it. Images appear as explicit text
281
+ placeholders rather than inline images.
282
+
283
+ The tree header reports the effective access source, authored settings, direct
284
+ running/idle counts, and actual Coordinator Tool activation. Partial external
285
+ activation is reported as `N/6 active`; status does not repair it.
262
286
 
263
287
  RPC mode receives a concise status notification. JSON and print modes produce
264
288
  no observer-only output.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ian-pascoe/pi-minimal-subagents",
3
- "version": "0.6.5",
3
+ "version": "0.7.0",
4
4
  "private": false,
5
5
  "description": "Persistent nested subagents with bounded delegation for Pi",
6
6
  "keywords": [
@@ -1,4 +1,3 @@
1
- import type { JsonValue } from "@earendil-works/pi-ai";
2
1
  import type { SettingsManager } from "@earendil-works/pi-coding-agent";
3
2
  import { type Static, Type } from "typebox";
4
3
  import { Value } from "typebox/value";
@@ -7,16 +6,18 @@ import { DEFAULT_MAX_SUBAGENT_DEPTH, THINKING_LEVELS } from "./minimal-subagents
7
6
  const MODEL_ROLE_NAME_MAX_LENGTH = 64;
8
7
  const MODEL_ROLE_HINT_MAX_LENGTH = 500;
9
8
 
10
- const JsonValueSchema = Type.Unsafe<JsonValue>({});
11
9
  const SettingsDocumentSchema = Type.Object({
12
- minimalSubagents: Type.Optional(JsonValueSchema),
10
+ minimalSubagents: Type.Optional(Type.Unknown()),
13
11
  });
14
12
  const MinimalSubagentsSettingsSchema = Type.Object({
15
- enabled: Type.Optional(JsonValueSchema),
16
- maxSubagentDepth: Type.Optional(JsonValueSchema),
17
- modelRoles: Type.Optional(JsonValueSchema),
13
+ enabled: Type.Optional(Type.Unknown()),
14
+ maxSubagentDepth: Type.Optional(Type.Unknown()),
15
+ modelRoles: Type.Optional(Type.Unknown()),
16
+ });
17
+ const ModelRoleObjectSchema = Type.Object({
18
+ model: Type.Optional(Type.Unknown()),
19
+ hint: Type.Optional(Type.Unknown()),
18
20
  });
19
- const JsonObjectSchema = Type.Record(Type.String(), JsonValueSchema);
20
21
  const EnabledSettingSchema = Type.Boolean();
21
22
  const PositiveSafeIntegerSchema = Type.Integer({ minimum: 1, maximum: Number.MAX_SAFE_INTEGER });
22
23
  const MaxSubagentDepthSettingSchema = Type.Union([PositiveSafeIntegerSchema, Type.Null()]);
@@ -28,7 +29,7 @@ const ExpandedModelRoleSchema = Type.Object(
28
29
  },
29
30
  { additionalProperties: false },
30
31
  );
31
- const ModelRoleEntriesSchema = Type.Record(Type.String(), JsonValueSchema);
32
+ const ModelRoleEntriesSchema = Type.Record(Type.String(), Type.Unknown());
32
33
  const ModelRolesSettingSchema = Type.Union([ModelRoleEntriesSchema, Type.Null()]);
33
34
 
34
35
  type ModelRoleThinkingLevel = (typeof THINKING_LEVELS)[number];
@@ -61,7 +62,7 @@ export interface ResolvedMinimalSubagentsConfig {
61
62
  }
62
63
 
63
64
  interface MinimalSubagentsSettingsDocument {
64
- minimalSubagents?: JsonValue;
65
+ minimalSubagents?: unknown;
65
66
  }
66
67
 
67
68
  interface MinimalSubagentsConfigInput {
@@ -102,7 +103,7 @@ type ModelRoleWireValue =
102
103
  | { kind: "delete" }
103
104
  | { kind: "shorthand"; model: string }
104
105
  | { kind: "expanded"; fields: Static<typeof ExpandedModelRoleSchema> }
105
- | { kind: "malformed-expanded"; fields: Record<string, JsonValue> }
106
+ | { kind: "malformed-expanded"; fields: Static<typeof ModelRoleObjectSchema> }
106
107
  | { kind: "invalid" };
107
108
 
108
109
  type ModelRolesWireValue =
@@ -110,15 +111,17 @@ type ModelRolesWireValue =
110
111
  | { kind: "entries"; entries: ReadonlyMap<string, ModelRoleWireValue> }
111
112
  | { kind: "invalid" };
112
113
 
113
- function parseMaxSubagentDepthWireValue(value: JsonValue): MaxSubagentDepthWireValue {
114
+ // oxlint-disable-next-line anti-slop/no-unknown-parameters -- Authored settings remain unparsed until the depth schema below validates their value.
115
+ function parseMaxSubagentDepthWireValue(value: unknown): MaxSubagentDepthWireValue {
114
116
  if (!Value.Check(MaxSubagentDepthSettingSchema, value)) return { kind: "invalid" };
115
117
  return value === null ? { kind: "reset" } : { kind: "depth", value };
116
118
  }
117
119
 
118
- function parseModelRoleWireValue(value: JsonValue): ModelRoleWireValue {
120
+ // oxlint-disable-next-line anti-slop/no-unknown-parameters -- Role entries may contain arbitrary settings data; schemas classify them before model/hint validation.
121
+ function parseModelRoleWireValue(value: unknown): ModelRoleWireValue {
119
122
  if (value === null) return { kind: "delete" };
120
123
  if (Value.Check(ShorthandModelRoleSchema, value)) return { kind: "shorthand", model: value };
121
- if (Value.Check(JsonObjectSchema, value)) {
124
+ if (Value.Check(ModelRoleObjectSchema, value)) {
122
125
  return Value.Check(ExpandedModelRoleSchema, value)
123
126
  ? { kind: "expanded", fields: value }
124
127
  : { kind: "malformed-expanded", fields: value };
@@ -132,7 +135,8 @@ function isExpandedModelRoleWireValue(
132
135
  return value.kind === "expanded" || value.kind === "malformed-expanded";
133
136
  }
134
137
 
135
- function parseModelRolesWireValue(value: JsonValue): ModelRolesWireValue {
138
+ // oxlint-disable-next-line anti-slop/no-unknown-parameters -- Validate the authored role collection before parsing each untrusted entry.
139
+ function parseModelRolesWireValue(value: unknown): ModelRolesWireValue {
136
140
  if (!Value.Check(ModelRolesSettingSchema, value)) return { kind: "invalid" };
137
141
  if (value === null) return { kind: "reset" };
138
142
  return {
@@ -8,8 +8,6 @@ import type {
8
8
  } from "./minimal-subagents-types.js";
9
9
 
10
10
  const RECENT_AGENT_ACTIVITY_LIMIT = 12;
11
- const CHILD_AGENT_TRANSCRIPT_MESSAGE_LIMIT = 24;
12
- const CHILD_AGENT_TRANSCRIPT_PAIR_WINDOW = CHILD_AGENT_TRANSCRIPT_MESSAGE_LIMIT * 2;
13
11
  const RECENT_AGENT_ACTIVITY_MAX_LINES = 20;
14
12
  const RECENT_AGENT_ACTIVITY_MAX_BYTES = 2 * 1024;
15
13
 
@@ -47,110 +45,45 @@ function visibleMessageContent(content: string | readonly (TextContent | ImageCo
47
45
  return contentText(content, "\n\n") || "(no text content)";
48
46
  }
49
47
 
50
- function omitAgentMessageImages(message: AgentMessage): AgentMessage {
48
+ function replaceAgentMessageImages(message: AgentMessage): AgentMessage {
51
49
  if (message.role === "user" || message.role === "custom") {
52
50
  return {
53
51
  ...structuredClone(message),
54
52
  content: Array.isArray(message.content)
55
- ? message.content
56
- .filter((content) => content.type !== "image")
57
- .map((content) => structuredClone(content))
53
+ ? message.content.map((content) =>
54
+ content.type === "image"
55
+ ? { type: "text" as const, text: `[Image: ${content.mimeType}]` }
56
+ : structuredClone(content),
57
+ )
58
58
  : message.content,
59
59
  };
60
60
  }
61
61
  if (message.role === "toolResult") {
62
62
  return {
63
63
  ...structuredClone(message),
64
- content: message.content
65
- .filter((content) => content.type !== "image")
66
- .map((content) => structuredClone(content)),
64
+ content: message.content.map((content) =>
65
+ content.type === "image"
66
+ ? { type: "text" as const, text: `[Image: ${content.mimeType}]` }
67
+ : structuredClone(content),
68
+ ),
67
69
  };
68
70
  }
69
71
  return structuredClone(message);
70
72
  }
71
73
 
72
- interface IndexedTranscriptMessage {
73
- message: AgentMessage;
74
- originalIndex: number;
75
- streaming: boolean;
76
- }
77
-
78
- /** Select at most 48 recent raw messages, retaining cross-cutoff tool pairs and omitting images. */
74
+ /** Clone the complete visible UI transcript, replacing images without bounding conversation history. */
79
75
  export function selectChildAgentTranscript(
80
76
  messages: readonly AgentMessage[],
81
77
  streamingAssistantMessage?: AgentMessage,
82
78
  ): ChildAgentTranscriptSnapshot {
83
- const pairWindow = messages.slice(-CHILD_AGENT_TRANSCRIPT_PAIR_WINDOW);
84
- const firstWindowIndex = messages.length - pairWindow.length;
85
- const indexed: IndexedTranscriptMessage[] = [
86
- ...pairWindow.map((message, windowIndex) => ({
87
- message,
88
- originalIndex: firstWindowIndex + windowIndex,
89
- streaming: false,
90
- })),
91
- ...(streamingAssistantMessage
92
- ? [
93
- {
94
- message: streamingAssistantMessage,
95
- originalIndex: messages.length,
96
- streaming: true,
97
- },
98
- ]
99
- : []),
100
- ];
101
- const tail = indexed.slice(-CHILD_AGENT_TRANSCRIPT_MESSAGE_LIMIT).map((item) => ({
102
- ...item,
103
- message: omitAgentMessageImages(item.message),
104
- }));
105
- const selectedIndexes = new Set(tail.map(({ originalIndex }) => originalIndex));
106
- const calls = new Map<
107
- string,
108
- { originalIndex: number; assistant: Extract<AgentMessage, { role: "assistant" }> }
109
- >();
110
- for (const item of indexed) {
111
- if (item.message.role !== "assistant") continue;
112
- for (const content of item.message.content) {
113
- if (content.type === "toolCall") {
114
- calls.set(content.id, { originalIndex: item.originalIndex, assistant: item.message });
115
- }
116
- }
117
- }
118
-
119
- const retained = tail.filter(
120
- (item) => item.message.role !== "toolResult" || calls.has(item.message.toolCallId),
121
- );
122
- const missingCalls = new Map<
123
- number,
124
- { assistant: Extract<AgentMessage, { role: "assistant" }>; callIds: Set<string> }
125
- >();
126
- for (const item of retained) {
127
- if (item.message.role !== "toolResult") continue;
128
- const call = calls.get(item.message.toolCallId);
129
- if (!call || selectedIndexes.has(call.originalIndex)) continue;
130
- const missing = missingCalls.get(call.originalIndex) ?? {
131
- assistant: call.assistant,
132
- callIds: new Set<string>(),
133
- };
134
- missing.callIds.add(item.message.toolCallId);
135
- missingCalls.set(call.originalIndex, missing);
136
- }
137
- const prefixes: IndexedTranscriptMessage[] = [...missingCalls.entries()]
138
- .sort(([left], [right]) => left - right)
139
- .map(([originalIndex, missing]) => ({
140
- message: {
141
- ...structuredClone(missing.assistant),
142
- content: missing.assistant.content
143
- .filter((content) => content.type === "toolCall" && missing.callIds.has(content.id))
144
- .map((content) => structuredClone(content)),
145
- },
146
- originalIndex,
147
- streaming: false,
148
- }));
149
- const selected = [...prefixes, ...retained];
150
- const streamingAssistantIndex = selected.findIndex(({ streaming }) => streaming);
79
+ const visible = messages.filter((message) => message.role !== "custom" || message.display);
80
+ const streaming = streamingAssistantMessage && !messages.includes(streamingAssistantMessage);
151
81
  return {
152
- messages: selected.map(({ message }) => message),
153
- streamingAssistantIndex: streamingAssistantIndex >= 0 ? streamingAssistantIndex : undefined,
82
+ messages: [
83
+ ...visible.map(replaceAgentMessageImages),
84
+ ...(streaming ? [replaceAgentMessageImages(streamingAssistantMessage)] : []),
85
+ ],
86
+ streamingAssistantIndex: streaming ? visible.length : undefined,
154
87
  toolDefinitions: [],
155
88
  };
156
89
  }
@@ -452,14 +452,22 @@ export class MinimalSubagentsCoordinator {
452
452
  };
453
453
  }
454
454
 
455
- /** Lazily inspect one Child Agent's bounded process-local transcript for trusted UI. */
455
+ /** Lazily inspect one Child Session Transcript without restoring a missing runtime. */
456
456
  inspectTranscript(agentId: string): ChildAgentTranscriptSnapshot {
457
457
  const agent = this.requireAgent(agentId);
458
458
  const runtime = this.runtimes.get(agentId);
459
459
  const liveSnapshot = runtime?.snapshotActivityTranscript?.();
460
460
  if (liveSnapshot) return liveSnapshot;
461
461
  if (runtime) return selectChildAgentTranscript(runtime.snapshotActivityMessages());
462
+ let historyError: string | undefined;
463
+ try {
464
+ const saved = this.dependencies.sessions.readTranscript?.(agent);
465
+ if (saved) return saved;
466
+ } catch (error) {
467
+ historyError = error instanceof Error ? error.message : String(error);
468
+ }
462
469
  const fallback =
470
+ historyError ||
463
471
  agent.unavailable_reason ||
464
472
  agent.latest_result?.error ||
465
473
  agent.latest_result?.output ||
@@ -2,6 +2,7 @@ import { fileURLToPath } from "node:url";
2
2
  import type { AgentMessage } from "@earendil-works/pi-agent-core";
3
3
  import {
4
4
  buildSessionContext,
5
+ CustomEditor,
5
6
  getAgentDir,
6
7
  SessionManager,
7
8
  SettingsManager,
@@ -9,6 +10,7 @@ import {
9
10
  type ExtensionCommandContext,
10
11
  type ExtensionContext,
11
12
  type ExtensionFactory,
13
+ type ExtensionUIContext,
12
14
  type MessageEndEvent,
13
15
  type SessionBeforeForkEvent,
14
16
  type SessionEntry,
@@ -16,6 +18,7 @@ import {
16
18
  type SessionStartEvent,
17
19
  type SessionTreeEvent,
18
20
  } from "@earendil-works/pi-coding-agent";
21
+ import { isKeyRelease, isKeyRepeat, matchesKey } from "@earendil-works/pi-tui";
19
22
  import {
20
23
  createSubagentAccessBranchRecord,
21
24
  reconcileCoordinatorToolAccess,
@@ -393,6 +396,80 @@ const productionLifecycleEffects: MinimalSubagentsLifecycleEffects = {
393
396
  createSessionFactory: (options) => new PiAgentSessionFactory(options),
394
397
  };
395
398
 
399
+ /** Compose a focus-local key sequence without taking over the editor's other behavior. */
400
+ function installViewerShortcut(ui: ExtensionUIContext, open: () => void) {
401
+ const previous = ui.getEditorComponent();
402
+ let active = true;
403
+ let previousLeftAt: number | undefined;
404
+ const reset = () => {
405
+ previousLeftAt = undefined;
406
+ };
407
+ const factory: NonNullable<ReturnType<ExtensionUIContext["getEditorComponent"]>> = (
408
+ tui,
409
+ theme,
410
+ keybindings,
411
+ ) => {
412
+ reset();
413
+ const editor =
414
+ previous?.(tui, theme, keybindings) ??
415
+ new CustomEditor(tui, theme, keybindings, { embedWorkingStatus: true });
416
+ let focused = false;
417
+ const handleInput = (data: string) => {
418
+ if (isKeyRelease(data)) {
419
+ editor.handleInput(data);
420
+ return;
421
+ }
422
+ if (
423
+ active &&
424
+ focused &&
425
+ editor.getText() === "" &&
426
+ matchesKey(data, "left") &&
427
+ !isKeyRepeat(data)
428
+ ) {
429
+ const now = performance.now();
430
+ if (previousLeftAt !== undefined && now - previousLeftAt <= 500) {
431
+ reset();
432
+ open();
433
+ return;
434
+ }
435
+ previousLeftAt = now;
436
+ } else {
437
+ reset();
438
+ }
439
+ editor.handleInput(data);
440
+ };
441
+ // Forward the editor's complete interface, including app handlers and custom methods.
442
+ // Binding to the original instance also preserves private fields in custom editors.
443
+ return new Proxy(editor, {
444
+ has: (target, property) => property === "focused" || Reflect.has(target, property),
445
+ get(target, property) {
446
+ if (property === "focused") return focused;
447
+ if (property === "handleInput") return handleInput;
448
+ // oxlint-disable-next-line anti-slop/no-reflect-get -- SAFETY: Forward the SDK editor's open interface, including other extensions' methods, without replacing their receiver.
449
+ const value = Reflect.get(target, property, target);
450
+ // oxlint-disable-next-line anti-slop/no-runtime-typeof -- SAFETY: Proxy forwarding binds callable members; this is not parsing external input.
451
+ return typeof value === "function" ? value.bind(target) : value;
452
+ },
453
+ set(target, property, value) {
454
+ if (property === "focused") {
455
+ focused = value;
456
+ if (!focused) reset();
457
+ }
458
+ return Reflect.set(target, property, value, target);
459
+ },
460
+ });
461
+ };
462
+ ui.setEditorComponent(factory);
463
+ return {
464
+ reset,
465
+ dispose() {
466
+ active = false;
467
+ reset();
468
+ if (ui.getEditorComponent() === factory) ui.setEditorComponent(previous);
469
+ },
470
+ };
471
+ }
472
+
396
473
  /** Own coordinator, UI, and prepared-fork state for one root Pi session lifecycle. */
397
474
  export class MinimalSubagentsLifecycleController {
398
475
  private coordinator: MinimalSubagentsCoordinator | undefined;
@@ -407,6 +484,7 @@ export class MinimalSubagentsLifecycleController {
407
484
  };
408
485
  private uiController: MinimalSubagentsUiController | undefined;
409
486
  private statusPanelController: MinimalSubagentsStatusPanelController | undefined;
487
+ private viewerShortcut: ReturnType<typeof installViewerShortcut> | undefined;
410
488
  private accessSession: ActiveSubagentAccessSession | undefined;
411
489
  private preparedFork:
412
490
  | { sourceSessionFile: string; selectedBranchSnapshot: RegistrySnapshot }
@@ -619,6 +697,14 @@ export class MinimalSubagentsLifecycleController {
619
697
  () => this.currentSubagentStatusAccess(),
620
698
  );
621
699
 
700
+ this.viewerShortcut?.dispose();
701
+ this.viewerShortcut =
702
+ context.mode === "tui"
703
+ ? installViewerShortcut(context.ui, () => {
704
+ void this.statusPanelController?.open();
705
+ })
706
+ : undefined;
707
+
622
708
  if (hasHistoricalChildIdentity(context.sessionManager.getBranch())) {
623
709
  context.ui.notify(
624
710
  "Opened a former subagent session directly. It is now an independent root; former descendants and parent messaging were not restored. Concurrent ownership by its original root is unsupported.",
@@ -666,6 +752,7 @@ export class MinimalSubagentsLifecycleController {
666
752
  _event: SessionTreeEvent,
667
753
  context: ExtensionContext,
668
754
  ): Promise<void> {
755
+ this.viewerShortcut?.reset();
669
756
  if (!this.coordinator) return;
670
757
  const snapshot = replayRegistryEntries(
671
758
  context.sessionManager.getBranch(),
@@ -805,6 +892,8 @@ export class MinimalSubagentsLifecycleController {
805
892
  event: SessionShutdownEvent,
806
893
  context: ExtensionContext,
807
894
  ): Promise<void> {
895
+ this.viewerShortcut?.dispose();
896
+ this.viewerShortcut = undefined;
808
897
  this.statusPanelController?.dispose();
809
898
  this.statusPanelController = undefined;
810
899
  if (this.coordinator) {
@@ -2,7 +2,7 @@ import type { ForkSnapshot } from "./minimal-subagents-types.js";
2
2
  import { canonicalPath } from "./minimal-subagents-paths.js";
3
3
 
4
4
  declare global {
5
- // eslint-disable-next-line no-var -- A process-global handoff must be visible to replacement extension instances.
5
+ // A process-global handoff must be visible to replacement extension instances.
6
6
  var minimalSubagentsForkSnapshots: Map<string, ForkSnapshot> | undefined;
7
7
  }
8
8
 
@@ -1,9 +1,5 @@
1
- import type { JsonValue } from "@earendil-works/pi-ai";
2
1
  import { Type, type Static } from "typebox";
3
2
 
4
- /** Establishes Pi's recursive JSON owner type before Registry envelope parsing. */
5
- export const RegistryJsonValueWireSchema = Type.Unsafe<JsonValue>({});
6
-
7
3
  const NonnegativeNumberSchema = Type.Number({ minimum: 0 });
8
4
  const NonEmptyStringSchema = Type.String({ minLength: 1 });
9
5
  const TurnStatusSchema = Type.Union([
@@ -28,7 +28,6 @@ import {
28
28
  RegistryDeliveryTurnEventWireSchema,
29
29
  RegistryEnvelopeWireSchema,
30
30
  RegistryEventDiscriminantWireSchema,
31
- RegistryJsonValueWireSchema,
32
31
  RegistryLooseEnvelopeWireSchema,
33
32
  RegistryMessageRecordedEventWireSchema,
34
33
  RegistryRootProbeWireSchema,
@@ -846,7 +845,8 @@ function validParsedEvent(event: RegistryEventV2): ParsedRegistryEvent {
846
845
  type RegistryParseInput = JsonValue | RegistryEventV2;
847
846
 
848
847
  function parseRegistryEventRecord(
849
- value: RegistryParseInput,
848
+ // oxlint-disable-next-line anti-slop/no-unknown-parameters -- Registry entry data is unparsed; ownership is checked first, then envelope and event schemas validate every consumed field.
849
+ value: unknown,
850
850
  rootSessionId: string,
851
851
  ): ParsedRegistryEvent {
852
852
  if (Value.Check(RegistryRootProbeWireSchema, value) && value.root_session_id !== rootSessionId) {
@@ -1191,10 +1191,6 @@ export function replayRegistryEntries(
1191
1191
  const diagnostics: RegistryReplayDiagnostic[] = [];
1192
1192
  entries.forEach((entry, entryIndex) => {
1193
1193
  if (entry.type !== "custom" || entry.customType !== REGISTRY_ENTRY_TYPE) return;
1194
- if (!Value.Check(RegistryJsonValueWireSchema, entry.data)) {
1195
- reportDiagnostic(diagnostics, entryIndex, "invalid-envelope", "record must be JSON");
1196
- return;
1197
- }
1198
1194
  const parsed = parseRegistryEventRecord(entry.data, rootSessionId);
1199
1195
  if (parsed.kind === "foreign-root") return;
1200
1196
  if (parsed.kind === "event") {
@@ -1,8 +1,4 @@
1
- import type {
2
- AgentToolResult,
3
- MessageRenderer,
4
- ToolDefinition,
5
- } from "@earendil-works/pi-coding-agent";
1
+ import type { ToolDefinition } from "@earendil-works/pi-coding-agent";
6
2
  import { Type, type Static } from "typebox";
7
3
  import { Value } from "typebox/value";
8
4
  import { COORDINATOR_TOOL_NAMES } from "./minimal-subagents-capabilities.js";
@@ -339,8 +335,6 @@ export type RenderStatusAgent = Static<typeof RenderStatusAgentSchema>;
339
335
  /** Raw tool arguments supplied by Pi's tool-rendering interface. */
340
336
  export type CoordinatorToolCallInput = Parameters<NonNullable<ToolDefinition["renderCall"]>>[0];
341
337
 
342
- type CoordinatorMessageInput = Parameters<MessageRenderer>[0];
343
-
344
338
  /** Parsed tool-call arguments tagged by their coordinator tool name. */
345
339
  export type ParsedCoordinatorToolCall =
346
340
  | { toolName: "subagent"; args: SpawnCallArguments }
@@ -383,7 +377,8 @@ export function parseCoordinatorToolCall(
383
377
  /** Parse one historical tool result exactly once at the transcript rendering boundary. */
384
378
  export function parseCoordinatorToolResult(
385
379
  toolName: CoordinatorToolName,
386
- details: AgentToolResult<unknown>["details"],
380
+ // oxlint-disable-next-line anti-slop/no-unknown-parameters -- Historical tool details are unparsed until the selected per-tool schema checks them below.
381
+ details: unknown,
387
382
  ): ParsedCoordinatorToolResult | undefined {
388
383
  switch (toolName) {
389
384
  case "subagent":
@@ -417,7 +412,8 @@ export type CoordinatorMessageRenderDetails = Static<typeof CoordinatorMessageRe
417
412
 
418
413
  /** Parse optional custom-message details while tolerating legacy field names. */
419
414
  export function parseCoordinatorMessageDetails(
420
- details: CoordinatorMessageInput["details"],
415
+ // oxlint-disable-next-line anti-slop/no-unknown-parameters -- Historical custom-message metadata is validated by its owning schema below.
416
+ details: unknown,
421
417
  ): CoordinatorMessageRenderDetails | undefined {
422
418
  return Value.Check(CoordinatorMessageRenderDetailsSchema, details) ? details : undefined;
423
419
  }
@@ -39,6 +39,7 @@ import {
39
39
  type WaitRenderDetails,
40
40
  } from "./minimal-subagents-render-contract.js";
41
41
  import { stripCoordinatorMessageEnvelope } from "./minimal-subagents-message-envelope.js";
42
+ import type { AgentSummary } from "./minimal-subagents-types.js";
42
43
 
43
44
  export type { CoordinatorToolName } from "./minimal-subagents-render-contract.js";
44
45
 
@@ -98,6 +99,23 @@ function toolResultText(result: AgentToolResult<unknown>): string {
98
99
  return text?.type === "text" ? text.text : "";
99
100
  }
100
101
 
102
+ /** Copy the hierarchy with active subtrees first, preserving sibling ties and ancestry. */
103
+ export function orderActiveAgentSubtrees(agents: readonly AgentSummary[]): AgentSummary[] {
104
+ const orderSiblings = (
105
+ siblings: readonly AgentSummary[],
106
+ ): { agent: AgentSummary; active: boolean }[] =>
107
+ siblings
108
+ .map((agent) => {
109
+ const children = orderSiblings(agent.children);
110
+ return {
111
+ agent: { ...agent, children: children.map((child) => child.agent) },
112
+ active: agent.state === "running" || children.some((child) => child.active),
113
+ };
114
+ })
115
+ .sort((left, right) => Number(right.active) - Number(left.active));
116
+ return orderSiblings(agents).map(({ agent }) => agent);
117
+ }
118
+
101
119
  /** Shared unavailable → running → latest-turn → idle status ladder for one subagent. */
102
120
  export function subagentStatusLadder(agent: {
103
121
  readonly availability?: string;