@ian-pascoe/pi-minimal-subagents 0.5.0 → 0.6.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.
@@ -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[],
@@ -579,15 +585,14 @@ class PiChildAgentRuntime implements ChildAgentRuntime {
579
585
  }
580
586
 
581
587
  async queueCoordinatorMessage(message: CoordinatorMessage): Promise<void> {
582
- await this.session.sendCustomMessage(
583
- {
584
- customType: message.customType,
585
- content: message.content,
586
- display: true,
587
- details: message.details,
588
- },
589
- { triggerTurn: true, deliverAs: "steer" },
590
- );
588
+ this.session.agent.steer({
589
+ role: "custom",
590
+ customType: message.customType,
591
+ content: message.content,
592
+ display: true,
593
+ details: message.details,
594
+ timestamp: Date.now(),
595
+ });
591
596
  }
592
597
 
593
598
  async abort(): Promise<void> {
@@ -609,6 +614,11 @@ class PiChildAgentRuntime implements ChildAgentRuntime {
609
614
  };
610
615
  }
611
616
 
617
+ getActiveToolNames(): string[] {
618
+ const coordinatorTools = new Set<string>(COORDINATOR_TOOL_NAMES);
619
+ return this.session.getActiveToolNames().filter((toolName) => !coordinatorTools.has(toolName));
620
+ }
621
+
612
622
  snapshotCommittedMessages(): AgentMessage[] {
613
623
  return snapshotCommittedContext(this.session.messages, this.session.isStreaming);
614
624
  }
@@ -618,6 +628,26 @@ class PiChildAgentRuntime implements ChildAgentRuntime {
618
628
  return [...this.session.messages, ...(streamingMessage ? [streamingMessage] : [])];
619
629
  }
620
630
 
631
+ snapshotActivityTranscript(): ChildAgentTranscriptSnapshot {
632
+ const snapshot = selectChildAgentTranscript(
633
+ this.session.messages,
634
+ this.session.state.streamingMessage,
635
+ );
636
+ const toolNames = new Set<string>();
637
+ for (const message of snapshot.messages) {
638
+ if (message.role !== "assistant") continue;
639
+ for (const content of message.content) {
640
+ if (content.type === "toolCall") toolNames.add(content.name);
641
+ }
642
+ }
643
+ return {
644
+ ...snapshot,
645
+ toolDefinitions: [...toolNames]
646
+ .map((toolName) => this.session.getToolDefinition(toolName))
647
+ .filter((definition) => definition !== undefined),
648
+ };
649
+ }
650
+
621
651
  hasDeliveryEvidence(sourceAgentId: string, sourceTurnId: string, deliveryId?: string): boolean {
622
652
  return findDeliveryEvidence(
623
653
  this.session.sessionManager.getBranch(),
@@ -933,11 +963,7 @@ export class PiAgentSessionFactory implements AgentSessionFactory {
933
963
  }
934
964
 
935
965
  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(",")}`;
966
+ const cacheKey = [...agent.launch_contract.ordinary_tools].sort().join(",");
941
967
  const cached = this.discoveredToolNames.get(cacheKey);
942
968
  if (cached) return cached;
943
969
  const discovery = (async () => {
@@ -948,20 +974,15 @@ export class PiAgentSessionFactory implements AgentSessionFactory {
948
974
  (name) => !PI_BUILTIN_ORDINARY_TOOL_NAMES.has(name),
949
975
  );
950
976
  if (!requiresCustomToolDiscovery) return names;
951
- const settingsManager = SettingsManager.create(this.options.cwd, this.options.agentDir, {
952
- projectTrusted: this.options.projectTrusted,
977
+ const settingsManager = this.createChildSettingsManager();
978
+ const resourceLoader = createChildResourceLoader({
979
+ cwd: this.options.cwd,
980
+ agentDir: this.options.agentDir,
981
+ projectContext: agent.launch_contract.project_context,
982
+ extensionEntrypoint: this.options.extensionEntrypoint,
983
+ systemPromptBlock: this.buildChildSystemPrompt(agent),
984
+ settingsManager,
953
985
  });
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
986
  await resourceLoader.reload();
966
987
  for (const extension of resourceLoader.getExtensions().extensions) {
967
988
  for (const toolName of extension.tools.keys()) names.add(toolName);
@@ -981,23 +1002,18 @@ export class PiAgentSessionFactory implements AgentSessionFactory {
981
1002
  throw new Error(
982
1003
  `Minimal subagents restore: model unavailable: ${agent.launch_contract.model}`,
983
1004
  );
984
- const settingsManager = SettingsManager.create(this.options.cwd, this.options.agentDir, {
985
- projectTrusted: this.options.projectTrusted,
986
- });
1005
+ const settingsManager = this.createChildSettingsManager();
987
1006
  settingsManager.applyOverrides({
988
1007
  retry: { enabled: false, maxRetries: 0, provider: { maxRetries: 0 } },
989
1008
  });
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
- );
1009
+ const resourceLoader = createChildResourceLoader({
1010
+ cwd: this.options.cwd,
1011
+ agentDir: this.options.agentDir,
1012
+ projectContext: agent.launch_contract.project_context,
1013
+ extensionEntrypoint: this.options.extensionEntrypoint,
1014
+ systemPromptBlock: this.buildChildSystemPrompt(agent),
1015
+ settingsManager,
1016
+ });
1001
1017
  await resourceLoader.reload();
1002
1018
  const modelRuntime = await ModelRuntime.create({
1003
1019
  authPath: resolve(this.options.agentDir, "auth.json"),
@@ -1015,25 +1031,43 @@ export class PiAgentSessionFactory implements AgentSessionFactory {
1015
1031
  ...agent.launch_contract.ordinary_tools,
1016
1032
  ...coordinatorTools.map((tool) => tool.name),
1017
1033
  ];
1034
+ const runtimeToolAdapters = this.options.getRuntimeToolAdapters?.() ?? [];
1035
+ const adapterToolNames = new Set(runtimeToolAdapters.flatMap((adapter) => adapter.toolNames));
1036
+ const adaptRuntimeTools = adapterToolNames.size > 0;
1018
1037
  const { session } = await createAgentSession({
1019
1038
  cwd: this.options.cwd,
1020
1039
  agentDir: this.options.agentDir,
1021
1040
  model,
1022
1041
  thinkingLevel: agent.launch_contract.thinking_level,
1023
- tools: allowedToolNames,
1042
+ tools: adaptRuntimeTools ? undefined : allowedToolNames,
1024
1043
  customTools: coordinatorTools,
1025
1044
  resourceLoader,
1026
1045
  sessionManager,
1027
1046
  settingsManager,
1028
1047
  modelRuntime,
1029
1048
  });
1030
- await session.bindExtensions({ mode: "print" });
1031
- const activeNames = new Set(session.getActiveToolNames());
1032
- const missingTools = allowedToolNames.filter((toolName) => !activeNames.has(toolName));
1049
+ if (adaptRuntimeTools) session.setActiveToolsByName(allowedToolNames);
1050
+ const initialActiveNames = new Set(session.getActiveToolNames());
1051
+ const missingTools = allowedToolNames.filter((toolName) => !initialActiveNames.has(toolName));
1033
1052
  if (missingTools.length > 0) {
1034
1053
  session.dispose();
1035
1054
  throw new Error(`Minimal subagents child tool loading failed: ${missingTools.join(", ")}`);
1036
1055
  }
1056
+ // The inner policy filters names added by extension wrappers such as Pi CodeMode.
1057
+ installChildToolCapabilityPolicy(session, allowedToolNames, runtimeToolAdapters);
1058
+ await session.bindExtensions({ mode: "print" });
1059
+ // The outer policy filters names before extension wrappers build their own tool catalogues.
1060
+ installChildToolCapabilityPolicy(session, allowedToolNames, runtimeToolAdapters);
1061
+ const activeNames = new Set(session.getActiveToolNames());
1062
+ const missingCoordinatorTools = coordinatorTools
1063
+ .map((tool) => tool.name)
1064
+ .filter((toolName) => !activeNames.has(toolName));
1065
+ if (missingCoordinatorTools.length > 0) {
1066
+ session.dispose();
1067
+ throw new Error(
1068
+ `Minimal subagents child coordinator tool loading failed: ${missingCoordinatorTools.join(", ")}`,
1069
+ );
1070
+ }
1037
1071
  return new PiChildAgentRuntime(
1038
1072
  session,
1039
1073
  modelRuntime,
@@ -1041,4 +1075,10 @@ export class PiAgentSessionFactory implements AgentSessionFactory {
1041
1075
  this.options.onChildSessionActivity,
1042
1076
  );
1043
1077
  }
1078
+
1079
+ private createChildSettingsManager(): SettingsManager {
1080
+ return SettingsManager.create(this.options.cwd, this.options.agentDir, {
1081
+ projectTrusted: this.options.projectTrusted,
1082
+ });
1083
+ }
1044
1084
  }
@@ -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
+ }