@ian-pascoe/pi-minimal-subagents 0.16.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
 
@@ -437,7 +437,7 @@ with Pi's tool-expansion hint (normally Ctrl+O), which reveals the rest and the
437
437
  full detail. Automatic agent results and messages preview the same way.
438
438
 
439
439
  In TUI mode, `/subagents` or `/subagents status` opens a large, centered,
440
- 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
441
441
  Transcript. Escape returns to the tree, then Escape closes the overlay. The
442
442
  viewer is read-only: Root Agent input and ongoing agent work remain intact.
443
443
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ian-pascoe/pi-minimal-subagents",
3
- "version": "0.16.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
  };
@@ -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;