@ian-pascoe/pi-minimal-subagents 0.9.0 → 0.10.1

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.1",
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": "*",
@@ -2,6 +2,7 @@
2
2
  name: pi-minimal-subagents
3
3
  description: Configure or diagnose Pi Minimal Subagents for Subagent Access, missing Coordinator Tools, spawn, capability, delivery, wait, restore, reload, or fork failures.
4
4
  license: MIT
5
+ disable-model-invocation: true
5
6
  ---
6
7
 
7
8
  # Pi Minimal Subagents
@@ -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(
@@ -34,6 +34,7 @@ import {
34
34
  type DeliveryLedger,
35
35
  type DeliveryLedgerTransition,
36
36
  } from "./minimal-subagents-delivery-ledger.js";
37
+ import { withTroubleshootingHint } from "./troubleshooting-skill.js";
37
38
  import { addCoordinatorMessageEnvelope } from "./minimal-subagents-message-envelope.js";
38
39
  import { unavailableAgent } from "./minimal-subagents-sessions.js";
39
40
  import { createRegistryEvent } from "./minimal-subagents-registry.js";
@@ -264,7 +265,9 @@ export class MinimalSubagentsCoordinator {
264
265
  const requiredMissing = missingDependencies.filter((name) => !optionalMissing.has(name));
265
266
  if (requiredMissing.length > 0) {
266
267
  throw new Error(
267
- `Minimal subagents launch dependencies unavailable: ${requiredMissing.join(", ")}`,
268
+ withTroubleshootingHint(
269
+ `Minimal subagents launch dependencies unavailable: ${requiredMissing.join(", ")}`,
270
+ ),
268
271
  );
269
272
  }
270
273
  if (optionalMissing.size > 0) {
@@ -381,7 +384,7 @@ export class MinimalSubagentsCoordinator {
381
384
  agent_id: targetId,
382
385
  message_id: messageId,
383
386
  disposition: "failed",
384
- error: deliveryError,
387
+ error: withTroubleshootingHint(deliveryError),
385
388
  };
386
389
  }
387
390
  }
@@ -925,7 +928,7 @@ export class MinimalSubagentsCoordinator {
925
928
  turn_id: turnId,
926
929
  status: "failed",
927
930
  output: "",
928
- error: error instanceof Error ? error.message : String(error),
931
+ error: withTroubleshootingHint(error instanceof Error ? error.message : String(error)),
929
932
  });
930
933
  }
931
934
  }
@@ -1366,7 +1369,7 @@ export class MinimalSubagentsCoordinator {
1366
1369
  turn_id: turnId,
1367
1370
  status: "failed",
1368
1371
  output: "",
1369
- error: cause instanceof Error ? cause.message : String(cause),
1372
+ error: withTroubleshootingHint(cause instanceof Error ? cause.message : String(cause)),
1370
1373
  });
1371
1374
  }
1372
1375
  });
@@ -1634,12 +1637,16 @@ export class MinimalSubagentsCoordinator {
1634
1637
  const agent = this.requireAgent(agentId);
1635
1638
  if (agent.availability === "unavailable") {
1636
1639
  throw new Error(
1637
- agent.unavailable_reason ?? `Minimal subagents ${operation}: ${agentId} is unavailable`,
1640
+ withTroubleshootingHint(
1641
+ agent.unavailable_reason ?? `Minimal subagents ${operation}: ${agentId} is unavailable`,
1642
+ ),
1638
1643
  );
1639
1644
  }
1640
1645
  if (agent.clone_error || !agent.session_file) {
1641
1646
  throw new Error(
1642
- `Minimal subagents ${operation}: ${agent.clone_error ?? `${agentId} has no child session`}`,
1647
+ withTroubleshootingHint(
1648
+ `Minimal subagents ${operation}: ${agent.clone_error ?? `${agentId} has no child session`}`,
1649
+ ),
1643
1650
  );
1644
1651
  }
1645
1652
  return agent;
@@ -62,8 +62,9 @@ import {
62
62
  type RuntimeToolAdapter,
63
63
  unavailableAgent,
64
64
  } from "./minimal-subagents-sessions.js";
65
+ import { withTroubleshootingHint } from "./troubleshooting-skill.js";
65
66
  import { shutdownMinimalSubagentsSession } from "./minimal-subagents-shutdown.js";
66
- import { MinimalSubagentsSettingsWriter } from "./minimal-subagents-settings-writer.js";
67
+ import { writeMinimalSubagentsEnabled } from "./minimal-subagents-settings-writer.js";
67
68
  import {
68
69
  MinimalSubagentsStatusPanelController,
69
70
  type MinimalSubagentsStatusAccess,
@@ -138,16 +139,42 @@ function currentConversationMessages(context: ExtensionContext): AgentMessage[]
138
139
  return snapshotCommittedContext(messages, !context.isIdle());
139
140
  }
140
141
 
142
+ export function availableToolNames(pi: Pick<ExtensionAPI, "getAllTools">): string[] {
143
+ return excludeCoordinatorTools(
144
+ pi
145
+ .getAllTools()
146
+ .filter((tool) => tool.exposure !== "hidden")
147
+ .map((tool) => tool.name),
148
+ );
149
+ }
150
+
151
+ /** Reachable Tools: active tools plus tools scripts can call without declaring them. */
152
+ export function reachableToolNames(
153
+ pi: Pick<ExtensionAPI, "getActiveTools" | "getAllTools">,
154
+ ): string[] {
155
+ const active = new Set(pi.getActiveTools());
156
+ return excludeCoordinatorTools(
157
+ pi
158
+ .getAllTools()
159
+ .filter(
160
+ (tool) =>
161
+ active.has(tool.name) || tool.exposure === "codemode" || tool.exposure === "deferred",
162
+ )
163
+ .map((tool) => tool.name),
164
+ );
165
+ }
166
+
141
167
  function rootCallerSnapshot(pi: ExtensionAPI, context: ExtensionContext): CallerSnapshot {
142
- 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));
168
+ if (!context.model)
169
+ throw new Error(
170
+ withTroubleshootingHint("Minimal subagents spawn: root has no effective model"),
171
+ );
145
172
  return {
146
173
  messages: currentConversationMessages(context),
147
174
  model: `${context.model.provider}/${context.model.id}`,
148
175
  thinkingLevel: context.thinkingLevel ?? pi.getThinkingLevel(),
149
- ordinaryTools: activeTools,
150
- capabilityCeiling: availableTools,
176
+ ordinaryTools: reachableToolNames(pi),
177
+ capabilityCeiling: availableToolNames(pi),
151
178
  spawnEntryId: context.sessionManager.getLeafId() ?? "root",
152
179
  };
153
180
  }
@@ -561,9 +588,6 @@ export class MinimalSubagentsLifecycleController {
561
588
  ) {
562
589
  models.push(context.model);
563
590
  }
564
- const availableToolNames = excludeCoordinatorTools(
565
- this.pi.getAllTools().map((tool) => tool.name),
566
- );
567
591
  const schemas = createCoordinatorToolSchemas(eligibleModelIds);
568
592
  let activeCoordinator: MinimalSubagentsCoordinator | undefined;
569
593
  const requireActiveCoordinator = (): MinimalSubagentsCoordinator => {
@@ -581,7 +605,7 @@ export class MinimalSubagentsLifecycleController {
581
605
  models,
582
606
  eligibleModelIds,
583
607
  modelScopeRestricted: enabledModelPatterns !== undefined,
584
- availableToolNames,
608
+ availableToolNames: availableToolNames(this.pi),
585
609
  getRuntimeToolAdapters: () => codexConversionRuntimeToolAdapters(this.pi),
586
610
  projectTrusted: context.isProjectTrusted(),
587
611
  maxSubagentDepth: minimalSubagentsConfig.maxSubagentDepth,
@@ -816,15 +840,15 @@ export class MinimalSubagentsLifecycleController {
816
840
 
817
841
  let persistedScope: "global" | "project" | undefined;
818
842
  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");
843
+ try {
844
+ await writeMinimalSubagentsEnabled(
845
+ context,
846
+ accessSession.agentDir,
847
+ command.scope,
848
+ command.action === "reset" ? undefined : command.action === "enable",
849
+ );
850
+ } catch (error) {
851
+ context.ui.notify(error instanceof Error ? error.message : String(error), "error");
828
852
  return;
829
853
  }
830
854
  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
  }
@@ -10,6 +10,7 @@ import {
10
10
  type ToolRenderResultOptions,
11
11
  } from "@earendil-works/pi-coding-agent";
12
12
  import type { MinimalSubagentsCoordinator } from "./minimal-subagents-coordinator.js";
13
+ import { withTroubleshootingHint } from "./troubleshooting-skill.js";
13
14
  import type { MinimalSubagentsModelRole } from "./minimal-subagents-config.js";
14
15
  import {
15
16
  renderCoordinatorToolCall,
@@ -125,6 +126,7 @@ function structuredToolResult<TDetails extends CoordinatorToolResultDetails>(
125
126
  return {
126
127
  content: [{ type: "text" as const, text: truncated.content }],
127
128
  details: result,
129
+ structuredContent: JSON.parse(json),
128
130
  };
129
131
  }
130
132
 
@@ -134,7 +136,7 @@ function failedStructuredOperation(prefix: string, result: DeleteResult): never
134
136
  maxBytes: DEFAULT_MAX_BYTES,
135
137
  maxLines: DEFAULT_MAX_LINES,
136
138
  });
137
- throw new Error(`${prefix}: ${truncated.content}`);
139
+ throw new Error(withTroubleshootingHint(`${prefix}: ${truncated.content}`));
138
140
  }
139
141
 
140
142
  function callerSourceTurnId(
@@ -235,13 +237,15 @@ export function createCoordinatorToolDefinitions(
235
237
  signal,
236
238
  parameters.turn_id,
237
239
  );
240
+ const details = {
241
+ ...result,
242
+ source_agent_id: result.agent_id,
243
+ source_turn_id: result.turn_id,
244
+ };
238
245
  return {
239
246
  ...structuredToolResult(result),
240
- details: {
241
- ...result,
242
- source_agent_id: result.agent_id,
243
- source_turn_id: result.turn_id,
244
- },
247
+ details,
248
+ structuredContent: JSON.parse(JSON.stringify(details)),
245
249
  };
246
250
  });
247
251
  } finally {
@@ -0,0 +1,14 @@
1
+ import { fileURLToPath } from "node:url";
2
+
3
+ /** Absolute path of this package's user-invoked troubleshooting Skill. */
4
+ export const TROUBLESHOOTING_SKILL_PATH = fileURLToPath(
5
+ new URL("../skills/pi-minimal-subagents/SKILL.md", import.meta.url),
6
+ );
7
+
8
+ /** Model-facing pointer appended to failures this package's Skill diagnoses. */
9
+ export const TROUBLESHOOTING_HINT = `For diagnosis, read the pi-minimal-subagents troubleshooting Skill at ${TROUBLESHOOTING_SKILL_PATH}.`;
10
+
11
+ /** Append the troubleshooting Skill pointer to a model-facing configuration or runtime failure. */
12
+ export function withTroubleshootingHint(message: string): string {
13
+ return `${message}\n\n${TROUBLESHOOTING_HINT}`;
14
+ }