@ian-pascoe/pi-minimal-subagents 0.8.0 → 0.10.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
@@ -156,9 +156,8 @@ spawn attempts; the root retains recursive hierarchy management.
156
156
 
157
157
  ## Toolsets
158
158
 
159
- Configure ordinary tools using case-sensitive minimatch patterns, with the same
160
- syntax as CodeMode Exposure Patterns. These are lists of pattern strings, not
161
- CodeMode exposure-rule objects. For example:
159
+ Configure ordinary tools using case-sensitive minimatch pattern strings. For
160
+ example:
162
161
 
163
162
  ```json
164
163
  {
@@ -183,14 +182,15 @@ Tool Presets are cumulative:
183
182
  - `tools: "none"` or `tools: []`: Base Toolset only.
184
183
  - `tools: ["read"]`: Base Toolset + exactly `read`; arrays do not expand patterns
185
184
  or presets.
186
- - Omitted `tools`: Base Toolset + the caller's inherited ordinary tools.
185
+ - Omitted `tools`: Base Toolset + the caller's Reachable Tools: the Root Agent's
186
+ active tools plus tools scripts can call without declaring them (`codemode` or
187
+ `deferred` exposure, such as MCP tools), or a Child Agent caller's own grant.
187
188
 
188
189
  Patterns select from permitted ordinary tool names, including inactive tools
189
190
  registered at the root. Their matches are unioned in pattern order, retaining
190
191
  registry order within each pattern and removing duplicates at first occurrence.
191
192
  Each pattern is independent: a negated minimatch pattern matches its complement;
192
- it does not subtract earlier matches or implement CodeMode's last-rule-wins
193
- exposure policy. Coordinator Tools remain separately controlled by delegation.
193
+ it does not subtract earlier matches. Coordinator Tools remain separately controlled by delegation.
194
194
 
195
195
  Invalid configuration entries and patterns matching no permitted ordinary tools
196
196
  warn and are skipped. Configured tools unavailable in child resources are also
@@ -206,11 +206,15 @@ still apply; the saved grant is not silently rewritten. Toolsets configure names
206
206
  not tool operations: granting `lsp`, `dap`, or another multifunction tool grants
207
207
  that tool's available operations, regardless of preset name.
208
208
 
209
- CodeMode still controls whether a granted tool is direct, CodeMode-only, or both.
210
- If a child needs CodeMode-only tools, also grant `codemode_*` in its toolsets so
211
- it has the tools needed to discover and call them. This does not bypass CodeMode's
212
- own restrictions on nested calls. Exposure rules also apply to injected
213
- Coordinator Tools; delegation still determines which Coordinator Tools are granted.
209
+ Child Agents load Pi's built-in `codemode`, `tool-search`, and `mcp` extensions
210
+ unless settings disable them (`-builtin:<name>`); each child connects its own MCP
211
+ servers. A tool's exposure still decides whether it is declared or reachable only
212
+ through `codemode` or `tool_search`. Granted `codemode`- or `deferred`-exposed
213
+ tools are callable but not declared, so a child that needs them also needs
214
+ `codemode` or `tool_search` in its grant. Ungranted tools are unreachable in the
215
+ child. MCP tools are checked at launch only by name, because children register
216
+ them after connecting; a tool whose server disappeared fails when called. Closing
217
+ a child runtime shuts its extensions down, closing its MCP connections.
214
218
 
215
219
  ## Capabilities and persistence
216
220
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ian-pascoe/pi-minimal-subagents",
3
- "version": "0.8.0",
3
+ "version": "0.10.0",
4
4
  "private": false,
5
5
  "description": "Persistent nested subagents with bounded delegation for Pi",
6
6
  "keywords": [
@@ -31,11 +31,8 @@
31
31
  "provenance": true
32
32
  },
33
33
  "dependencies": {
34
- "minimatch": "^10.2.6",
35
- "proper-lockfile": "^4.1.2"
36
- },
37
- "devDependencies": {
38
- "@types/proper-lockfile": "^4.1.4"
34
+ "@ian-pascoe/pi-utils": "^0.3.0",
35
+ "minimatch": "^10.2.6"
39
36
  },
40
37
  "peerDependencies": {
41
38
  "@earendil-works/pi-agent-core": "*",
@@ -1,4 +1,5 @@
1
1
  import { dirname } from "node:path";
2
+ import * as piCodingAgent from "@earendil-works/pi-coding-agent";
2
3
  import { DefaultResourceLoader, SettingsManager } from "@earendil-works/pi-coding-agent";
3
4
  import { canonicalPath } from "./minimal-subagents-paths.js";
4
5
  import type { ProjectContextMode } from "./minimal-subagents-types.js";
@@ -12,6 +13,25 @@ export interface ChildResourceLoaderOptions {
12
13
  settingsManager?: SettingsManager;
13
14
  }
14
15
 
16
+ type InlineExtension = NonNullable<
17
+ ConstructorParameters<typeof DefaultResourceLoader>[0]["extensionFactories"]
18
+ >[number];
19
+
20
+ /** Pi's exported built-in extensions (Pi 0.99+); older hosts have none, so neither do children. */
21
+ export function childBuiltinExtensions(): InlineExtension[] {
22
+ // SAFETY: Older Pi hosts lack these exports; the namespace yields undefined rather than failing.
23
+ const sdk = piCodingAgent as Partial<typeof piCodingAgent>;
24
+ return (
25
+ [
26
+ ["codemode", sdk.createCodemodeExtension],
27
+ ["tool-search", sdk.createToolSearchExtension],
28
+ ["mcp", sdk.createMcpExtension],
29
+ ] as const
30
+ ).flatMap(([name, create]) =>
31
+ create ? [{ name, factory: create(), builtin: true, replaceable: true }] : [],
32
+ );
33
+ }
34
+
15
35
  function createDefaultChildResourceLoaderOptions(
16
36
  input: ChildResourceLoaderOptions,
17
37
  ): ConstructorParameters<typeof DefaultResourceLoader>[0] {
@@ -24,6 +44,7 @@ function createDefaultChildResourceLoaderOptions(
24
44
  noContextFiles: false,
25
45
  noSkills: false,
26
46
  noPromptTemplates: false,
47
+ extensionFactories: childBuiltinExtensions(),
27
48
  extensionsOverride: (base) => ({
28
49
  ...base,
29
50
  extensions: base.extensions.filter(
@@ -570,7 +570,7 @@ export class MinimalSubagentsCoordinator {
570
570
  const runtime = this.runtimes.get(agent.agent_id);
571
571
  try {
572
572
  if (agent.active_turn_id) await this.cancelActiveTurn(agent);
573
- runtime?.dispose();
573
+ await runtime?.dispose();
574
574
  this.runtimes.delete(agent.agent_id);
575
575
  if (agent.session_file) {
576
576
  await this.dependencies.sessions.trashSession(agent);
@@ -622,7 +622,7 @@ export class MinimalSubagentsCoordinator {
622
622
  await Promise.allSettled(
623
623
  abandonedRuntimes.map((runtime) => (runtime.isRunning ? runtime.abort() : Promise.resolve())),
624
624
  );
625
- for (const runtime of abandonedRuntimes) runtime.dispose();
625
+ for (const runtime of abandonedRuntimes) await runtime.dispose();
626
626
  this.runtimes.clear();
627
627
  this.runtimeInitializations.clear();
628
628
  this.pendingAgentIds.clear();
@@ -892,7 +892,7 @@ export class MinimalSubagentsCoordinator {
892
892
  await Promise.allSettled(this.runtimeInitializations.values());
893
893
  await Promise.allSettled(this.backgroundOperations);
894
894
  await Promise.allSettled(this.recipientQueues.values());
895
- for (const runtime of this.runtimes.values()) runtime.dispose();
895
+ for (const runtime of this.runtimes.values()) await runtime.dispose();
896
896
  this.runtimes.clear();
897
897
  }
898
898
 
@@ -910,7 +910,7 @@ export class MinimalSubagentsCoordinator {
910
910
  const runtime = await this.ensureRuntime(agent);
911
911
  if (this.agents.get(agentId) !== agent || agent.active_turn_id !== turnId) {
912
912
  if (!this.agents.has(agentId) || !this.acceptingOperations) {
913
- runtime.dispose();
913
+ await runtime.dispose();
914
914
  this.runtimes.delete(agentId);
915
915
  }
916
916
  return;
@@ -943,13 +943,13 @@ export class MinimalSubagentsCoordinator {
943
943
  const openingForTurn = agent.active_turn_id !== undefined;
944
944
  const initialization = this.dependencies.sessions
945
945
  .openRuntime(agent)
946
- .then((runtime) => {
946
+ .then(async (runtime) => {
947
947
  if (this.agents.get(agent.agent_id) !== agent) {
948
- runtime.dispose();
948
+ await runtime.dispose();
949
949
  throw new Error(`Minimal subagents runtime replaced while opening ${agent.agent_id}`);
950
950
  }
951
951
  if (!runtime.sessionLeafId) {
952
- runtime.dispose();
952
+ await runtime.dispose();
953
953
  throw new Error(
954
954
  `Minimal subagents session restoration: no selected session leaf for ${agent.agent_id}`,
955
955
  );
@@ -63,7 +63,7 @@ import {
63
63
  unavailableAgent,
64
64
  } from "./minimal-subagents-sessions.js";
65
65
  import { shutdownMinimalSubagentsSession } from "./minimal-subagents-shutdown.js";
66
- import { MinimalSubagentsSettingsWriter } from "./minimal-subagents-settings-writer.js";
66
+ import { writeMinimalSubagentsEnabled } from "./minimal-subagents-settings-writer.js";
67
67
  import {
68
68
  MinimalSubagentsStatusPanelController,
69
69
  type MinimalSubagentsStatusAccess,
@@ -80,6 +80,7 @@ import { MinimalSubagentsUiController } from "./minimal-subagents-ui.js";
80
80
  import type {
81
81
  AgentSessionFactory,
82
82
  CallerSnapshot,
83
+ ChildSessionObserver,
83
84
  CoordinatorNotification,
84
85
  ForkSnapshot,
85
86
  PersistedAgent,
@@ -137,16 +138,39 @@ function currentConversationMessages(context: ExtensionContext): AgentMessage[]
137
138
  return snapshotCommittedContext(messages, !context.isIdle());
138
139
  }
139
140
 
141
+ export function availableToolNames(pi: Pick<ExtensionAPI, "getAllTools">): string[] {
142
+ return excludeCoordinatorTools(
143
+ pi
144
+ .getAllTools()
145
+ .filter((tool) => tool.exposure !== "hidden")
146
+ .map((tool) => tool.name),
147
+ );
148
+ }
149
+
150
+ /** Reachable Tools: active tools plus tools scripts can call without declaring them. */
151
+ export function reachableToolNames(
152
+ pi: Pick<ExtensionAPI, "getActiveTools" | "getAllTools">,
153
+ ): string[] {
154
+ const active = new Set(pi.getActiveTools());
155
+ return excludeCoordinatorTools(
156
+ pi
157
+ .getAllTools()
158
+ .filter(
159
+ (tool) =>
160
+ active.has(tool.name) || tool.exposure === "codemode" || tool.exposure === "deferred",
161
+ )
162
+ .map((tool) => tool.name),
163
+ );
164
+ }
165
+
140
166
  function rootCallerSnapshot(pi: ExtensionAPI, context: ExtensionContext): CallerSnapshot {
141
167
  if (!context.model) throw new Error("Minimal subagents spawn: root has no effective model");
142
- const activeTools = excludeCoordinatorTools(pi.getActiveTools());
143
- const availableTools = excludeCoordinatorTools(pi.getAllTools().map((tool) => tool.name));
144
168
  return {
145
169
  messages: currentConversationMessages(context),
146
170
  model: `${context.model.provider}/${context.model.id}`,
147
171
  thinkingLevel: context.thinkingLevel ?? pi.getThinkingLevel(),
148
- ordinaryTools: activeTools,
149
- capabilityCeiling: availableTools,
172
+ ordinaryTools: reachableToolNames(pi),
173
+ capabilityCeiling: availableToolNames(pi),
150
174
  spawnEntryId: context.sessionManager.getLeafId() ?? "root",
151
175
  };
152
176
  }
@@ -560,9 +584,6 @@ export class MinimalSubagentsLifecycleController {
560
584
  ) {
561
585
  models.push(context.model);
562
586
  }
563
- const availableToolNames = excludeCoordinatorTools(
564
- this.pi.getAllTools().map((tool) => tool.name),
565
- );
566
587
  const schemas = createCoordinatorToolSchemas(eligibleModelIds);
567
588
  let activeCoordinator: MinimalSubagentsCoordinator | undefined;
568
589
  const requireActiveCoordinator = (): MinimalSubagentsCoordinator => {
@@ -580,11 +601,24 @@ export class MinimalSubagentsLifecycleController {
580
601
  models,
581
602
  eligibleModelIds,
582
603
  modelScopeRestricted: enabledModelPatterns !== undefined,
583
- availableToolNames,
604
+ availableToolNames: availableToolNames(this.pi),
584
605
  getRuntimeToolAdapters: () => codexConversionRuntimeToolAdapters(this.pi),
585
606
  projectTrusted: context.isProjectTrusted(),
586
607
  maxSubagentDepth: minimalSubagentsConfig.maxSubagentDepth,
587
608
  onChildSessionActivity: () => activeCoordinator?.scheduleDeliveryReconciliation(),
609
+ observeSession: (session, agentId, resourceInputs) => {
610
+ let observer: ChildSessionObserver | undefined;
611
+ this.pi.events.emit("pi-minimal-subagents:observe-session", {
612
+ rootSessionId,
613
+ agentId,
614
+ session,
615
+ resourceInputs,
616
+ attach: (attached: ChildSessionObserver) => {
617
+ observer = attached;
618
+ },
619
+ });
620
+ return observer;
621
+ },
588
622
  getCoordinatorTools: (callerId) => {
589
623
  const coordinator = requireActiveCoordinator();
590
624
  return createCoordinatorToolDefinitions({
@@ -802,15 +836,15 @@ export class MinimalSubagentsLifecycleController {
802
836
 
803
837
  let persistedScope: "global" | "project" | undefined;
804
838
  if (command.scope !== "session") {
805
- const writeResult = await new MinimalSubagentsSettingsWriter(
806
- context,
807
- () => accessSession.agentDir,
808
- ).writeMinimalSubagentsEnabled(
809
- command.scope,
810
- command.action === "reset" ? undefined : command.action === "enable",
811
- );
812
- if (!writeResult.ok) {
813
- context.ui.notify(writeResult.error.message, "error");
839
+ try {
840
+ await writeMinimalSubagentsEnabled(
841
+ context,
842
+ accessSession.agentDir,
843
+ command.scope,
844
+ command.action === "reset" ? undefined : command.action === "enable",
845
+ );
846
+ } catch (error) {
847
+ context.ui.notify(error instanceof Error ? error.message : String(error), "error");
814
848
  return;
815
849
  }
816
850
  persistedScope = command.scope;
@@ -17,6 +17,7 @@ import {
17
17
  SettingsManager,
18
18
  sessionEntryToContextMessages,
19
19
  type AgentSessionEvent,
20
+ type Extension,
20
21
  type SessionEntry,
21
22
  type ToolDefinition,
22
23
  } from "@earendil-works/pi-coding-agent";
@@ -53,6 +54,7 @@ import { addMinimalSubagentsUsage } from "./minimal-subagents-usage.js";
53
54
  import type {
54
55
  AgentSessionFactory,
55
56
  ChildAgentRuntime,
57
+ ChildSessionObserver,
56
58
  ChildAgentTranscriptSnapshot,
57
59
  CoordinatorMessage,
58
60
  PersistedAgent,
@@ -96,6 +98,18 @@ export interface RuntimeToolAdapter {
96
98
  readonly replacements: readonly RuntimeToolReplacement[];
97
99
  }
98
100
 
101
+ const MCP_RESOURCE_TOOL_NAMES = new Set([
102
+ "list_mcp_resources",
103
+ "list_mcp_resource_templates",
104
+ "read_mcp_resource",
105
+ ]);
106
+
107
+ // ponytail: Pi's built-in MCP registers tools only after connecting, so launch checks accept its
108
+ // tool names unverified; a vanished server's tools fail when called instead of at launch.
109
+ function isMcpToolName(toolName: string): boolean {
110
+ return toolName.startsWith("mcp__") || MCP_RESOURCE_TOOL_NAMES.has(toolName);
111
+ }
112
+
99
113
  /** Resolve the exact active tools permitted by one Child Agent Launch Contract. */
100
114
  export function resolveChildActiveToolNames(
101
115
  allowedToolNames: readonly string[],
@@ -130,24 +144,24 @@ function installChildToolCapabilityPolicy(
130
144
  session: AgentSession,
131
145
  allowedToolNames: readonly string[],
132
146
  runtimeToolAdapters: readonly RuntimeToolAdapter[],
133
- preserveGrantedTools = true,
134
147
  ): void {
148
+ const isScriptOnly = (name: string) => {
149
+ const exposure = session.getAllTools().find((tool) => tool.name === name)?.exposure;
150
+ return exposure === "codemode" || exposure === "deferred";
151
+ };
135
152
  const applyActiveTools = session.setActiveToolsByName.bind(session);
136
153
  session.setActiveToolsByName = (requestedToolNames) => {
137
- const permitted = resolveChildActiveToolNames(
138
- allowedToolNames,
139
- requestedToolNames,
140
- runtimeToolAdapters,
141
- );
142
- // Inside an exposure wrapper, ordinary tools may intentionally be hidden. The
143
- // outer policy restores grants before that wrapper applies its own selection.
154
+ const requested = new Set(requestedToolNames);
155
+ // Granted declared tools stay active; `codemode` and `deferred` grants stay
156
+ // callable without being declared unless an extension activates them.
144
157
  applyActiveTools(
145
- preserveGrantedTools
146
- ? permitted
147
- : permitted.filter((name) => requestedToolNames.includes(name)),
158
+ resolveChildActiveToolNames(allowedToolNames, requestedToolNames, runtimeToolAdapters).filter(
159
+ (name) => requested.has(name) || !isScriptOnly(name),
160
+ ),
148
161
  );
149
162
  };
150
- session.setActiveToolsByName(session.getActiveToolNames());
163
+ // Pi's `tools` option also activates script-only grants; drop them before extensions bind.
164
+ session.setActiveToolsByName(session.getActiveToolNames().filter((name) => !isScriptOnly(name)));
151
165
  }
152
166
 
153
167
  /** Moves one verified child session file to trash and reports command unavailability. */
@@ -180,6 +194,15 @@ export interface PiAgentSessionFactoryOptions {
180
194
  sessionFileTrash?: SessionFileTrashCapability;
181
195
  getCoordinatorTools: (callerId: string) => ToolDefinition[];
182
196
  onChildSessionActivity?: () => void;
197
+ observeSession?: (
198
+ session: AgentSession,
199
+ agentId: string,
200
+ resourceInputs: {
201
+ agentDir: string;
202
+ extensions: readonly Pick<Extension, "path" | "resolvedPath" | "sourceInfo" | "hidden">[];
203
+ flagValues: ReadonlyMap<string, boolean | string>;
204
+ },
205
+ ) => ChildSessionObserver | undefined;
183
206
  }
184
207
 
185
208
  /** Build one child prompt using the active delegation depth rather than persisted launch state. */
@@ -494,7 +517,7 @@ function collectChildTurnOutcome(
494
517
  error: "No terminal assistant response",
495
518
  };
496
519
  }
497
- if (finalAssistant.stopReason === "aborted") {
520
+ if (aborted || finalAssistant.stopReason === "aborted") {
498
521
  return {
499
522
  status: "cancelled",
500
523
  output: assistantText(finalAssistant),
@@ -554,6 +577,7 @@ class PiChildAgentRuntime implements ChildAgentRuntime {
554
577
  private readonly modelRuntime: ModelRuntime,
555
578
  private readonly modelById: ReadonlyMap<string, Model<any>>,
556
579
  onSessionActivity?: () => void,
580
+ private readonly observer?: ChildSessionObserver,
557
581
  ) {
558
582
  // Keep this child-only; AgentSession.setSteeringMode would overwrite the user's global setting.
559
583
  session.agent.steeringMode = "all";
@@ -613,12 +637,21 @@ class PiChildAgentRuntime implements ChildAgentRuntime {
613
637
 
614
638
  async abort(): Promise<void> {
615
639
  this.aborted = true;
616
- await this.session.abort();
640
+ await Promise.all([this.observer?.abort(), this.session.abort()]);
617
641
  }
618
642
 
619
- dispose(): void {
643
+ async dispose(): Promise<void> {
620
644
  this.unsubscribe();
621
- this.session.dispose();
645
+ try {
646
+ await this.observer?.dispose();
647
+ // Let child extensions release services such as MCP server connections.
648
+ const runner = this.session.extensionRunner;
649
+ if (runner.hasHandlers("session_shutdown")) {
650
+ await runner.emit({ type: "session_shutdown", reason: "quit" });
651
+ }
652
+ } finally {
653
+ this.session.dispose();
654
+ }
622
655
  }
623
656
 
624
657
  getRuntimeProfile(): RuntimeProfile | undefined {
@@ -715,7 +748,20 @@ class PiChildAgentRuntime implements ChildAgentRuntime {
715
748
 
716
749
  private async captureTurn(operation: () => Promise<void>): Promise<RuntimeTurnOutcome> {
717
750
  this.aborted = false;
718
- return captureChildTurnOutcome(this.session, operation, () => this.aborted);
751
+ this.observer?.beginTurn();
752
+ return captureChildTurnOutcome(
753
+ this.session,
754
+ async () => {
755
+ try {
756
+ await operation();
757
+ if (!this.aborted) await this.observer?.finishTurn();
758
+ } catch (error) {
759
+ await this.observer?.abort();
760
+ throw error;
761
+ }
762
+ },
763
+ () => this.aborted,
764
+ );
719
765
  }
720
766
 
721
767
  private async compactImportedContext(
@@ -1098,6 +1144,11 @@ export class PiAgentSessionFactory implements AgentSessionFactory {
1098
1144
  await resourceLoader.reload();
1099
1145
  for (const extension of resourceLoader.getExtensions().extensions) {
1100
1146
  for (const toolName of extension.tools.keys()) names.add(toolName);
1147
+ if (extension.path === "builtin:mcp") {
1148
+ for (const toolName of agent.launch_contract.ordinary_tools) {
1149
+ if (isMcpToolName(toolName)) names.add(toolName);
1150
+ }
1151
+ }
1101
1152
  }
1102
1153
  return names;
1103
1154
  })();
@@ -1145,7 +1196,6 @@ export class PiAgentSessionFactory implements AgentSessionFactory {
1145
1196
  ];
1146
1197
  const runtimeToolAdapters = this.options.getRuntimeToolAdapters?.() ?? [];
1147
1198
  const adapterToolNames = new Set(runtimeToolAdapters.flatMap((adapter) => adapter.toolNames));
1148
- const adaptRuntimeTools = adapterToolNames.size > 0;
1149
1199
  const { session } = await createAgentSession({
1150
1200
  cwd: this.options.cwd,
1151
1201
  agentDir: this.options.agentDir,
@@ -1158,20 +1208,16 @@ export class PiAgentSessionFactory implements AgentSessionFactory {
1158
1208
  settingsManager,
1159
1209
  modelRuntime,
1160
1210
  });
1161
- if (adaptRuntimeTools) session.setActiveToolsByName(allowedToolNames);
1162
- const initialActiveNames = new Set(session.getActiveToolNames());
1163
- const missingTools = allowedToolNames.filter((toolName) => !initialActiveNames.has(toolName));
1211
+ const loadedNames = new Set(session.getAllTools().map((tool) => tool.name));
1212
+ const missingTools = allowedToolNames.filter(
1213
+ (toolName) => !loadedNames.has(toolName) && !isMcpToolName(toolName),
1214
+ );
1164
1215
  if (missingTools.length > 0) {
1165
1216
  session.dispose();
1166
1217
  throw new Error(`Minimal subagents child tool loading failed: ${missingTools.join(", ")}`);
1167
1218
  }
1168
- // The inner policy bounds extension-selected exposure without restoring hidden ordinary tools.
1169
- installChildToolCapabilityPolicy(session, allowedToolNames, runtimeToolAdapters, false);
1170
- await session.bindExtensions({ mode: "print" });
1171
- // The outer policy filters names before extension wrappers build their own tool catalogues.
1172
1219
  installChildToolCapabilityPolicy(session, allowedToolNames, runtimeToolAdapters);
1173
- // Exposure policy may route granted Coordinator Tools through another tool;
1174
- // require their definitions to remain registered, not necessarily direct.
1220
+ await session.bindExtensions({ mode: "print" });
1175
1221
  const registeredNames = new Set(session.getAllTools().map((tool) => tool.name));
1176
1222
  const missingCoordinatorTools = coordinatorTools
1177
1223
  .map((tool) => tool.name)
@@ -1187,6 +1233,18 @@ export class PiAgentSessionFactory implements AgentSessionFactory {
1187
1233
  modelRuntime,
1188
1234
  this.modelById,
1189
1235
  this.options.onChildSessionActivity,
1236
+ this.options.observeSession?.(session, agent.agent_id, {
1237
+ agentDir: this.options.agentDir,
1238
+ extensions: resourceLoader
1239
+ .getExtensions()
1240
+ .extensions.map(({ path, resolvedPath, sourceInfo, hidden }) => ({
1241
+ path,
1242
+ resolvedPath,
1243
+ sourceInfo,
1244
+ hidden,
1245
+ })),
1246
+ flagValues: new Map(resourceLoader.getExtensions().runtime.flagValues),
1247
+ }),
1190
1248
  );
1191
1249
  }
1192
1250
 
@@ -1,308 +1,72 @@
1
1
  import type { JsonValue } from "@earendil-works/pi-ai";
2
- import { CONFIG_DIR_NAME, getAgentDir } from "@earendil-works/pi-coding-agent";
3
- import { randomUUID } from "node:crypto";
4
- import { mkdir, open, readFile, rename, rm, stat } from "node:fs/promises";
5
- import { basename, dirname, join, resolve } from "node:path";
6
- import lockfile from "proper-lockfile";
2
+ import { CONFIG_DIR_NAME } from "@earendil-works/pi-coding-agent";
3
+ import { updateFileLocked } from "@ian-pascoe/pi-utils/locked-file-update";
4
+ import { resolve } from "node:path";
7
5
 
8
6
  /** Identifies the standard Pi settings file changed by a Subagent Access command. */
9
7
  export type MinimalSubagentsSettingsScope = "global" | "project";
10
8
 
11
- /** Describes why a scoped minimal subagents settings write could not complete. */
12
- export type MinimalSubagentsSettingsWriteFailureReason =
13
- | "project-untrusted"
14
- | "malformed-json"
15
- | "incompatible-shape"
16
- | "filesystem";
17
-
18
- /** Reports an expected scoped settings failure without throwing through the command boundary. */
19
- export class MinimalSubagentsSettingsWriteError extends Error {
20
- readonly _tag = "MinimalSubagentsSettingsWriteError" as const;
21
-
22
- /** Create a settings write failure carrying its exact scope and path. */
23
- constructor(
24
- readonly scope: MinimalSubagentsSettingsScope,
25
- readonly path: string,
26
- readonly reason: MinimalSubagentsSettingsWriteFailureReason,
27
- message: string,
28
- readonly operation?: SettingsFilesystemOperation,
29
- cause?: unknown,
30
- ) {
31
- super(message, cause === undefined ? undefined : { cause });
32
- }
33
- }
34
-
35
- /** Returns the path changed by a successful write or a typed expected settings failure. */
36
- export type MinimalSubagentsSettingsWriteResult =
37
- | {
38
- readonly ok: true;
39
- readonly scope: MinimalSubagentsSettingsScope;
40
- readonly path: string;
41
- }
42
- | {
43
- readonly ok: false;
44
- readonly error: MinimalSubagentsSettingsWriteError;
45
- };
46
-
47
9
  /** Supplies only the Root Agent context needed to select and authorize a settings file. */
48
10
  export interface MinimalSubagentsSettingsWriteContext {
49
11
  readonly cwd: string;
50
12
  isProjectTrusted(): boolean;
51
13
  }
52
14
 
53
- type SettingsFilesystemOperation = "prepare" | "lock" | "read" | "write" | "release";
54
15
  type SettingsJsonObject = Record<string, JsonValue>;
55
16
 
56
- type ParsedSettingsDocument =
57
- | { readonly ok: true; readonly settings: SettingsJsonObject }
58
- | { readonly ok: false; readonly error: MinimalSubagentsSettingsWriteError };
59
-
60
- interface ExistingSettingsDocument {
61
- readonly settings: SettingsJsonObject;
62
- readonly mode: number;
63
- }
64
-
65
17
  function isSettingsJsonObject(value: JsonValue | undefined): value is SettingsJsonObject {
66
18
  // oxlint-disable-next-line anti-slop/no-runtime-typeof -- JSON.parse already established JSON data; distinguish object roots and settings blocks from primitives and arrays.
67
19
  return value !== null && typeof value === "object" && !Array.isArray(value);
68
20
  }
69
- const SETTINGS_LOCK_RETRY_DELAY_MS = 20;
70
- const SETTINGS_LOCK_RETRIES = 100;
71
- const NEW_SETTINGS_FILE_MODE = 0o600;
72
-
73
- function stripUtf8Bom(content: string): string {
74
- return content.charCodeAt(0) === 0xfeff ? content.slice(1) : content;
75
- }
76
21
 
77
- function settingsContractError(
78
- scope: MinimalSubagentsSettingsScope,
79
- path: string,
80
- detail: string,
81
- ): MinimalSubagentsSettingsWriteError {
82
- return new MinimalSubagentsSettingsWriteError(
83
- scope,
84
- path,
85
- "incompatible-shape",
86
- `Minimal subagents settings shape is incompatible for ${scope} settings at ${path}: ${detail}`,
87
- );
88
- }
89
-
90
- function parseSettingsDocument(
91
- content: string,
92
- scope: MinimalSubagentsSettingsScope,
93
- path: string,
94
- ): ParsedSettingsDocument {
22
+ function parseSettings(content: string | undefined, path: string): SettingsJsonObject {
23
+ if (content === undefined) return {};
95
24
  // JSON.parse is the provenance for this JSON type; the object shape is checked below.
96
25
  let parsed: JsonValue;
97
26
  try {
98
- parsed = JSON.parse(stripUtf8Bom(content));
27
+ parsed = JSON.parse(content.charCodeAt(0) === 0xfeff ? content.slice(1) : content);
99
28
  } catch (cause) {
100
- return {
101
- ok: false,
102
- error: new MinimalSubagentsSettingsWriteError(
103
- scope,
104
- path,
105
- "malformed-json",
106
- `Minimal subagents settings JSON is malformed for ${scope} settings at ${path}`,
107
- "read",
108
- cause,
109
- ),
110
- };
29
+ throw new Error(`Minimal subagents settings JSON is malformed at ${path}`, { cause });
111
30
  }
112
-
113
31
  if (!isSettingsJsonObject(parsed)) {
114
- return { ok: false, error: settingsContractError(scope, path, "expected an object root") };
115
- }
116
- const minimalSubagents = parsed.minimalSubagents;
117
- if (minimalSubagents !== undefined && !isSettingsJsonObject(minimalSubagents)) {
118
- return {
119
- ok: false,
120
- error: settingsContractError(
121
- scope,
122
- path,
123
- "expected minimalSubagents to be an object or absent",
124
- ),
125
- };
126
- }
127
- return { ok: true, settings: parsed };
128
- }
129
-
130
- function mutateMinimalSubagentsEnabled(
131
- settings: SettingsJsonObject,
132
- enabled: boolean | undefined,
133
- ): void {
134
- const currentMinimalSubagents = settings.minimalSubagents;
135
- const minimalSubagents = isSettingsJsonObject(currentMinimalSubagents)
136
- ? currentMinimalSubagents
137
- : {};
138
-
139
- if (enabled === undefined) {
140
- delete minimalSubagents.enabled;
141
- } else {
142
- minimalSubagents.enabled = enabled;
32
+ throw new Error(`Minimal subagents settings at ${path} must have an object root`);
143
33
  }
144
-
145
- if (Object.keys(minimalSubagents).length === 0) {
146
- delete settings.minimalSubagents;
147
- } else {
148
- settings.minimalSubagents = minimalSubagents;
149
- }
150
- }
151
-
152
- function filesystemWriteError(
153
- scope: MinimalSubagentsSettingsScope,
154
- path: string,
155
- operation: SettingsFilesystemOperation,
156
- cause: unknown,
157
- ): MinimalSubagentsSettingsWriteError {
158
- return new MinimalSubagentsSettingsWriteError(
159
- scope,
160
- path,
161
- "filesystem",
162
- `Minimal subagents settings ${operation} failed for ${scope} settings at ${path}`,
163
- operation,
164
- cause,
165
- );
166
- }
167
-
168
- async function readSettingsDocumentUnderLock(
169
- scope: MinimalSubagentsSettingsScope,
170
- path: string,
171
- ): Promise<ExistingSettingsDocument | MinimalSubagentsSettingsWriteError> {
172
- let fileMode = NEW_SETTINGS_FILE_MODE;
173
- let content: string;
174
- try {
175
- const fileStat = await stat(path);
176
- fileMode = fileStat.mode & 0o7777;
177
- content = await readFile(path, "utf8");
178
- } catch (cause) {
179
- if (isNodeErrorWithCode(cause, "ENOENT")) return { settings: {}, mode: fileMode };
180
- return filesystemWriteError(scope, path, "read", cause);
181
- }
182
-
183
- const parsed = parseSettingsDocument(content, scope, path);
184
- return parsed.ok ? { settings: parsed.settings, mode: fileMode } : parsed.error;
185
- }
186
-
187
- function isNodeErrorWithCode(cause: unknown, code: string): boolean {
188
- return cause instanceof Error && "code" in cause && cause.code === code;
189
- }
190
-
191
- async function replaceSettingsFileAtomically(
192
- path: string,
193
- settings: SettingsJsonObject,
194
- mode: number,
195
- temporaryId: string,
196
- ): Promise<void> {
197
- const temporaryPath = join(dirname(path), `.${basename(path)}.${process.pid}.${temporaryId}.tmp`);
198
- let temporaryFile: Awaited<ReturnType<typeof open>> | undefined;
199
- try {
200
- temporaryFile = await open(temporaryPath, "wx", mode);
201
- await temporaryFile.chmod(mode);
202
- await temporaryFile.writeFile(`${JSON.stringify(settings, undefined, 2)}\n`, "utf8");
203
- await temporaryFile.sync();
204
- await temporaryFile.close();
205
- temporaryFile = undefined;
206
- await rename(temporaryPath, path);
207
- } catch (cause) {
208
- if (temporaryFile !== undefined) await temporaryFile.close().catch(() => undefined);
209
- await rm(temporaryPath, { force: true }).catch(() => undefined);
210
- throw cause;
34
+ if (parsed.minimalSubagents !== undefined && !isSettingsJsonObject(parsed.minimalSubagents)) {
35
+ throw new Error(`Minimal subagents settings at ${path} must have an object minimalSubagents`);
211
36
  }
37
+ return parsed;
212
38
  }
213
39
 
214
40
  /**
215
- * Mutates only `minimalSubagents.enabled` in global or trusted-project Pi settings.
216
- * Calls targeting the same file are serialized in process and re-read while holding Pi's lock.
41
+ * Set or remove `minimalSubagents.enabled` in global or trusted-project Pi settings,
42
+ * preserving every unrelated setting. Writes re-read the file under Pi's settings lock.
43
+ * @returns The changed settings path.
217
44
  */
218
- export class MinimalSubagentsSettingsWriter {
219
- private readonly globalSettingsPath: string;
220
- private readonly projectSettingsPath: string;
221
-
222
- /** Bind settings paths to one Root Agent context; the directory supplier exists for isolated tests. */
223
- constructor(
224
- private readonly context: MinimalSubagentsSettingsWriteContext,
225
- getAgentDirectory: () => string = getAgentDir,
226
- private readonly createTemporaryId: () => string = randomUUID,
227
- ) {
228
- this.globalSettingsPath = resolve(getAgentDirectory(), "settings.json");
229
- this.projectSettingsPath = resolve(context.cwd, CONFIG_DIR_NAME, "settings.json");
230
- }
231
-
232
- /** Set or remove one authored Subagent Access default, preserving every unrelated setting. */
233
- async writeMinimalSubagentsEnabled(
234
- scope: MinimalSubagentsSettingsScope,
235
- enabled: boolean | undefined,
236
- ): Promise<MinimalSubagentsSettingsWriteResult> {
237
- const path = scope === "global" ? this.globalSettingsPath : this.projectSettingsPath;
238
- if (scope === "project" && !this.context.isProjectTrusted()) {
239
- return {
240
- ok: false,
241
- error: new MinimalSubagentsSettingsWriteError(
242
- scope,
243
- path,
244
- "project-untrusted",
245
- `Minimal subagents project settings write refused because the project is not trusted: ${path}`,
246
- ),
247
- };
248
- }
249
-
250
- return this.writeEnabledUnderLock(scope, path, enabled);
45
+ export async function writeMinimalSubagentsEnabled(
46
+ context: MinimalSubagentsSettingsWriteContext,
47
+ agentDirectory: string,
48
+ scope: MinimalSubagentsSettingsScope,
49
+ enabled: boolean | undefined,
50
+ ): Promise<string> {
51
+ const path =
52
+ scope === "global"
53
+ ? resolve(agentDirectory, "settings.json")
54
+ : resolve(context.cwd, CONFIG_DIR_NAME, "settings.json");
55
+ if (scope === "project" && !context.isProjectTrusted()) {
56
+ throw new Error(
57
+ `Minimal subagents project settings write refused because the project is not trusted: ${path}`,
58
+ );
251
59
  }
252
60
 
253
- private async writeEnabledUnderLock(
254
- scope: MinimalSubagentsSettingsScope,
255
- path: string,
256
- enabled: boolean | undefined,
257
- ): Promise<MinimalSubagentsSettingsWriteResult> {
258
- try {
259
- await mkdir(dirname(path), { recursive: true });
260
- } catch (cause) {
261
- return { ok: false, error: filesystemWriteError(scope, path, "prepare", cause) };
262
- }
263
-
264
- let release: (() => Promise<void>) | undefined;
265
- try {
266
- release = await lockfile.lock(path, {
267
- realpath: false,
268
- retries: {
269
- retries: SETTINGS_LOCK_RETRIES,
270
- factor: 1,
271
- minTimeout: SETTINGS_LOCK_RETRY_DELAY_MS,
272
- maxTimeout: SETTINGS_LOCK_RETRY_DELAY_MS,
273
- randomize: false,
274
- },
275
- });
276
- } catch (cause) {
277
- return { ok: false, error: filesystemWriteError(scope, path, "lock", cause) };
278
- }
279
-
280
- let result: MinimalSubagentsSettingsWriteResult;
281
- try {
282
- const existing = await readSettingsDocumentUnderLock(scope, path);
283
- if (existing instanceof MinimalSubagentsSettingsWriteError) {
284
- result = { ok: false, error: existing };
285
- } else {
286
- try {
287
- mutateMinimalSubagentsEnabled(existing.settings, enabled);
288
- await replaceSettingsFileAtomically(
289
- path,
290
- existing.settings,
291
- existing.mode,
292
- this.createTemporaryId(),
293
- );
294
- result = { ok: true, scope, path };
295
- } catch (cause) {
296
- result = { ok: false, error: filesystemWriteError(scope, path, "write", cause) };
297
- }
298
- }
299
- } finally {
300
- try {
301
- await release();
302
- } catch (cause) {
303
- result = { ok: false, error: filesystemWriteError(scope, path, "release", cause) };
304
- }
305
- }
306
- return result;
307
- }
61
+ await updateFileLocked(path, (content) => {
62
+ const settings = parseSettings(content, path);
63
+ const current = settings.minimalSubagents;
64
+ const minimalSubagents = isSettingsJsonObject(current) ? { ...current } : {};
65
+ if (enabled === undefined) delete minimalSubagents.enabled;
66
+ else minimalSubagents.enabled = enabled;
67
+ if (Object.keys(minimalSubagents).length === 0) delete settings.minimalSubagents;
68
+ else settings.minimalSubagents = minimalSubagents;
69
+ return `${JSON.stringify(settings, undefined, 2)}\n`;
70
+ });
71
+ return path;
308
72
  }
@@ -125,6 +125,7 @@ function structuredToolResult<TDetails extends CoordinatorToolResultDetails>(
125
125
  return {
126
126
  content: [{ type: "text" as const, text: truncated.content }],
127
127
  details: result,
128
+ structuredContent: JSON.parse(json),
128
129
  };
129
130
  }
130
131
 
@@ -235,13 +236,15 @@ export function createCoordinatorToolDefinitions(
235
236
  signal,
236
237
  parameters.turn_id,
237
238
  );
239
+ const details = {
240
+ ...result,
241
+ source_agent_id: result.agent_id,
242
+ source_turn_id: result.turn_id,
243
+ };
238
244
  return {
239
245
  ...structuredToolResult(result),
240
- details: {
241
- ...result,
242
- source_agent_id: result.agent_id,
243
- source_turn_id: result.turn_id,
244
- },
246
+ details,
247
+ structuredContent: JSON.parse(JSON.stringify(details)),
245
248
  };
246
249
  });
247
250
  } finally {
@@ -224,6 +224,14 @@ export interface CoordinatorMessage {
224
224
  };
225
225
  }
226
226
 
227
+ /** Optional review work attached by the root and awaited by the existing child owner. */
228
+ export interface ChildSessionObserver {
229
+ beginTurn(): void;
230
+ finishTurn(): Promise<void>;
231
+ abort(): Promise<void>;
232
+ dispose(): Promise<void>;
233
+ }
234
+
227
235
  /** Process-local adapter around one SDK-created Pi child session. */
228
236
  export interface ChildAgentRuntime {
229
237
  readonly sessionLeafId: string | undefined;
@@ -238,7 +246,7 @@ export interface ChildAgentRuntime {
238
246
  /** Queue one typed coordinator message into the child session. */
239
247
  queueCoordinatorMessage(message: CoordinatorMessage): Promise<void>;
240
248
  abort(): Promise<void>;
241
- dispose(): void;
249
+ dispose(): void | Promise<void>;
242
250
  /** Return the live Runtime Profile, or undefined when the SDK session has no model. */
243
251
  getRuntimeProfile(): RuntimeProfile | undefined;
244
252
  /** Return the effective ordinary tools after child extensions apply runtime adapters. */