@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.
- 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 +144 -104
- 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[],
|
|
@@ -579,15 +585,14 @@ class PiChildAgentRuntime implements ChildAgentRuntime {
|
|
|
579
585
|
}
|
|
580
586
|
|
|
581
587
|
async queueCoordinatorMessage(message: CoordinatorMessage): Promise<void> {
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
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 =
|
|
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 =
|
|
952
|
-
|
|
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 =
|
|
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 =
|
|
991
|
-
|
|
992
|
-
|
|
993
|
-
|
|
994
|
-
|
|
995
|
-
|
|
996
|
-
|
|
997
|
-
|
|
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
|
-
|
|
1031
|
-
const
|
|
1032
|
-
const missingTools = allowedToolNames.filter((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
|
+
}
|