@ian-pascoe/pi-minimal-subagents 0.9.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.9.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(
@@ -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,
@@ -138,16 +138,39 @@ function currentConversationMessages(context: ExtensionContext): AgentMessage[]
138
138
  return snapshotCommittedContext(messages, !context.isIdle());
139
139
  }
140
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
+
141
166
  function rootCallerSnapshot(pi: ExtensionAPI, context: ExtensionContext): CallerSnapshot {
142
167
  if (!context.model) throw new Error("Minimal subagents spawn: root has no effective model");
143
- const activeTools = excludeCoordinatorTools(pi.getActiveTools());
144
- const availableTools = excludeCoordinatorTools(pi.getAllTools().map((tool) => tool.name));
145
168
  return {
146
169
  messages: currentConversationMessages(context),
147
170
  model: `${context.model.provider}/${context.model.id}`,
148
171
  thinkingLevel: context.thinkingLevel ?? pi.getThinkingLevel(),
149
- ordinaryTools: activeTools,
150
- capabilityCeiling: availableTools,
172
+ ordinaryTools: reachableToolNames(pi),
173
+ capabilityCeiling: availableToolNames(pi),
151
174
  spawnEntryId: context.sessionManager.getLeafId() ?? "root",
152
175
  };
153
176
  }
@@ -561,9 +584,6 @@ export class MinimalSubagentsLifecycleController {
561
584
  ) {
562
585
  models.push(context.model);
563
586
  }
564
- const availableToolNames = excludeCoordinatorTools(
565
- this.pi.getAllTools().map((tool) => tool.name),
566
- );
567
587
  const schemas = createCoordinatorToolSchemas(eligibleModelIds);
568
588
  let activeCoordinator: MinimalSubagentsCoordinator | undefined;
569
589
  const requireActiveCoordinator = (): MinimalSubagentsCoordinator => {
@@ -581,7 +601,7 @@ export class MinimalSubagentsLifecycleController {
581
601
  models,
582
602
  eligibleModelIds,
583
603
  modelScopeRestricted: enabledModelPatterns !== undefined,
584
- availableToolNames,
604
+ availableToolNames: availableToolNames(this.pi),
585
605
  getRuntimeToolAdapters: () => codexConversionRuntimeToolAdapters(this.pi),
586
606
  projectTrusted: context.isProjectTrusted(),
587
607
  maxSubagentDepth: minimalSubagentsConfig.maxSubagentDepth,
@@ -816,15 +836,15 @@ export class MinimalSubagentsLifecycleController {
816
836
 
817
837
  let persistedScope: "global" | "project" | undefined;
818
838
  if (command.scope !== "session") {
819
- const writeResult = await new MinimalSubagentsSettingsWriter(
820
- context,
821
- () => accessSession.agentDir,
822
- ).writeMinimalSubagentsEnabled(
823
- command.scope,
824
- command.action === "reset" ? undefined : command.action === "enable",
825
- );
826
- if (!writeResult.ok) {
827
- 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");
828
848
  return;
829
849
  }
830
850
  persistedScope = command.scope;
@@ -98,6 +98,18 @@ export interface RuntimeToolAdapter {
98
98
  readonly replacements: readonly RuntimeToolReplacement[];
99
99
  }
100
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
+
101
113
  /** Resolve the exact active tools permitted by one Child Agent Launch Contract. */
102
114
  export function resolveChildActiveToolNames(
103
115
  allowedToolNames: readonly string[],
@@ -132,24 +144,24 @@ function installChildToolCapabilityPolicy(
132
144
  session: AgentSession,
133
145
  allowedToolNames: readonly string[],
134
146
  runtimeToolAdapters: readonly RuntimeToolAdapter[],
135
- preserveGrantedTools = true,
136
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
+ };
137
152
  const applyActiveTools = session.setActiveToolsByName.bind(session);
138
153
  session.setActiveToolsByName = (requestedToolNames) => {
139
- const permitted = resolveChildActiveToolNames(
140
- allowedToolNames,
141
- requestedToolNames,
142
- runtimeToolAdapters,
143
- );
144
- // Inside an exposure wrapper, ordinary tools may intentionally be hidden. The
145
- // 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.
146
157
  applyActiveTools(
147
- preserveGrantedTools
148
- ? permitted
149
- : permitted.filter((name) => requestedToolNames.includes(name)),
158
+ resolveChildActiveToolNames(allowedToolNames, requestedToolNames, runtimeToolAdapters).filter(
159
+ (name) => requested.has(name) || !isScriptOnly(name),
160
+ ),
150
161
  );
151
162
  };
152
- 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)));
153
165
  }
154
166
 
155
167
  /** Moves one verified child session file to trash and reports command unavailability. */
@@ -632,6 +644,11 @@ class PiChildAgentRuntime implements ChildAgentRuntime {
632
644
  this.unsubscribe();
633
645
  try {
634
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
+ }
635
652
  } finally {
636
653
  this.session.dispose();
637
654
  }
@@ -1127,6 +1144,11 @@ export class PiAgentSessionFactory implements AgentSessionFactory {
1127
1144
  await resourceLoader.reload();
1128
1145
  for (const extension of resourceLoader.getExtensions().extensions) {
1129
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
+ }
1130
1152
  }
1131
1153
  return names;
1132
1154
  })();
@@ -1174,7 +1196,6 @@ export class PiAgentSessionFactory implements AgentSessionFactory {
1174
1196
  ];
1175
1197
  const runtimeToolAdapters = this.options.getRuntimeToolAdapters?.() ?? [];
1176
1198
  const adapterToolNames = new Set(runtimeToolAdapters.flatMap((adapter) => adapter.toolNames));
1177
- const adaptRuntimeTools = adapterToolNames.size > 0;
1178
1199
  const { session } = await createAgentSession({
1179
1200
  cwd: this.options.cwd,
1180
1201
  agentDir: this.options.agentDir,
@@ -1187,20 +1208,16 @@ export class PiAgentSessionFactory implements AgentSessionFactory {
1187
1208
  settingsManager,
1188
1209
  modelRuntime,
1189
1210
  });
1190
- if (adaptRuntimeTools) session.setActiveToolsByName(allowedToolNames);
1191
- const initialActiveNames = new Set(session.getActiveToolNames());
1192
- 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
+ );
1193
1215
  if (missingTools.length > 0) {
1194
1216
  session.dispose();
1195
1217
  throw new Error(`Minimal subagents child tool loading failed: ${missingTools.join(", ")}`);
1196
1218
  }
1197
- // The inner policy bounds extension-selected exposure without restoring hidden ordinary tools.
1198
- installChildToolCapabilityPolicy(session, allowedToolNames, runtimeToolAdapters, false);
1199
- await session.bindExtensions({ mode: "print" });
1200
- // The outer policy filters names before extension wrappers build their own tool catalogues.
1201
1219
  installChildToolCapabilityPolicy(session, allowedToolNames, runtimeToolAdapters);
1202
- // Exposure policy may route granted Coordinator Tools through another tool;
1203
- // require their definitions to remain registered, not necessarily direct.
1220
+ await session.bindExtensions({ mode: "print" });
1204
1221
  const registeredNames = new Set(session.getAllTools().map((tool) => tool.name));
1205
1222
  const missingCoordinatorTools = coordinatorTools
1206
1223
  .map((tool) => tool.name)
@@ -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 {