@ian-pascoe/pi-minimal-subagents 0.15.0 → 0.17.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.99.0`.
32
+ `pi -e ./src/index.ts`. Requires Node `>=22.19.0` and Pi `>=1.1.0`.
33
33
 
34
34
  ## Typical workflow
35
35
 
@@ -276,10 +276,10 @@ creation; reloading does not silently broaden them. The default maximum depth
276
276
  is two levels beneath the interactive Root Agent.
277
277
 
278
278
  The extension registers six coordinator tools for the Root Agent and fanout
279
- children: `subagent`, `agent_message`, `subagent_wait`, `subagent_status`,
280
- `subagent_cancel`, and `subagent_delete`. Ordinary non-fanout children receive
281
- only the three adjacent-coordination tools: `agent_message`, `subagent_wait`,
282
- and `subagent_status`.
279
+ children below the depth cap: `subagent`, `agent_message`, `subagent_wait`, `subagent_status`,
280
+ `subagent_cancel`, and `subagent_delete`. Children that cannot spawn (delegation
281
+ `none`, or fanout at the depth cap) have no children to wait on or inspect, so they
282
+ receive only `agent_message`.
283
283
 
284
284
  A running child's `latest_activity_at` advances with the timestamps of its latest
285
285
  messages and tool results. Targeted `subagent_status` includes `recent_activity`, a bounded tail of message
@@ -288,7 +288,9 @@ assistant message but omits image data. Timeout Wait Events carry only a compact
288
288
  progress snapshot. Children target only direct children; the Root Agent
289
289
  may inspect any descendant. Model-facing status reports a `child_count` but no
290
290
  nested `children` summaries. Reported `usage` always includes `cacheWrite1h`
291
- and `reasoning`, as `0` when the provider reports none. `subagent_cancel`
291
+ and `reasoning`, as `0` when the provider reports none. Tool-result text and
292
+ `structuredContent` round every `usage.cost` field to six decimal places of USD;
293
+ exact values stay in session data. `subagent_cancel`
292
294
  lists in `affected_agent_ids` only agents whose active turns it cancelled.
293
295
 
294
296
  `session_context` defaults to `omit`: the child starts with only its system
@@ -435,7 +437,7 @@ with Pi's tool-expansion hint (normally Ctrl+O), which reveals the rest and the
435
437
  full detail. Automatic agent results and messages preview the same way.
436
438
 
437
439
  In TUI mode, `/subagents` or `/subagents status` opens a large, centered,
438
- framed overlay. Up/Down selects a Child Agent; Enter opens its Child Session
440
+ overlay in Pi's selector frame. Up/Down selects a Child Agent; Enter opens its Child Session
439
441
  Transcript. Escape returns to the tree, then Escape closes the overlay. The
440
442
  viewer is read-only: Root Agent input and ongoing agent work remain intact.
441
443
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ian-pascoe/pi-minimal-subagents",
3
- "version": "0.15.0",
3
+ "version": "0.17.0",
4
4
  "private": false,
5
5
  "description": "Persistent nested subagents with bounded delegation for Pi",
6
6
  "keywords": [
@@ -31,14 +31,14 @@
31
31
  "provenance": true
32
32
  },
33
33
  "dependencies": {
34
- "@ian-pascoe/pi-utils": "^0.3.1",
34
+ "@ian-pascoe/pi-utils": "^0.5.0",
35
35
  "minimatch": "^10.2.6"
36
36
  },
37
37
  "peerDependencies": {
38
- "@earendil-works/pi-agent-core": ">=0.99.0",
39
- "@earendil-works/pi-ai": ">=0.99.0",
40
- "@earendil-works/pi-coding-agent": ">=0.99.0",
41
- "@earendil-works/pi-tui": ">=0.99.0",
38
+ "@earendil-works/pi-agent-core": ">=1.1.0",
39
+ "@earendil-works/pi-ai": ">=1.1.0",
40
+ "@earendil-works/pi-coding-agent": ">=1.1.0",
41
+ "@earendil-works/pi-tui": ">=1.1.0",
42
42
  "typebox": "*"
43
43
  },
44
44
  "engines": {
@@ -1,4 +1,8 @@
1
1
  import type { SettingsManager } from "@earendil-works/pi-coding-agent";
2
+ import {
3
+ type LayeredSettingScope,
4
+ resolveLayeredOptions,
5
+ } from "@ian-pascoe/pi-utils/layered-settings";
2
6
  import { Minimatch } from "minimatch";
3
7
  import { type Static, Type } from "typebox";
4
8
  import { Value } from "typebox/value";
@@ -224,29 +228,28 @@ function readMinimalSubagentsSettings(
224
228
  return {};
225
229
  }
226
230
 
231
+ /** Merge one scope's model roles into those of lower scopes, entry by entry. */
227
232
  function mergeModelRoleEntries(
228
- globalValue: ModelRolesWireValue | undefined,
229
- projectValue: ModelRolesWireValue | undefined,
233
+ current: ReadonlyMap<string, ScopedSettingValue>,
234
+ value: ModelRolesWireValue,
235
+ source: LayeredSettingScope,
230
236
  warnings: string[],
231
237
  ): Map<string, ScopedSettingValue> {
232
- const entries = new Map<string, ScopedSettingValue>();
233
- if (globalValue?.kind === "entries") {
234
- for (const [name, value] of globalValue.entries) {
235
- entries.set(name, { scope: "global", value });
236
- }
237
- } else if (globalValue?.kind === "invalid") {
238
- warnings.push("global minimalSubagents.modelRoles: expected an object or null");
238
+ if (value.kind === "reset") return new Map();
239
+ if (value.kind === "invalid") {
240
+ warnings.push(`${source} minimalSubagents.modelRoles: expected an object or null`);
241
+ return new Map(current);
239
242
  }
240
- if (projectValue?.kind === "reset") return new Map();
241
- if (projectValue === undefined) return entries;
242
- if (projectValue.kind === "invalid") {
243
- warnings.push("project minimalSubagents.modelRoles: expected an object or null");
243
+ const entries = new Map(current);
244
+ if (source === "global") {
245
+ for (const [name, roleValue] of value.entries) {
246
+ entries.set(name, { scope: "global", value: roleValue });
247
+ }
244
248
  return entries;
245
249
  }
246
- if (projectValue.kind !== "entries") return entries;
247
250
 
248
- for (const [name, value] of projectValue.entries) {
249
- if (value.kind === "delete") {
251
+ for (const [name, roleValue] of value.entries) {
252
+ if (roleValue.kind === "delete") {
250
253
  entries.delete(name);
251
254
  continue;
252
255
  }
@@ -254,9 +257,9 @@ function mergeModelRoleEntries(
254
257
  const mergedValue =
255
258
  inherited !== undefined &&
256
259
  isExpandedModelRoleWireValue(inherited) &&
257
- isExpandedModelRoleWireValue(value)
258
- ? parseModelRoleWireValue({ ...inherited.fields, ...value.fields })
259
- : value;
260
+ isExpandedModelRoleWireValue(roleValue)
261
+ ? parseModelRoleWireValue({ ...inherited.fields, ...roleValue.fields })
262
+ : roleValue;
260
263
  entries.set(name, { scope: "project", value: mergedValue });
261
264
  }
262
265
  return entries;
@@ -380,26 +383,24 @@ function resolveSubagentAccessSettings(
380
383
  };
381
384
  }
382
385
 
383
- function resolveMaxSubagentDepth(
384
- globalValue: MaxSubagentDepthWireValue | undefined,
385
- projectValue: MaxSubagentDepthWireValue | undefined,
386
+ function mergeMaxSubagentDepth(
387
+ current: number,
388
+ value: MaxSubagentDepthWireValue,
389
+ source: LayeredSettingScope,
386
390
  warnings: string[],
387
391
  ): number {
388
- let resolvedDepth = DEFAULT_MAX_SUBAGENT_DEPTH;
389
- if (globalValue?.kind === "depth") {
390
- resolvedDepth = globalValue.value;
391
- } else if (globalValue?.kind === "invalid") {
392
- warnings.push(
393
- "global minimalSubagents.maxSubagentDepth: expected a positive safe integer or null",
394
- );
395
- }
396
- if (projectValue === undefined) return resolvedDepth;
397
- if (projectValue.kind === "reset") return DEFAULT_MAX_SUBAGENT_DEPTH;
398
- if (projectValue.kind === "depth") return projectValue.value;
392
+ if (value.kind === "reset") return DEFAULT_MAX_SUBAGENT_DEPTH;
393
+ if (value.kind === "depth") return value.value;
399
394
  warnings.push(
400
- "project minimalSubagents.maxSubagentDepth: expected a positive safe integer or null",
395
+ `${source} minimalSubagents.maxSubagentDepth: expected a positive safe integer or null`,
401
396
  );
402
- return resolvedDepth;
397
+ return current;
398
+ }
399
+
400
+ /** Settings resolved across scopes, before model role references are checked. */
401
+ interface MergedMinimalSubagentsSettings extends MinimalSubagentsToolsets {
402
+ maxSubagentDepth: number;
403
+ modelRoles: Map<string, ScopedSettingValue>;
403
404
  }
404
405
 
405
406
  /** Resolve trusted global and project settings into validated subagent guidance and limits. */
@@ -412,28 +413,39 @@ export function resolveMinimalSubagentsConfig(
412
413
  input.projectTrusted === false
413
414
  ? {}
414
415
  : readMinimalSubagentsSettings(input.projectSettings, "project", warnings);
415
- const maxSubagentDepth = resolveMaxSubagentDepth(
416
- globalConfig.maxSubagentDepth,
417
- projectConfig.maxSubagentDepth,
418
- warnings,
419
- );
420
- const modelRoleEntries = mergeModelRoleEntries(
421
- globalConfig.modelRoles,
422
- projectConfig.modelRoles,
423
- warnings,
424
- );
416
+ const { settings } = resolveLayeredOptions<
417
+ ParsedMinimalSubagentsSettings,
418
+ MergedMinimalSubagentsSettings
419
+ >({
420
+ defaults: {
421
+ maxSubagentDepth: DEFAULT_MAX_SUBAGENT_DEPTH,
422
+ modelRoles: new Map(),
423
+ baseToolset: [...DEFAULT_TOOLSETS.baseToolset],
424
+ readToolset: [...DEFAULT_TOOLSETS.readToolset],
425
+ modifyToolset: [...DEFAULT_TOOLSETS.modifyToolset],
426
+ },
427
+ // Depth warnings precede model role warnings, as each key merges across scopes in turn.
428
+ optionKeys: ["maxSubagentDepth", "modelRoles", "baseToolset", "readToolset", "modifyToolset"],
429
+ layers: [
430
+ ["global", globalConfig],
431
+ ["project", projectConfig],
432
+ ],
433
+ merge: {
434
+ maxSubagentDepth: (current, value, source) =>
435
+ mergeMaxSubagentDepth(current, value, source, warnings),
436
+ modelRoles: (current, value, source) =>
437
+ mergeModelRoleEntries(current, value, source, warnings),
438
+ },
439
+ });
425
440
 
426
441
  return {
427
- maxSubagentDepth,
442
+ maxSubagentDepth: settings.maxSubagentDepth,
428
443
  subagentAccess: resolveSubagentAccessSettings(globalConfig.enabled, projectConfig.enabled),
429
- modelRoles: parseModelRoles(modelRoleEntries, input.eligibleModelIds, warnings),
444
+ modelRoles: parseModelRoles(settings.modelRoles, input.eligibleModelIds, warnings),
430
445
  toolsets: {
431
- baseToolset: projectConfig.baseToolset ??
432
- globalConfig.baseToolset ?? [...DEFAULT_TOOLSETS.baseToolset],
433
- readToolset: projectConfig.readToolset ??
434
- globalConfig.readToolset ?? [...DEFAULT_TOOLSETS.readToolset],
435
- modifyToolset: projectConfig.modifyToolset ??
436
- globalConfig.modifyToolset ?? [...DEFAULT_TOOLSETS.modifyToolset],
446
+ baseToolset: settings.baseToolset,
447
+ readToolset: settings.readToolset,
448
+ modifyToolset: settings.modifyToolset,
437
449
  },
438
450
  warnings,
439
451
  };
@@ -353,7 +353,7 @@ export function buildSubagentSystemPrompt(
353
353
  ): string {
354
354
  const coordinatorBoundary = options.canSpawn
355
355
  ? "Coordinator tools support subagent, agent_message, subagent_wait, subagent_status, subagent_cancel, and subagent_delete. Wait, status, cancel, and delete target direct children only; recursive cancel and delete may affect a child's subtree."
356
- : "Coordinator tools support agent_message, subagent_wait, and subagent_status; wait and status target direct children only.";
356
+ : "Coordinator tools support only agent_message; you have no children to wait on or inspect.";
357
357
  const delegationBoundary = options.canSpawn
358
358
  ? [
359
359
  "You have explicit fanout responsibility for this assigned task.",
@@ -370,7 +370,7 @@ export class MinimalSubagentsCoordinator {
370
370
  this.dependencies.notify?.({
371
371
  type: "tool-warning",
372
372
  agentId,
373
- message: `Minimal subagents tool warnings for ${agentId}:\n- ${warnings.join("\n- ")}`,
373
+ message: `Tool warnings for ${agentId}:\n- ${warnings.join("\n- ")}`,
374
374
  });
375
375
  }
376
376
  this.dependencies.notify?.({
@@ -20,6 +20,7 @@ import {
20
20
  type TurnEndEvent,
21
21
  } from "@earendil-works/pi-coding-agent";
22
22
  import { isKeyRelease, isKeyRepeat, matchesKey } from "@earendil-works/pi-tui";
23
+ import { expandMessageOnClick, noticeText } from "@ian-pascoe/pi-utils/ui";
23
24
  import {
24
25
  createSubagentAccessBranchRecord,
25
26
  reconcileCoordinatorToolAccess,
@@ -247,7 +248,10 @@ function reportInvalidRegistryRecords(
247
248
  .map(({ entry_index: entryIndex, code }) => `entry ${entryIndex}: ${code}`)
248
249
  .join(", ");
249
250
  context.ui.notify(
250
- `Minimal subagents Registry ignored ${diagnostics.length} invalid active-branch record${diagnostics.length === 1 ? "" : "s"} (${summaries}${diagnostics.length > 3 ? ", …" : ""}).`,
251
+ noticeText(
252
+ "Subagents",
253
+ `Registry ignored ${diagnostics.length} invalid active-branch record${diagnostics.length === 1 ? "" : "s"} (${summaries}${diagnostics.length > 3 ? ", ..." : ""}).`,
254
+ ),
251
255
  "warning",
252
256
  );
253
257
  };
@@ -342,7 +346,10 @@ async function rebindForkAgents(
342
346
  const message = error instanceof Error ? error.message : String(error);
343
347
  failedSubtrees.add(agent.agent_id);
344
348
  agents.push(unavailableForkAgent(agent, message));
345
- context.ui.notify(`${options.notifyLabel} for ${agent.agent_id}: ${message}`, "error");
349
+ context.ui.notify(
350
+ noticeText("Subagents", `${options.notifyLabel} for ${agent.agent_id}: ${message}`),
351
+ "error",
352
+ );
346
353
  }
347
354
  }
348
355
  return agents;
@@ -410,7 +417,10 @@ function reportInvalidSubagentAccessRecords(
410
417
  ): (diagnostics: SubagentAccessReplayDiagnostic[]) => void {
411
418
  return (diagnostics) => {
412
419
  context.ui.notify(
413
- `Minimal subagents ignored ${diagnostics.length} invalid Subagent Access branch record${diagnostics.length === 1 ? "" : "s"}.`,
420
+ noticeText(
421
+ "Subagents",
422
+ `Ignored ${diagnostics.length} invalid Subagent Access branch record${diagnostics.length === 1 ? "" : "s"}.`,
423
+ ),
414
424
  "warning",
415
425
  );
416
426
  };
@@ -545,11 +555,18 @@ export class MinimalSubagentsLifecycleController {
545
555
  schemas: createCoordinatorToolSchemas([]),
546
556
  captureCaller: (context) => rootCallerSnapshot(this.pi, context),
547
557
  onActivity: () => this.uiController?.refresh(),
548
- onAttention: (message) => this.accessSession?.context.ui.notify(message, "error"),
558
+ onAttention: (message) =>
559
+ this.accessSession?.context.ui.notify(noticeText("Subagents", message), "error"),
549
560
  });
550
561
  for (const tool of rootTools) this.pi.registerTool(tool);
551
- this.pi.registerMessageRenderer("minimal-subagents.message", renderMinimalSubagentsMessage);
552
- this.pi.registerMessageRenderer("minimal-subagents.result", renderMinimalSubagentsResult);
562
+ this.pi.registerMessageRenderer(
563
+ "minimal-subagents.message",
564
+ expandMessageOnClick(renderMinimalSubagentsMessage),
565
+ );
566
+ this.pi.registerMessageRenderer(
567
+ "minimal-subagents.result",
568
+ expandMessageOnClick(renderMinimalSubagentsResult),
569
+ );
553
570
  this.pi.registerCommand("subagents", {
554
571
  description: "Control Subagent Access and inspect Child Agents",
555
572
  getArgumentCompletions: completeSubagentsCommandArguments,
@@ -587,7 +604,10 @@ export class MinimalSubagentsLifecycleController {
587
604
  ).override;
588
605
  if (minimalSubagentsConfig.warnings.length > 0) {
589
606
  context.ui.notify(
590
- `Minimal subagents configuration warnings:\n- ${minimalSubagentsConfig.warnings.join("\n- ")}`,
607
+ noticeText(
608
+ "Subagents",
609
+ `Configuration warnings:\n- ${minimalSubagentsConfig.warnings.join("\n- ")}`,
610
+ ),
591
611
  "warning",
592
612
  );
593
613
  }
@@ -648,7 +668,7 @@ export class MinimalSubagentsLifecycleController {
648
668
  callerId,
649
669
  childContext.sessionManager.getLeafId() ?? callerId,
650
670
  ),
651
- onAttention: (message) => context.ui.notify(message, "error"),
671
+ onAttention: (message) => context.ui.notify(noticeText("Subagents", message), "error"),
652
672
  });
653
673
  },
654
674
  });
@@ -665,7 +685,11 @@ export class MinimalSubagentsLifecycleController {
665
685
  notify: (notification) => {
666
686
  this.uiController?.refresh();
667
687
  if (shouldSurfaceNotification(notification)) {
668
- context.ui.notify(notification.message, notificationLevel(notification));
688
+ const level = notificationLevel(notification);
689
+ context.ui.notify(
690
+ level === "info" ? notification.message : noticeText("Subagents", notification.message),
691
+ level,
692
+ );
669
693
  }
670
694
  },
671
695
  });
@@ -679,7 +703,10 @@ export class MinimalSubagentsLifecycleController {
679
703
  if (forkSnapshot?.source_root_session_id !== sourceRootSessionId) {
680
704
  if (forkSnapshot) {
681
705
  context.ui.notify(
682
- "Minimal subagents fork handoff rejected because its source root identity did not match.",
706
+ noticeText(
707
+ "Subagents",
708
+ "Fork handoff rejected because its source root identity did not match.",
709
+ ),
683
710
  "error",
684
711
  );
685
712
  }
@@ -705,7 +732,10 @@ export class MinimalSubagentsLifecycleController {
705
732
  );
706
733
  } else {
707
734
  context.ui.notify(
708
- "Minimal subagents fork recovery skipped because the destination selected branch could not be proven from parentSession provenance.",
735
+ noticeText(
736
+ "Subagents",
737
+ "Fork recovery skipped because the destination selected branch could not be proven from parentSession provenance.",
738
+ ),
709
739
  "warning",
710
740
  );
711
741
  }
@@ -733,7 +763,7 @@ export class MinimalSubagentsLifecycleController {
733
763
  schemas,
734
764
  captureCaller: (toolContext) => rootCallerSnapshot(this.pi, toolContext),
735
765
  onActivity: () => this.uiController?.refresh(),
736
- onAttention: (message) => context.ui.notify(message, "error"),
766
+ onAttention: (message) => context.ui.notify(noticeText("Subagents", message), "error"),
737
767
  });
738
768
  for (const tool of rootTools) this.pi.registerTool(tool);
739
769
  this.accessSession = {
@@ -760,7 +790,10 @@ export class MinimalSubagentsLifecycleController {
760
790
 
761
791
  if (hasHistoricalChildIdentity(context.sessionManager.getBranch())) {
762
792
  context.ui.notify(
763
- "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.",
793
+ noticeText(
794
+ "Subagents",
795
+ "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.",
796
+ ),
764
797
  "warning",
765
798
  );
766
799
  }
@@ -828,11 +861,11 @@ export class MinimalSubagentsLifecycleController {
828
861
  private async runSubagentsCommand(args: string, context: ExtensionCommandContext): Promise<void> {
829
862
  const parsed = parseSubagentsCommandArguments(args);
830
863
  if (!parsed.ok) {
831
- context.ui.notify(parsed.message, "error");
864
+ context.ui.notify(noticeText("Subagents", parsed.message), "error");
832
865
  return;
833
866
  }
834
867
  if (!this.accessSession || !this.coordinator) {
835
- context.ui.notify("Minimal subagents is not active for this session.", "error");
868
+ context.ui.notify(noticeText("Subagents", "Not active for this session."), "error");
836
869
  return;
837
870
  }
838
871
  if (parsed.command.action === "status") {
@@ -861,7 +894,10 @@ export class MinimalSubagentsLifecycleController {
861
894
  command.action === "reset" ? undefined : command.action === "enable",
862
895
  );
863
896
  } catch (error) {
864
- context.ui.notify(error instanceof Error ? error.message : String(error), "error");
897
+ context.ui.notify(
898
+ noticeText("Subagents", error instanceof Error ? error.message : String(error)),
899
+ "error",
900
+ );
865
901
  return;
866
902
  }
867
903
  persistedScope = command.scope;
@@ -885,9 +921,12 @@ export class MinimalSubagentsLifecycleController {
885
921
  } catch (error) {
886
922
  const detail = error instanceof Error ? error.message : String(error);
887
923
  context.ui.notify(
888
- persistedScope
889
- ? `Minimal subagents ${persistedScope} default changed, but the current session did not: ${detail}`
890
- : `Minimal subagents could not change the current session: ${detail}`,
924
+ noticeText(
925
+ "Subagents",
926
+ persistedScope
927
+ ? `${persistedScope} default changed, but the current session did not: ${detail}`
928
+ : `Could not change the current session: ${detail}`,
929
+ ),
891
930
  "error",
892
931
  );
893
932
  return;
@@ -272,7 +272,10 @@ const CoordinatorWaitSourceOutputSchema = {
272
272
  source_turn_id: Type.String(),
273
273
  };
274
274
 
275
- /** Final details schemas exposed to CodeMode for the six coordinator tools. */
275
+ /**
276
+ * Final details schemas exposed to CodeMode for the six coordinator tools. `structuredContent`
277
+ * matches them with every `usage.cost` field rounded for presentation.
278
+ */
276
279
  export const CoordinatorToolOutputSchemas = {
277
280
  subagent: Type.Object({
278
281
  agent_id: Type.String(),