@ian-pascoe/pi-minimal-subagents 0.12.0 → 0.14.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.
@@ -17,6 +17,7 @@ import {
17
17
  type SessionShutdownEvent,
18
18
  type SessionStartEvent,
19
19
  type SessionTreeEvent,
20
+ type TurnEndEvent,
20
21
  } from "@earendil-works/pi-coding-agent";
21
22
  import { isKeyRelease, isKeyRepeat, matchesKey } from "@earendil-works/pi-tui";
22
23
  import {
@@ -182,9 +183,12 @@ function rootCallerSnapshot(pi: ExtensionAPI, context: ExtensionContext): Caller
182
183
  function createRootConversationEndpoint(
183
184
  pi: ExtensionAPI,
184
185
  context: ExtensionContext,
186
+ onSteer: () => void,
185
187
  ): RootConversationEndpoint {
186
188
  return {
187
189
  async queueCoordinatorMessage(message) {
190
+ // A running root takes the message as a clearable steer; an idle root starts a turn with it.
191
+ if (!context.isIdle()) onSteer();
188
192
  pi.sendMessage(
189
193
  {
190
194
  customType: message.customType,
@@ -505,6 +509,9 @@ export class MinimalSubagentsLifecycleController {
505
509
  private readonly coordinatorOperations: CoordinatorToolOperations = {
506
510
  spawn: (...args) => this.requireCoordinator().spawn(...args),
507
511
  inspectStatus: (...args) => this.requireCoordinator().inspectStatus(...args),
512
+ previewActiveTurn: (...args) => this.requireCoordinator().previewActiveTurn(...args),
513
+ inspectActiveTurnTranscript: (...args) =>
514
+ this.requireCoordinator().inspectActiveTurnTranscript(...args),
508
515
  sendAgentMessage: (...args) => this.requireCoordinator().sendAgentMessage(...args),
509
516
  wait: (...args) => this.requireCoordinator().wait(...args),
510
517
  status: (...args) => this.requireCoordinator().status(...args),
@@ -514,6 +521,7 @@ export class MinimalSubagentsLifecycleController {
514
521
  private uiController: MinimalSubagentsUiController | undefined;
515
522
  private statusPanelController: MinimalSubagentsStatusPanelController | undefined;
516
523
  private viewerShortcut: ReturnType<typeof installViewerShortcut> | undefined;
524
+ private unwatchRootTurnEnds: (() => void) | undefined;
517
525
  private accessSession: ActiveSubagentAccessSession | undefined;
518
526
  private preparedFork:
519
527
  | { sourceSessionFile: string; selectedBranchSnapshot: RegistrySnapshot }
@@ -525,7 +533,10 @@ export class MinimalSubagentsLifecycleController {
525
533
  private readonly effects: MinimalSubagentsLifecycleEffects,
526
534
  ) {}
527
535
 
528
- /** Register stable tools, renderers, and the six Pi lifecycle event handlers. */
536
+ /**
537
+ * Register stable tools, renderers, and the six static Pi lifecycle event handlers. `turn_end` is
538
+ * registered only while a result waits in the root's steer queue (`watchRootTurnEnds`).
539
+ */
529
540
  register(): void {
530
541
  const rootTools = createCoordinatorToolDefinitions({
531
542
  coordinator: this.coordinatorOperations,
@@ -549,6 +560,7 @@ export class MinimalSubagentsLifecycleController {
549
560
  this.pi.on("session_before_fork", (event, context) => this.prepareSessionFork(event, context));
550
561
  this.pi.on("session_tree", (event, context) => this.restoreSessionTree(event, context));
551
562
  this.pi.on("message_end", (event, context) => this.reconcileMessageDelivery(event, context));
563
+ this.pi.on("agent_start", () => this.deliverDiscardedRootResults());
552
564
  this.pi.on("session_shutdown", (event, context) => this.shutdownSession(event, context));
553
565
  }
554
566
 
@@ -642,9 +654,10 @@ export class MinimalSubagentsLifecycleController {
642
654
  });
643
655
  activeCoordinator = new MinimalSubagentsCoordinator({
644
656
  sessions: sessionFactory,
645
- root: createRootConversationEndpoint(this.pi, context),
657
+ root: createRootConversationEndpoint(this.pi, context, () => this.watchRootTurnEnds()),
646
658
  maxSubagentDepth: minimalSubagentsConfig.maxSubagentDepth,
647
659
  toolsets: minimalSubagentsConfig.toolsets,
660
+ modelRoles: minimalSubagentsConfig.modelRoles,
648
661
  registry: {
649
662
  rootSessionId,
650
663
  append: (registryEvent) => this.pi.appendEntry(REGISTRY_ENTRY_TYPE, registryEvent),
@@ -922,16 +935,63 @@ export class MinimalSubagentsLifecycleController {
922
935
  _context: ExtensionContext,
923
936
  ): Promise<void> {
924
937
  if (!this.coordinator) return;
938
+ this.coordinator.recordRootMessageEnd();
925
939
  if (event.message.role === "toolResult" || event.message.role === "custom") {
926
940
  await this.coordinator.reconcileDeliveries();
927
941
  this.uiController?.refresh();
928
942
  }
929
943
  }
930
944
 
945
+ /**
946
+ * Watch root turn boundaries while a result waits in the root's steer queue. Pi builds a boundary
947
+ * preview for every `turn_end` while any handler listens, so the handler is removed again once no
948
+ * queued result remains.
949
+ */
950
+ private watchRootTurnEnds(): void {
951
+ this.unwatchRootTurnEnds ??= this.pi.on("turn_end", (event, context) =>
952
+ this.requeueDiscardedRootResults(event, context),
953
+ );
954
+ }
955
+
956
+ private stopWatchingRootTurnEnds(): void {
957
+ this.unwatchRootTurnEnds?.();
958
+ this.unwatchRootTurnEnds = undefined;
959
+ }
960
+
961
+ /**
962
+ * Pi gives extensions no queue-cleared event, so Esc's discarded steers are found here: the turn
963
+ * boundary's pending-message preview is empty. Returns nothing, so the transcript, system prompt,
964
+ * and tools are unchanged.
965
+ *
966
+ * A queue clear without an abort (dequeue, RPC `clear_queue`) leaves the run going, so released
967
+ * results are steered back at once and Pi's poll after this boundary takes them in the same run.
968
+ * Esc aborts the run, so its released results wait for the root's next run.
969
+ */
970
+ private requeueDiscardedRootResults(event: TurnEndEvent, context: ExtensionContext): void {
971
+ if (!this.coordinator) {
972
+ this.stopWatchingRootTurnEnds();
973
+ return;
974
+ }
975
+ if (event.context.pendingMessages.length === 0) {
976
+ this.coordinator.requeueDiscardedRootResults();
977
+ if (context.signal && !context.signal.aborted) {
978
+ this.coordinator.deliverDiscardedRootResults();
979
+ }
980
+ this.uiController?.refresh();
981
+ }
982
+ if (!this.coordinator.hasQueuedRootHandOffs()) this.stopWatchingRootTurnEnds();
983
+ }
984
+
985
+ /** Re-send discarded results into a root run that just started; recovery never starts one. */
986
+ private deliverDiscardedRootResults(): void {
987
+ this.coordinator?.deliverDiscardedRootResults();
988
+ }
989
+
931
990
  private async shutdownSession(
932
991
  event: SessionShutdownEvent,
933
992
  context: ExtensionContext,
934
993
  ): Promise<void> {
994
+ this.stopWatchingRootTurnEnds();
935
995
  this.viewerShortcut?.dispose();
936
996
  this.viewerShortcut = undefined;
937
997
  this.statusPanelController?.dispose();
@@ -56,6 +56,7 @@ const ToolSelectionWireSchema = Type.Union([
56
56
 
57
57
  /** Parses persisted immutable Launch Contracts without coercion. */
58
58
  export const RegistryLaunchContractWireSchema = Type.Object({
59
+ role: Type.Optional(NonEmptyStringSchema),
59
60
  session_context: Type.Union([
60
61
  Type.Literal("inherit"),
61
62
  Type.Literal("compact"),
@@ -1,6 +1,8 @@
1
1
  import type { JsonValue } from "@earendil-works/pi-ai";
2
2
  import { Value } from "typebox/value";
3
3
  import { COORDINATOR_TOOL_NAMES } from "./minimal-subagents-capabilities.js";
4
+ import { isModelRoleName } from "./minimal-subagents-config.js";
5
+ import { normalizeMinimalSubagentsUsage } from "./minimal-subagents-usage.js";
4
6
  import {
5
7
  createDeliveryLedger,
6
8
  deliveryLedgerSnapshot,
@@ -280,7 +282,8 @@ function cloneRegistryTurnResult(value: RegistryTurnResultWire): TurnResult {
280
282
  output: value.output,
281
283
  };
282
284
  if (value.error !== undefined) result.error = value.error;
283
- if (value.usage !== undefined) result.usage = structuredClone(value.usage);
285
+ // Records from older versions may lack optional usage counters.
286
+ if (value.usage !== undefined) result.usage = normalizeMinimalSubagentsUsage(value.usage);
284
287
  if (value.elapsed_ms !== undefined) result.elapsed_ms = value.elapsed_ms;
285
288
  return result;
286
289
  }
@@ -302,6 +305,7 @@ function parseRegistryLaunchContract(
302
305
  ): LaunchContract | undefined {
303
306
  if (
304
307
  !isCanonicalModelId(value.model) ||
308
+ (value.role !== undefined && !isModelRoleName(value.role)) ||
305
309
  !isUniqueNonEmptyStringArray(value.ordinary_tools) ||
306
310
  !excludesCoordinatorToolNames(value.ordinary_tools) ||
307
311
  (Array.isArray(value.tools) &&
@@ -318,6 +322,7 @@ function parseRegistryLaunchContract(
318
322
  ordinary_tools: [...value.ordinary_tools],
319
323
  };
320
324
  if (value.delegation !== undefined) launchContract.delegation = value.delegation;
325
+ if (value.role !== undefined) launchContract.role = value.role;
321
326
  return launchContract;
322
327
  }
323
328
 
@@ -23,6 +23,7 @@ const RenderUsageSchema = Type.Object({
23
23
  });
24
24
 
25
25
  const RenderLaunchContractSchema = Type.Object({
26
+ role: Type.Optional(Type.String()),
26
27
  session_context: Type.Optional(Type.String()),
27
28
  project_context: Type.Optional(Type.String()),
28
29
  model: Type.Optional(Type.String()),
@@ -87,9 +88,11 @@ const SpawnCallArgumentsSchema = Type.Object({
87
88
  agent_id: Type.Optional(Type.String()),
88
89
  session_context: Type.Optional(Type.String()),
89
90
  project_context: Type.Optional(Type.String()),
91
+ role: Type.Optional(Type.String()),
90
92
  model: Type.Optional(Type.String()),
91
93
  thinking_level: Type.Optional(Type.String()),
92
94
  delegation: Type.Optional(Type.String()),
95
+ tools: Type.Optional(Type.Union([Type.String(), Type.Array(Type.String())])),
93
96
  });
94
97
  const MessageCallArgumentsSchema = Type.Object({
95
98
  agent_id: Type.Optional(Type.String()),
@@ -113,6 +116,7 @@ const CurrentMessageDetailsSchema = Type.Object({
113
116
  agent_id: Type.String(),
114
117
  message_id: Type.Optional(Type.String()),
115
118
  disposition: Type.String(),
119
+ turn_id: Type.Optional(Type.String()),
116
120
  behavior: Type.Optional(Type.String()),
117
121
  error: Type.Optional(Type.String()),
118
122
  });
@@ -209,7 +213,7 @@ const CoordinatorAgentSummaryOutputSchema = Type.Object({
209
213
  latest_activity_at: Type.Optional(Type.String()),
210
214
  task: Type.Optional(Type.String()),
211
215
  child_count: Type.Number(),
212
- children: Type.Array(Type.Unknown()),
216
+ children: Type.Optional(Type.Array(Type.Unknown())),
213
217
  model: Type.String(),
214
218
  thinking_level: Type.String(),
215
219
  });
@@ -217,6 +221,7 @@ const CoordinatorAgentDetailOutputSchema = Type.Object({
217
221
  ...CoordinatorAgentSummaryOutputSchema.properties,
218
222
  session_file: Type.Optional(Type.String()),
219
223
  launch_contract: Type.Object({
224
+ role: Type.Optional(Type.String()),
220
225
  session_context: Type.String(),
221
226
  project_context: Type.String(),
222
227
  model: Type.String(),
@@ -266,7 +271,11 @@ export const CoordinatorToolOutputSchemas = {
266
271
  agent_id: Type.String(),
267
272
  turn_id: Type.String(),
268
273
  status: Type.Literal("running"),
269
- agent: Type.Optional(CoordinatorAgentDetailOutputSchema),
274
+ model: Type.String(),
275
+ thinking_level: Type.String(),
276
+ tools: Type.Array(Type.String()),
277
+ delegation: Type.Union([Type.Literal("none"), Type.Literal("fanout")]),
278
+ warnings: Type.Optional(Type.Array(Type.String())),
270
279
  }),
271
280
  agent_message: Type.Object({
272
281
  agent_id: Type.String(),
@@ -274,8 +283,10 @@ export const CoordinatorToolOutputSchemas = {
274
283
  disposition: Type.Union([
275
284
  Type.Literal("delivered-via-wait"),
276
285
  Type.Literal("queued"),
286
+ Type.Literal("started-turn"),
277
287
  Type.Literal("failed"),
278
288
  ]),
289
+ turn_id: Type.Optional(Type.String()),
279
290
  error: Type.Optional(Type.String()),
280
291
  }),
281
292
  subagent_wait: Type.Union([
@@ -319,6 +330,25 @@ const WaitRenderDetailsSchema = Type.Union([
319
330
  WaitTimeoutDetailsSchema,
320
331
  ]);
321
332
 
333
+ /** Partial `subagent_wait` updates: elapsed wait time and, while the child runs, its progress. */
334
+ const WaitProgressRenderDetailsSchema = Type.Object({
335
+ agent_id: Type.String(),
336
+ status: Type.Literal("waiting"),
337
+ elapsed_ms: Type.Number(),
338
+ turn_id: Type.Optional(Type.String()),
339
+ tool_calls: Type.Optional(Type.Number()),
340
+ });
341
+
342
+ export type WaitProgressRenderDetails = Static<typeof WaitProgressRenderDetailsSchema>;
343
+
344
+ /** Parse one partial `subagent_wait` progress update. */
345
+ export function parseWaitProgressDetails(
346
+ // oxlint-disable-next-line anti-slop/no-unknown-parameters -- Partial tool details are unparsed until the progress schema checks them below.
347
+ details: unknown,
348
+ ): WaitProgressRenderDetails | undefined {
349
+ return Value.Check(WaitProgressRenderDetailsSchema, details) ? details : undefined;
350
+ }
351
+
322
352
  export type SpawnCallArguments = Static<typeof SpawnCallArgumentsSchema>;
323
353
  export type MessageCallArguments = Static<typeof MessageCallArgumentsSchema>;
324
354
  export type WaitCallArguments = Static<typeof WaitCallArgumentsSchema>;
@@ -331,6 +361,7 @@ export type StatusRenderDetails = Static<typeof StatusDetailsSchema>;
331
361
  export type CancelRenderDetails = Static<typeof CancelDetailsSchema>;
332
362
  export type DeleteRenderDetails = Static<typeof DeleteDetailsSchema>;
333
363
  export type RenderStatusAgent = Static<typeof RenderStatusAgentSchema>;
364
+ export type RenderRecentActivity = Static<typeof RenderRecentActivitySchema>;
334
365
 
335
366
  /** Raw tool arguments supplied by Pi's tool-rendering interface. */
336
367
  export type CoordinatorToolCallInput = Parameters<NonNullable<ToolDefinition["renderCall"]>>[0];