@ian-pascoe/pi-minimal-subagents 0.5.0 → 0.6.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.
@@ -1,5 +1,5 @@
1
1
  import { execFile } from "node:child_process";
2
- import { existsSync, realpathSync, writeFileSync } from "node:fs";
2
+ import { existsSync, writeFileSync } from "node:fs";
3
3
  import { unlink } from "node:fs/promises";
4
4
  import { resolve } from "node:path";
5
5
  import type { AgentMessage, ThinkingLevel } from "@earendil-works/pi-agent-core";
@@ -8,7 +8,6 @@ import { clampThinkingLevel } from "@earendil-works/pi-ai/compat";
8
8
  import {
9
9
  AgentSession,
10
10
  createAgentSession,
11
- DefaultResourceLoader,
12
11
  estimateTokens,
13
12
  findCutPoint,
14
13
  generateSummaryWithUsage,
@@ -24,18 +23,22 @@ import type { Static, TSchema } from "typebox";
24
23
  import { Value } from "typebox/value";
25
24
  import {
26
25
  buildSubagentSystemPrompt,
26
+ selectChildAgentTranscript,
27
27
  snapshotCommittedContext,
28
28
  } from "./minimal-subagents-context.js";
29
29
  import {
30
30
  canAgentContractSpawn,
31
+ COORDINATOR_TOOL_NAMES,
31
32
  DEFAULT_MAX_SUBAGENT_DEPTH,
32
33
  getSubagentDepth,
33
34
  } from "./minimal-subagents-capabilities.js";
35
+ import { createChildResourceLoader } from "./minimal-subagents-child-resources.js";
34
36
  import {
35
37
  CHILD_IDENTITY_ENTRY_TYPE,
36
38
  FORK_CLONE_ENTRY_TYPE,
37
39
  FORK_OWNERSHIP_ENTRY_TYPE,
38
40
  } from "./minimal-subagents-registry.js";
41
+ import { canonicalPath } from "./minimal-subagents-paths.js";
39
42
  import {
40
43
  ChildSessionIdentityRecordSchema,
41
44
  DeliveryEvidenceDetailsSchema,
@@ -49,10 +52,10 @@ import { addMinimalSubagentsUsage } from "./minimal-subagents-usage.js";
49
52
  import type {
50
53
  AgentSessionFactory,
51
54
  ChildAgentRuntime,
55
+ ChildAgentTranscriptSnapshot,
52
56
  CoordinatorMessage,
53
57
  PersistedAgent,
54
58
  PersistedSessionIdentity,
55
- ProjectContextMode,
56
59
  RuntimeProfile,
57
60
  RuntimeTurnOutcome,
58
61
  } from "./minimal-subagents-types.js";
@@ -75,14 +78,64 @@ interface PersistentIdentityOptions {
75
78
  rootSessionId: string;
76
79
  }
77
80
 
78
- interface ChildResourceLoaderOptionsInput {
79
- cwd: string;
80
- agentDir: string;
81
- projectContext: ProjectContextMode;
82
- extensionEntrypoint: string;
83
- systemPromptBlock: string;
84
- ordinaryToolNames?: readonly string[];
85
- settingsManager?: SettingsManager;
81
+ /** Replaces the complete source group only while every runtime tool is active. */
82
+ export interface RuntimeToolReplacement {
83
+ /** Launch Contract tools required to authorize this replacement. */
84
+ readonly sourceToolNames: readonly string[];
85
+ /** Adapter tools that jointly replace the source group. */
86
+ readonly runtimeToolNames: readonly string[];
87
+ }
88
+
89
+ /** Identifies one configured extension allowed to replace Child Agent runtime tools. */
90
+ export interface RuntimeToolAdapter {
91
+ /** Every tool registered by the adapter, used to defer initial tool selection until it loads. */
92
+ readonly toolNames: readonly string[];
93
+ /** Capability-preserving replacements recognized for this adapter. */
94
+ readonly replacements: readonly RuntimeToolReplacement[];
95
+ }
96
+
97
+ /** Resolve the exact active tools permitted by one Child Agent Launch Contract. */
98
+ export function resolveChildActiveToolNames(
99
+ allowedToolNames: readonly string[],
100
+ requestedToolNames: readonly string[],
101
+ runtimeToolAdapters: readonly RuntimeToolAdapter[],
102
+ ): string[] {
103
+ const allowedNames = new Set(allowedToolNames);
104
+ const requestedNames = new Set(requestedToolNames);
105
+ const activeReplacements = runtimeToolAdapters
106
+ .flatMap((adapter) => adapter.replacements)
107
+ .filter((replacement) =>
108
+ replacement.sourceToolNames.every((toolName) => allowedNames.has(toolName)),
109
+ )
110
+ .filter((replacement) =>
111
+ replacement.runtimeToolNames.every((toolName) => requestedNames.has(toolName)),
112
+ );
113
+ const replacedSourceNames = new Set(
114
+ activeReplacements.flatMap((replacement) => replacement.sourceToolNames),
115
+ );
116
+ const activeRuntimeNames = new Set(
117
+ activeReplacements.flatMap((replacement) => replacement.runtimeToolNames),
118
+ );
119
+ return [
120
+ ...new Set([
121
+ ...allowedToolNames.filter((toolName) => !replacedSourceNames.has(toolName)),
122
+ ...requestedToolNames.filter((toolName) => activeRuntimeNames.has(toolName)),
123
+ ]),
124
+ ];
125
+ }
126
+
127
+ function installChildToolCapabilityPolicy(
128
+ session: AgentSession,
129
+ allowedToolNames: readonly string[],
130
+ runtimeToolAdapters: readonly RuntimeToolAdapter[],
131
+ ): void {
132
+ const applyActiveTools = session.setActiveToolsByName.bind(session);
133
+ session.setActiveToolsByName = (requestedToolNames) => {
134
+ applyActiveTools(
135
+ resolveChildActiveToolNames(allowedToolNames, requestedToolNames, runtimeToolAdapters),
136
+ );
137
+ };
138
+ session.setActiveToolsByName(session.getActiveToolNames());
86
139
  }
87
140
 
88
141
  /** Moves one verified child session file to trash and reports command unavailability. */
@@ -109,6 +162,7 @@ export interface PiAgentSessionFactoryOptions {
109
162
  eligibleModelIds: readonly string[];
110
163
  modelScopeRestricted: boolean;
111
164
  availableToolNames: readonly string[];
165
+ getRuntimeToolAdapters?: () => readonly RuntimeToolAdapter[];
112
166
  projectTrusted: boolean;
113
167
  maxSubagentDepth?: number;
114
168
  sessionFileTrash?: SessionFileTrashCapability;
@@ -131,12 +185,6 @@ export function buildDepthBoundSubagentPrompt(
131
185
  });
132
186
  }
133
187
 
134
- /** Canonicalize one path, resolving symlinks only when the target exists. */
135
- export function canonicalPath(path: string): string {
136
- const absolutePath = resolve(path);
137
- return existsSync(absolutePath) ? realpathSync(absolutePath) : absolutePath;
138
- }
139
-
140
188
  /** Build the shared unavailable-agent projection used by fork recovery and ownership binding. */
141
189
  export function unavailableAgent(
142
190
  agent: PersistedAgent,
@@ -358,48 +406,6 @@ export function verifyChildSessionIdentity(
358
406
  }
359
407
  }
360
408
 
361
- /** Build child resources while filtering recursive coordinator loading and honoring project-context omission. */
362
- export function createChildResourceLoaderOptions(
363
- input: ChildResourceLoaderOptionsInput,
364
- ): ConstructorParameters<typeof DefaultResourceLoader>[0] {
365
- const extensionEntrypoint = canonicalPath(input.extensionEntrypoint);
366
- const omitProjectContext = input.projectContext === "omit";
367
- const ordinaryToolNames = new Set(input.ordinaryToolNames ?? []);
368
- const loadOrdinaryToolExtensions = [...ordinaryToolNames].some(
369
- (toolName) => !PI_BUILTIN_ORDINARY_TOOL_NAMES.has(toolName),
370
- );
371
- return {
372
- cwd: input.cwd,
373
- agentDir: input.agentDir,
374
- settingsManager: input.settingsManager,
375
- noExtensions: !loadOrdinaryToolExtensions,
376
- noContextFiles: omitProjectContext,
377
- noSkills: omitProjectContext,
378
- noPromptTemplates: omitProjectContext,
379
- extensionsOverride: loadOrdinaryToolExtensions
380
- ? (base) => ({
381
- ...base,
382
- extensions: base.extensions.filter(
383
- (extension) =>
384
- canonicalPath(extension.resolvedPath) !== extensionEntrypoint &&
385
- [...extension.tools.keys()].some((toolName) => ordinaryToolNames.has(toolName)),
386
- ),
387
- errors: base.errors.filter((error) => canonicalPath(error.path) !== extensionEntrypoint),
388
- })
389
- : undefined,
390
- agentsFilesOverride: omitProjectContext ? () => ({ agentsFiles: [] }) : undefined,
391
- skillsOverride: omitProjectContext
392
- ? (base) => ({ skills: [], diagnostics: base.diagnostics })
393
- : undefined,
394
- promptsOverride: omitProjectContext
395
- ? (base) => ({ prompts: [], diagnostics: base.diagnostics })
396
- : undefined,
397
- systemPromptOverride: omitProjectContext ? () => undefined : undefined,
398
- appendSystemPromptOverride: (base) =>
399
- omitProjectContext ? [input.systemPromptBlock] : [...base, input.systemPromptBlock],
400
- };
401
- }
402
-
403
409
  /** Find durable keyed evidence for exactly-once wait or custom-result delivery. */
404
410
  export function findDeliveryEvidence(
405
411
  entries: readonly SessionEntry[],
@@ -609,6 +615,11 @@ class PiChildAgentRuntime implements ChildAgentRuntime {
609
615
  };
610
616
  }
611
617
 
618
+ getActiveToolNames(): string[] {
619
+ const coordinatorTools = new Set<string>(COORDINATOR_TOOL_NAMES);
620
+ return this.session.getActiveToolNames().filter((toolName) => !coordinatorTools.has(toolName));
621
+ }
622
+
612
623
  snapshotCommittedMessages(): AgentMessage[] {
613
624
  return snapshotCommittedContext(this.session.messages, this.session.isStreaming);
614
625
  }
@@ -618,6 +629,26 @@ class PiChildAgentRuntime implements ChildAgentRuntime {
618
629
  return [...this.session.messages, ...(streamingMessage ? [streamingMessage] : [])];
619
630
  }
620
631
 
632
+ snapshotActivityTranscript(): ChildAgentTranscriptSnapshot {
633
+ const snapshot = selectChildAgentTranscript(
634
+ this.session.messages,
635
+ this.session.state.streamingMessage,
636
+ );
637
+ const toolNames = new Set<string>();
638
+ for (const message of snapshot.messages) {
639
+ if (message.role !== "assistant") continue;
640
+ for (const content of message.content) {
641
+ if (content.type === "toolCall") toolNames.add(content.name);
642
+ }
643
+ }
644
+ return {
645
+ ...snapshot,
646
+ toolDefinitions: [...toolNames]
647
+ .map((toolName) => this.session.getToolDefinition(toolName))
648
+ .filter((definition) => definition !== undefined),
649
+ };
650
+ }
651
+
621
652
  hasDeliveryEvidence(sourceAgentId: string, sourceTurnId: string, deliveryId?: string): boolean {
622
653
  return findDeliveryEvidence(
623
654
  this.session.sessionManager.getBranch(),
@@ -933,11 +964,7 @@ export class PiAgentSessionFactory implements AgentSessionFactory {
933
964
  }
934
965
 
935
966
  private discoverChildToolNames(agent: PersistedAgent): Promise<Set<string>> {
936
- const cacheKey = `${agent.launch_contract.project_context}:${[
937
- ...agent.launch_contract.ordinary_tools,
938
- ]
939
- .sort()
940
- .join(",")}`;
967
+ const cacheKey = [...agent.launch_contract.ordinary_tools].sort().join(",");
941
968
  const cached = this.discoveredToolNames.get(cacheKey);
942
969
  if (cached) return cached;
943
970
  const discovery = (async () => {
@@ -948,20 +975,15 @@ export class PiAgentSessionFactory implements AgentSessionFactory {
948
975
  (name) => !PI_BUILTIN_ORDINARY_TOOL_NAMES.has(name),
949
976
  );
950
977
  if (!requiresCustomToolDiscovery) return names;
951
- const settingsManager = SettingsManager.create(this.options.cwd, this.options.agentDir, {
952
- projectTrusted: this.options.projectTrusted,
978
+ const settingsManager = this.createChildSettingsManager();
979
+ const resourceLoader = createChildResourceLoader({
980
+ cwd: this.options.cwd,
981
+ agentDir: this.options.agentDir,
982
+ projectContext: agent.launch_contract.project_context,
983
+ extensionEntrypoint: this.options.extensionEntrypoint,
984
+ systemPromptBlock: this.buildChildSystemPrompt(agent),
985
+ settingsManager,
953
986
  });
954
- const resourceLoader = new DefaultResourceLoader(
955
- createChildResourceLoaderOptions({
956
- cwd: this.options.cwd,
957
- agentDir: this.options.agentDir,
958
- projectContext: agent.launch_contract.project_context,
959
- extensionEntrypoint: this.options.extensionEntrypoint,
960
- systemPromptBlock: this.buildChildSystemPrompt(agent),
961
- ordinaryToolNames: agent.launch_contract.ordinary_tools,
962
- settingsManager,
963
- }),
964
- );
965
987
  await resourceLoader.reload();
966
988
  for (const extension of resourceLoader.getExtensions().extensions) {
967
989
  for (const toolName of extension.tools.keys()) names.add(toolName);
@@ -981,23 +1003,18 @@ export class PiAgentSessionFactory implements AgentSessionFactory {
981
1003
  throw new Error(
982
1004
  `Minimal subagents restore: model unavailable: ${agent.launch_contract.model}`,
983
1005
  );
984
- const settingsManager = SettingsManager.create(this.options.cwd, this.options.agentDir, {
985
- projectTrusted: this.options.projectTrusted,
986
- });
1006
+ const settingsManager = this.createChildSettingsManager();
987
1007
  settingsManager.applyOverrides({
988
1008
  retry: { enabled: false, maxRetries: 0, provider: { maxRetries: 0 } },
989
1009
  });
990
- const resourceLoader = new DefaultResourceLoader(
991
- createChildResourceLoaderOptions({
992
- cwd: this.options.cwd,
993
- agentDir: this.options.agentDir,
994
- projectContext: agent.launch_contract.project_context,
995
- extensionEntrypoint: this.options.extensionEntrypoint,
996
- systemPromptBlock: this.buildChildSystemPrompt(agent),
997
- ordinaryToolNames: agent.launch_contract.ordinary_tools,
998
- settingsManager,
999
- }),
1000
- );
1010
+ const resourceLoader = createChildResourceLoader({
1011
+ cwd: this.options.cwd,
1012
+ agentDir: this.options.agentDir,
1013
+ projectContext: agent.launch_contract.project_context,
1014
+ extensionEntrypoint: this.options.extensionEntrypoint,
1015
+ systemPromptBlock: this.buildChildSystemPrompt(agent),
1016
+ settingsManager,
1017
+ });
1001
1018
  await resourceLoader.reload();
1002
1019
  const modelRuntime = await ModelRuntime.create({
1003
1020
  authPath: resolve(this.options.agentDir, "auth.json"),
@@ -1015,25 +1032,43 @@ export class PiAgentSessionFactory implements AgentSessionFactory {
1015
1032
  ...agent.launch_contract.ordinary_tools,
1016
1033
  ...coordinatorTools.map((tool) => tool.name),
1017
1034
  ];
1035
+ const runtimeToolAdapters = this.options.getRuntimeToolAdapters?.() ?? [];
1036
+ const adapterToolNames = new Set(runtimeToolAdapters.flatMap((adapter) => adapter.toolNames));
1037
+ const adaptRuntimeTools = adapterToolNames.size > 0;
1018
1038
  const { session } = await createAgentSession({
1019
1039
  cwd: this.options.cwd,
1020
1040
  agentDir: this.options.agentDir,
1021
1041
  model,
1022
1042
  thinkingLevel: agent.launch_contract.thinking_level,
1023
- tools: allowedToolNames,
1043
+ tools: adaptRuntimeTools ? undefined : allowedToolNames,
1024
1044
  customTools: coordinatorTools,
1025
1045
  resourceLoader,
1026
1046
  sessionManager,
1027
1047
  settingsManager,
1028
1048
  modelRuntime,
1029
1049
  });
1030
- await session.bindExtensions({ mode: "print" });
1031
- const activeNames = new Set(session.getActiveToolNames());
1032
- const missingTools = allowedToolNames.filter((toolName) => !activeNames.has(toolName));
1050
+ if (adaptRuntimeTools) session.setActiveToolsByName(allowedToolNames);
1051
+ const initialActiveNames = new Set(session.getActiveToolNames());
1052
+ const missingTools = allowedToolNames.filter((toolName) => !initialActiveNames.has(toolName));
1033
1053
  if (missingTools.length > 0) {
1034
1054
  session.dispose();
1035
1055
  throw new Error(`Minimal subagents child tool loading failed: ${missingTools.join(", ")}`);
1036
1056
  }
1057
+ // The inner policy filters names added by extension wrappers such as Pi CodeMode.
1058
+ installChildToolCapabilityPolicy(session, allowedToolNames, runtimeToolAdapters);
1059
+ await session.bindExtensions({ mode: "print" });
1060
+ // The outer policy filters names before extension wrappers build their own tool catalogues.
1061
+ installChildToolCapabilityPolicy(session, allowedToolNames, runtimeToolAdapters);
1062
+ const activeNames = new Set(session.getActiveToolNames());
1063
+ const missingCoordinatorTools = coordinatorTools
1064
+ .map((tool) => tool.name)
1065
+ .filter((toolName) => !activeNames.has(toolName));
1066
+ if (missingCoordinatorTools.length > 0) {
1067
+ session.dispose();
1068
+ throw new Error(
1069
+ `Minimal subagents child coordinator tool loading failed: ${missingCoordinatorTools.join(", ")}`,
1070
+ );
1071
+ }
1037
1072
  return new PiChildAgentRuntime(
1038
1073
  session,
1039
1074
  modelRuntime,
@@ -1041,4 +1076,10 @@ export class PiAgentSessionFactory implements AgentSessionFactory {
1041
1076
  this.options.onChildSessionActivity,
1042
1077
  );
1043
1078
  }
1079
+
1080
+ private createChildSettingsManager(): SettingsManager {
1081
+ return SettingsManager.create(this.options.cwd, this.options.agentDir, {
1082
+ projectTrusted: this.options.projectTrusted,
1083
+ });
1084
+ }
1044
1085
  }
@@ -0,0 +1,307 @@
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";
7
+ import { Type } from "typebox";
8
+ import { Value } from "typebox/value";
9
+
10
+ /** Identifies the standard Pi settings file changed by a Subagent Access command. */
11
+ export type MinimalSubagentsSettingsScope = "global" | "project";
12
+
13
+ /** Describes why a scoped minimal subagents settings write could not complete. */
14
+ export type MinimalSubagentsSettingsWriteFailureReason =
15
+ | "project-untrusted"
16
+ | "malformed-json"
17
+ | "incompatible-shape"
18
+ | "filesystem";
19
+
20
+ /** Reports an expected scoped settings failure without throwing through the command boundary. */
21
+ export class MinimalSubagentsSettingsWriteError extends Error {
22
+ readonly _tag = "MinimalSubagentsSettingsWriteError" as const;
23
+
24
+ /** Create a settings write failure carrying its exact scope and path. */
25
+ constructor(
26
+ readonly scope: MinimalSubagentsSettingsScope,
27
+ readonly path: string,
28
+ readonly reason: MinimalSubagentsSettingsWriteFailureReason,
29
+ message: string,
30
+ readonly operation?: SettingsFilesystemOperation,
31
+ cause?: unknown,
32
+ ) {
33
+ super(message, cause === undefined ? undefined : { cause });
34
+ }
35
+ }
36
+
37
+ /** Returns the path changed by a successful write or a typed expected settings failure. */
38
+ export type MinimalSubagentsSettingsWriteResult =
39
+ | {
40
+ readonly ok: true;
41
+ readonly scope: MinimalSubagentsSettingsScope;
42
+ readonly path: string;
43
+ }
44
+ | {
45
+ readonly ok: false;
46
+ readonly error: MinimalSubagentsSettingsWriteError;
47
+ };
48
+
49
+ /** Supplies only the Root Agent context needed to select and authorize a settings file. */
50
+ export interface MinimalSubagentsSettingsWriteContext {
51
+ readonly cwd: string;
52
+ isProjectTrusted(): boolean;
53
+ }
54
+
55
+ type SettingsFilesystemOperation = "prepare" | "lock" | "read" | "write" | "release";
56
+ type SettingsJsonObject = Record<string, JsonValue>;
57
+
58
+ type ParsedSettingsDocument =
59
+ | { readonly ok: true; readonly settings: SettingsJsonObject }
60
+ | { readonly ok: false; readonly error: MinimalSubagentsSettingsWriteError };
61
+
62
+ interface ExistingSettingsDocument {
63
+ readonly settings: SettingsJsonObject;
64
+ readonly mode: number;
65
+ }
66
+
67
+ const JsonValueSchema = Type.Unsafe<JsonValue>({});
68
+ const SettingsJsonObjectSchema = Type.Record(Type.String(), JsonValueSchema);
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
+
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 {
95
+ let parsed: unknown;
96
+ try {
97
+ parsed = JSON.parse(stripUtf8Bom(content));
98
+ } catch (cause) {
99
+ return {
100
+ ok: false,
101
+ error: new MinimalSubagentsSettingsWriteError(
102
+ scope,
103
+ path,
104
+ "malformed-json",
105
+ `Minimal subagents settings JSON is malformed for ${scope} settings at ${path}`,
106
+ "read",
107
+ cause,
108
+ ),
109
+ };
110
+ }
111
+
112
+ if (!Value.Check(SettingsJsonObjectSchema, parsed)) {
113
+ return { ok: false, error: settingsContractError(scope, path, "expected an object root") };
114
+ }
115
+ const minimalSubagents = parsed.minimalSubagents;
116
+ if (minimalSubagents !== undefined && !Value.Check(SettingsJsonObjectSchema, minimalSubagents)) {
117
+ return {
118
+ ok: false,
119
+ error: settingsContractError(
120
+ scope,
121
+ path,
122
+ "expected minimalSubagents to be an object or absent",
123
+ ),
124
+ };
125
+ }
126
+ return { ok: true, settings: parsed };
127
+ }
128
+
129
+ function mutateMinimalSubagentsEnabled(
130
+ settings: SettingsJsonObject,
131
+ enabled: boolean | undefined,
132
+ ): void {
133
+ const currentMinimalSubagents = settings.minimalSubagents;
134
+ const minimalSubagents = Value.Check(SettingsJsonObjectSchema, currentMinimalSubagents)
135
+ ? currentMinimalSubagents
136
+ : {};
137
+
138
+ if (enabled === undefined) {
139
+ delete minimalSubagents.enabled;
140
+ } else {
141
+ minimalSubagents.enabled = enabled;
142
+ }
143
+
144
+ if (Object.keys(minimalSubagents).length === 0) {
145
+ delete settings.minimalSubagents;
146
+ } else {
147
+ settings.minimalSubagents = minimalSubagents;
148
+ }
149
+ }
150
+
151
+ function filesystemWriteError(
152
+ scope: MinimalSubagentsSettingsScope,
153
+ path: string,
154
+ operation: SettingsFilesystemOperation,
155
+ cause: unknown,
156
+ ): MinimalSubagentsSettingsWriteError {
157
+ return new MinimalSubagentsSettingsWriteError(
158
+ scope,
159
+ path,
160
+ "filesystem",
161
+ `Minimal subagents settings ${operation} failed for ${scope} settings at ${path}`,
162
+ operation,
163
+ cause,
164
+ );
165
+ }
166
+
167
+ async function readSettingsDocumentUnderLock(
168
+ scope: MinimalSubagentsSettingsScope,
169
+ path: string,
170
+ ): Promise<ExistingSettingsDocument | MinimalSubagentsSettingsWriteError> {
171
+ let fileMode = NEW_SETTINGS_FILE_MODE;
172
+ let content: string;
173
+ try {
174
+ const fileStat = await stat(path);
175
+ fileMode = fileStat.mode & 0o7777;
176
+ content = await readFile(path, "utf8");
177
+ } catch (cause) {
178
+ if (isNodeErrorWithCode(cause, "ENOENT")) return { settings: {}, mode: fileMode };
179
+ return filesystemWriteError(scope, path, "read", cause);
180
+ }
181
+
182
+ const parsed = parseSettingsDocument(content, scope, path);
183
+ return parsed.ok ? { settings: parsed.settings, mode: fileMode } : parsed.error;
184
+ }
185
+
186
+ function isNodeErrorWithCode(cause: unknown, code: string): boolean {
187
+ return cause instanceof Error && "code" in cause && cause.code === code;
188
+ }
189
+
190
+ async function replaceSettingsFileAtomically(
191
+ path: string,
192
+ settings: SettingsJsonObject,
193
+ mode: number,
194
+ temporaryId: string,
195
+ ): Promise<void> {
196
+ const temporaryPath = join(dirname(path), `.${basename(path)}.${process.pid}.${temporaryId}.tmp`);
197
+ let temporaryFile: Awaited<ReturnType<typeof open>> | undefined;
198
+ try {
199
+ temporaryFile = await open(temporaryPath, "wx", mode);
200
+ await temporaryFile.chmod(mode);
201
+ await temporaryFile.writeFile(`${JSON.stringify(settings, undefined, 2)}\n`, "utf8");
202
+ await temporaryFile.sync();
203
+ await temporaryFile.close();
204
+ temporaryFile = undefined;
205
+ await rename(temporaryPath, path);
206
+ } catch (cause) {
207
+ if (temporaryFile !== undefined) await temporaryFile.close().catch(() => undefined);
208
+ await rm(temporaryPath, { force: true }).catch(() => undefined);
209
+ throw cause;
210
+ }
211
+ }
212
+
213
+ /**
214
+ * Mutates only `minimalSubagents.enabled` in global or trusted-project Pi settings.
215
+ * Calls targeting the same file are serialized in process and re-read while holding Pi's lock.
216
+ */
217
+ export class MinimalSubagentsSettingsWriter {
218
+ private readonly globalSettingsPath: string;
219
+ private readonly projectSettingsPath: string;
220
+
221
+ /** Bind settings paths to one Root Agent context; the directory supplier exists for isolated tests. */
222
+ constructor(
223
+ private readonly context: MinimalSubagentsSettingsWriteContext,
224
+ getAgentDirectory: () => string = getAgentDir,
225
+ private readonly createTemporaryId: () => string = randomUUID,
226
+ ) {
227
+ this.globalSettingsPath = resolve(getAgentDirectory(), "settings.json");
228
+ this.projectSettingsPath = resolve(context.cwd, CONFIG_DIR_NAME, "settings.json");
229
+ }
230
+
231
+ /** Set or remove one authored Subagent Access default, preserving every unrelated setting. */
232
+ async writeMinimalSubagentsEnabled(
233
+ scope: MinimalSubagentsSettingsScope,
234
+ enabled: boolean | undefined,
235
+ ): Promise<MinimalSubagentsSettingsWriteResult> {
236
+ const path = scope === "global" ? this.globalSettingsPath : this.projectSettingsPath;
237
+ if (scope === "project" && !this.context.isProjectTrusted()) {
238
+ return {
239
+ ok: false,
240
+ error: new MinimalSubagentsSettingsWriteError(
241
+ scope,
242
+ path,
243
+ "project-untrusted",
244
+ `Minimal subagents project settings write refused because the project is not trusted: ${path}`,
245
+ ),
246
+ };
247
+ }
248
+
249
+ return this.writeEnabledUnderLock(scope, path, enabled);
250
+ }
251
+
252
+ private async writeEnabledUnderLock(
253
+ scope: MinimalSubagentsSettingsScope,
254
+ path: string,
255
+ enabled: boolean | undefined,
256
+ ): Promise<MinimalSubagentsSettingsWriteResult> {
257
+ try {
258
+ await mkdir(dirname(path), { recursive: true });
259
+ } catch (cause) {
260
+ return { ok: false, error: filesystemWriteError(scope, path, "prepare", cause) };
261
+ }
262
+
263
+ let release: (() => Promise<void>) | undefined;
264
+ try {
265
+ release = await lockfile.lock(path, {
266
+ realpath: false,
267
+ retries: {
268
+ retries: SETTINGS_LOCK_RETRIES,
269
+ factor: 1,
270
+ minTimeout: SETTINGS_LOCK_RETRY_DELAY_MS,
271
+ maxTimeout: SETTINGS_LOCK_RETRY_DELAY_MS,
272
+ randomize: false,
273
+ },
274
+ });
275
+ } catch (cause) {
276
+ return { ok: false, error: filesystemWriteError(scope, path, "lock", cause) };
277
+ }
278
+
279
+ let result: MinimalSubagentsSettingsWriteResult;
280
+ try {
281
+ const existing = await readSettingsDocumentUnderLock(scope, path);
282
+ if (existing instanceof MinimalSubagentsSettingsWriteError) {
283
+ result = { ok: false, error: existing };
284
+ } else {
285
+ try {
286
+ mutateMinimalSubagentsEnabled(existing.settings, enabled);
287
+ await replaceSettingsFileAtomically(
288
+ path,
289
+ existing.settings,
290
+ existing.mode,
291
+ this.createTemporaryId(),
292
+ );
293
+ result = { ok: true, scope, path };
294
+ } catch (cause) {
295
+ result = { ok: false, error: filesystemWriteError(scope, path, "write", cause) };
296
+ }
297
+ }
298
+ } finally {
299
+ try {
300
+ await release();
301
+ } catch (cause) {
302
+ result = { ok: false, error: filesystemWriteError(scope, path, "release", cause) };
303
+ }
304
+ }
305
+ return result;
306
+ }
307
+ }