@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.
- package/README.md +58 -1
- package/package.json +7 -1
- package/skills/pi-minimal-subagents/SKILL.md +7 -6
- package/src/minimal-subagents-access.ts +150 -0
- package/src/minimal-subagents-child-resources.ts +102 -0
- package/src/minimal-subagents-command.ts +63 -0
- package/src/minimal-subagents-config.ts +44 -1
- package/src/minimal-subagents-context.ts +128 -2
- package/src/minimal-subagents-coordinator.ts +25 -2
- package/src/minimal-subagents-extension.ts +222 -3
- package/src/minimal-subagents-fork-lifecycle.ts +1 -1
- package/src/minimal-subagents-paths.ts +8 -0
- package/src/minimal-subagents-sessions.ts +136 -95
- package/src/minimal-subagents-settings-writer.ts +307 -0
- package/src/minimal-subagents-status-panel.ts +483 -0
- package/src/minimal-subagents-tool-schemas.ts +4 -1
- package/src/minimal-subagents-types.ts +16 -0
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { execFile } from "node:child_process";
|
|
2
|
-
import { existsSync,
|
|
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
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
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 =
|
|
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 =
|
|
952
|
-
|
|
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 =
|
|
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 =
|
|
991
|
-
|
|
992
|
-
|
|
993
|
-
|
|
994
|
-
|
|
995
|
-
|
|
996
|
-
|
|
997
|
-
|
|
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
|
-
|
|
1031
|
-
const
|
|
1032
|
-
const missingTools = allowedToolNames.filter((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
|
+
}
|