@ian-pascoe/pi-minimal-subagents 0.9.0 → 0.10.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 +15 -11
- package/package.json +3 -6
- package/src/minimal-subagents-child-resources.ts +21 -0
- package/src/minimal-subagents-extension.ts +38 -18
- package/src/minimal-subagents-sessions.ts +39 -22
- package/src/minimal-subagents-settings-writer.ts +39 -275
- package/src/minimal-subagents-tools.ts +8 -5
package/README.md
CHANGED
|
@@ -156,9 +156,8 @@ spawn attempts; the root retains recursive hierarchy management.
|
|
|
156
156
|
|
|
157
157
|
## Toolsets
|
|
158
158
|
|
|
159
|
-
Configure ordinary tools using case-sensitive minimatch
|
|
160
|
-
|
|
161
|
-
CodeMode exposure-rule objects. For example:
|
|
159
|
+
Configure ordinary tools using case-sensitive minimatch pattern strings. For
|
|
160
|
+
example:
|
|
162
161
|
|
|
163
162
|
```json
|
|
164
163
|
{
|
|
@@ -183,14 +182,15 @@ Tool Presets are cumulative:
|
|
|
183
182
|
- `tools: "none"` or `tools: []`: Base Toolset only.
|
|
184
183
|
- `tools: ["read"]`: Base Toolset + exactly `read`; arrays do not expand patterns
|
|
185
184
|
or presets.
|
|
186
|
-
- Omitted `tools`: Base Toolset + the caller's
|
|
185
|
+
- Omitted `tools`: Base Toolset + the caller's Reachable Tools: the Root Agent's
|
|
186
|
+
active tools plus tools scripts can call without declaring them (`codemode` or
|
|
187
|
+
`deferred` exposure, such as MCP tools), or a Child Agent caller's own grant.
|
|
187
188
|
|
|
188
189
|
Patterns select from permitted ordinary tool names, including inactive tools
|
|
189
190
|
registered at the root. Their matches are unioned in pattern order, retaining
|
|
190
191
|
registry order within each pattern and removing duplicates at first occurrence.
|
|
191
192
|
Each pattern is independent: a negated minimatch pattern matches its complement;
|
|
192
|
-
it does not subtract earlier matches
|
|
193
|
-
exposure policy. Coordinator Tools remain separately controlled by delegation.
|
|
193
|
+
it does not subtract earlier matches. Coordinator Tools remain separately controlled by delegation.
|
|
194
194
|
|
|
195
195
|
Invalid configuration entries and patterns matching no permitted ordinary tools
|
|
196
196
|
warn and are skipped. Configured tools unavailable in child resources are also
|
|
@@ -206,11 +206,15 @@ still apply; the saved grant is not silently rewritten. Toolsets configure names
|
|
|
206
206
|
not tool operations: granting `lsp`, `dap`, or another multifunction tool grants
|
|
207
207
|
that tool's available operations, regardless of preset name.
|
|
208
208
|
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
209
|
+
Child Agents load Pi's built-in `codemode`, `tool-search`, and `mcp` extensions
|
|
210
|
+
unless settings disable them (`-builtin:<name>`); each child connects its own MCP
|
|
211
|
+
servers. A tool's exposure still decides whether it is declared or reachable only
|
|
212
|
+
through `codemode` or `tool_search`. Granted `codemode`- or `deferred`-exposed
|
|
213
|
+
tools are callable but not declared, so a child that needs them also needs
|
|
214
|
+
`codemode` or `tool_search` in its grant. Ungranted tools are unreachable in the
|
|
215
|
+
child. MCP tools are checked at launch only by name, because children register
|
|
216
|
+
them after connecting; a tool whose server disappeared fails when called. Closing
|
|
217
|
+
a child runtime shuts its extensions down, closing its MCP connections.
|
|
214
218
|
|
|
215
219
|
## Capabilities and persistence
|
|
216
220
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ian-pascoe/pi-minimal-subagents",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.10.0",
|
|
4
4
|
"private": false,
|
|
5
5
|
"description": "Persistent nested subagents with bounded delegation for Pi",
|
|
6
6
|
"keywords": [
|
|
@@ -31,11 +31,8 @@
|
|
|
31
31
|
"provenance": true
|
|
32
32
|
},
|
|
33
33
|
"dependencies": {
|
|
34
|
-
"
|
|
35
|
-
"
|
|
36
|
-
},
|
|
37
|
-
"devDependencies": {
|
|
38
|
-
"@types/proper-lockfile": "^4.1.4"
|
|
34
|
+
"@ian-pascoe/pi-utils": "^0.3.0",
|
|
35
|
+
"minimatch": "^10.2.6"
|
|
39
36
|
},
|
|
40
37
|
"peerDependencies": {
|
|
41
38
|
"@earendil-works/pi-agent-core": "*",
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { dirname } from "node:path";
|
|
2
|
+
import * as piCodingAgent from "@earendil-works/pi-coding-agent";
|
|
2
3
|
import { DefaultResourceLoader, SettingsManager } from "@earendil-works/pi-coding-agent";
|
|
3
4
|
import { canonicalPath } from "./minimal-subagents-paths.js";
|
|
4
5
|
import type { ProjectContextMode } from "./minimal-subagents-types.js";
|
|
@@ -12,6 +13,25 @@ export interface ChildResourceLoaderOptions {
|
|
|
12
13
|
settingsManager?: SettingsManager;
|
|
13
14
|
}
|
|
14
15
|
|
|
16
|
+
type InlineExtension = NonNullable<
|
|
17
|
+
ConstructorParameters<typeof DefaultResourceLoader>[0]["extensionFactories"]
|
|
18
|
+
>[number];
|
|
19
|
+
|
|
20
|
+
/** Pi's exported built-in extensions (Pi 0.99+); older hosts have none, so neither do children. */
|
|
21
|
+
export function childBuiltinExtensions(): InlineExtension[] {
|
|
22
|
+
// SAFETY: Older Pi hosts lack these exports; the namespace yields undefined rather than failing.
|
|
23
|
+
const sdk = piCodingAgent as Partial<typeof piCodingAgent>;
|
|
24
|
+
return (
|
|
25
|
+
[
|
|
26
|
+
["codemode", sdk.createCodemodeExtension],
|
|
27
|
+
["tool-search", sdk.createToolSearchExtension],
|
|
28
|
+
["mcp", sdk.createMcpExtension],
|
|
29
|
+
] as const
|
|
30
|
+
).flatMap(([name, create]) =>
|
|
31
|
+
create ? [{ name, factory: create(), builtin: true, replaceable: true }] : [],
|
|
32
|
+
);
|
|
33
|
+
}
|
|
34
|
+
|
|
15
35
|
function createDefaultChildResourceLoaderOptions(
|
|
16
36
|
input: ChildResourceLoaderOptions,
|
|
17
37
|
): ConstructorParameters<typeof DefaultResourceLoader>[0] {
|
|
@@ -24,6 +44,7 @@ function createDefaultChildResourceLoaderOptions(
|
|
|
24
44
|
noContextFiles: false,
|
|
25
45
|
noSkills: false,
|
|
26
46
|
noPromptTemplates: false,
|
|
47
|
+
extensionFactories: childBuiltinExtensions(),
|
|
27
48
|
extensionsOverride: (base) => ({
|
|
28
49
|
...base,
|
|
29
50
|
extensions: base.extensions.filter(
|
|
@@ -63,7 +63,7 @@ import {
|
|
|
63
63
|
unavailableAgent,
|
|
64
64
|
} from "./minimal-subagents-sessions.js";
|
|
65
65
|
import { shutdownMinimalSubagentsSession } from "./minimal-subagents-shutdown.js";
|
|
66
|
-
import {
|
|
66
|
+
import { writeMinimalSubagentsEnabled } from "./minimal-subagents-settings-writer.js";
|
|
67
67
|
import {
|
|
68
68
|
MinimalSubagentsStatusPanelController,
|
|
69
69
|
type MinimalSubagentsStatusAccess,
|
|
@@ -138,16 +138,39 @@ function currentConversationMessages(context: ExtensionContext): AgentMessage[]
|
|
|
138
138
|
return snapshotCommittedContext(messages, !context.isIdle());
|
|
139
139
|
}
|
|
140
140
|
|
|
141
|
+
export function availableToolNames(pi: Pick<ExtensionAPI, "getAllTools">): string[] {
|
|
142
|
+
return excludeCoordinatorTools(
|
|
143
|
+
pi
|
|
144
|
+
.getAllTools()
|
|
145
|
+
.filter((tool) => tool.exposure !== "hidden")
|
|
146
|
+
.map((tool) => tool.name),
|
|
147
|
+
);
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/** Reachable Tools: active tools plus tools scripts can call without declaring them. */
|
|
151
|
+
export function reachableToolNames(
|
|
152
|
+
pi: Pick<ExtensionAPI, "getActiveTools" | "getAllTools">,
|
|
153
|
+
): string[] {
|
|
154
|
+
const active = new Set(pi.getActiveTools());
|
|
155
|
+
return excludeCoordinatorTools(
|
|
156
|
+
pi
|
|
157
|
+
.getAllTools()
|
|
158
|
+
.filter(
|
|
159
|
+
(tool) =>
|
|
160
|
+
active.has(tool.name) || tool.exposure === "codemode" || tool.exposure === "deferred",
|
|
161
|
+
)
|
|
162
|
+
.map((tool) => tool.name),
|
|
163
|
+
);
|
|
164
|
+
}
|
|
165
|
+
|
|
141
166
|
function rootCallerSnapshot(pi: ExtensionAPI, context: ExtensionContext): CallerSnapshot {
|
|
142
167
|
if (!context.model) throw new Error("Minimal subagents spawn: root has no effective model");
|
|
143
|
-
const activeTools = excludeCoordinatorTools(pi.getActiveTools());
|
|
144
|
-
const availableTools = excludeCoordinatorTools(pi.getAllTools().map((tool) => tool.name));
|
|
145
168
|
return {
|
|
146
169
|
messages: currentConversationMessages(context),
|
|
147
170
|
model: `${context.model.provider}/${context.model.id}`,
|
|
148
171
|
thinkingLevel: context.thinkingLevel ?? pi.getThinkingLevel(),
|
|
149
|
-
ordinaryTools:
|
|
150
|
-
capabilityCeiling:
|
|
172
|
+
ordinaryTools: reachableToolNames(pi),
|
|
173
|
+
capabilityCeiling: availableToolNames(pi),
|
|
151
174
|
spawnEntryId: context.sessionManager.getLeafId() ?? "root",
|
|
152
175
|
};
|
|
153
176
|
}
|
|
@@ -561,9 +584,6 @@ export class MinimalSubagentsLifecycleController {
|
|
|
561
584
|
) {
|
|
562
585
|
models.push(context.model);
|
|
563
586
|
}
|
|
564
|
-
const availableToolNames = excludeCoordinatorTools(
|
|
565
|
-
this.pi.getAllTools().map((tool) => tool.name),
|
|
566
|
-
);
|
|
567
587
|
const schemas = createCoordinatorToolSchemas(eligibleModelIds);
|
|
568
588
|
let activeCoordinator: MinimalSubagentsCoordinator | undefined;
|
|
569
589
|
const requireActiveCoordinator = (): MinimalSubagentsCoordinator => {
|
|
@@ -581,7 +601,7 @@ export class MinimalSubagentsLifecycleController {
|
|
|
581
601
|
models,
|
|
582
602
|
eligibleModelIds,
|
|
583
603
|
modelScopeRestricted: enabledModelPatterns !== undefined,
|
|
584
|
-
availableToolNames,
|
|
604
|
+
availableToolNames: availableToolNames(this.pi),
|
|
585
605
|
getRuntimeToolAdapters: () => codexConversionRuntimeToolAdapters(this.pi),
|
|
586
606
|
projectTrusted: context.isProjectTrusted(),
|
|
587
607
|
maxSubagentDepth: minimalSubagentsConfig.maxSubagentDepth,
|
|
@@ -816,15 +836,15 @@ export class MinimalSubagentsLifecycleController {
|
|
|
816
836
|
|
|
817
837
|
let persistedScope: "global" | "project" | undefined;
|
|
818
838
|
if (command.scope !== "session") {
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
context.ui.notify(
|
|
839
|
+
try {
|
|
840
|
+
await writeMinimalSubagentsEnabled(
|
|
841
|
+
context,
|
|
842
|
+
accessSession.agentDir,
|
|
843
|
+
command.scope,
|
|
844
|
+
command.action === "reset" ? undefined : command.action === "enable",
|
|
845
|
+
);
|
|
846
|
+
} catch (error) {
|
|
847
|
+
context.ui.notify(error instanceof Error ? error.message : String(error), "error");
|
|
828
848
|
return;
|
|
829
849
|
}
|
|
830
850
|
persistedScope = command.scope;
|
|
@@ -98,6 +98,18 @@ export interface RuntimeToolAdapter {
|
|
|
98
98
|
readonly replacements: readonly RuntimeToolReplacement[];
|
|
99
99
|
}
|
|
100
100
|
|
|
101
|
+
const MCP_RESOURCE_TOOL_NAMES = new Set([
|
|
102
|
+
"list_mcp_resources",
|
|
103
|
+
"list_mcp_resource_templates",
|
|
104
|
+
"read_mcp_resource",
|
|
105
|
+
]);
|
|
106
|
+
|
|
107
|
+
// ponytail: Pi's built-in MCP registers tools only after connecting, so launch checks accept its
|
|
108
|
+
// tool names unverified; a vanished server's tools fail when called instead of at launch.
|
|
109
|
+
function isMcpToolName(toolName: string): boolean {
|
|
110
|
+
return toolName.startsWith("mcp__") || MCP_RESOURCE_TOOL_NAMES.has(toolName);
|
|
111
|
+
}
|
|
112
|
+
|
|
101
113
|
/** Resolve the exact active tools permitted by one Child Agent Launch Contract. */
|
|
102
114
|
export function resolveChildActiveToolNames(
|
|
103
115
|
allowedToolNames: readonly string[],
|
|
@@ -132,24 +144,24 @@ function installChildToolCapabilityPolicy(
|
|
|
132
144
|
session: AgentSession,
|
|
133
145
|
allowedToolNames: readonly string[],
|
|
134
146
|
runtimeToolAdapters: readonly RuntimeToolAdapter[],
|
|
135
|
-
preserveGrantedTools = true,
|
|
136
147
|
): void {
|
|
148
|
+
const isScriptOnly = (name: string) => {
|
|
149
|
+
const exposure = session.getAllTools().find((tool) => tool.name === name)?.exposure;
|
|
150
|
+
return exposure === "codemode" || exposure === "deferred";
|
|
151
|
+
};
|
|
137
152
|
const applyActiveTools = session.setActiveToolsByName.bind(session);
|
|
138
153
|
session.setActiveToolsByName = (requestedToolNames) => {
|
|
139
|
-
const
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
runtimeToolAdapters,
|
|
143
|
-
);
|
|
144
|
-
// Inside an exposure wrapper, ordinary tools may intentionally be hidden. The
|
|
145
|
-
// outer policy restores grants before that wrapper applies its own selection.
|
|
154
|
+
const requested = new Set(requestedToolNames);
|
|
155
|
+
// Granted declared tools stay active; `codemode` and `deferred` grants stay
|
|
156
|
+
// callable without being declared unless an extension activates them.
|
|
146
157
|
applyActiveTools(
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
158
|
+
resolveChildActiveToolNames(allowedToolNames, requestedToolNames, runtimeToolAdapters).filter(
|
|
159
|
+
(name) => requested.has(name) || !isScriptOnly(name),
|
|
160
|
+
),
|
|
150
161
|
);
|
|
151
162
|
};
|
|
152
|
-
|
|
163
|
+
// Pi's `tools` option also activates script-only grants; drop them before extensions bind.
|
|
164
|
+
session.setActiveToolsByName(session.getActiveToolNames().filter((name) => !isScriptOnly(name)));
|
|
153
165
|
}
|
|
154
166
|
|
|
155
167
|
/** Moves one verified child session file to trash and reports command unavailability. */
|
|
@@ -632,6 +644,11 @@ class PiChildAgentRuntime implements ChildAgentRuntime {
|
|
|
632
644
|
this.unsubscribe();
|
|
633
645
|
try {
|
|
634
646
|
await this.observer?.dispose();
|
|
647
|
+
// Let child extensions release services such as MCP server connections.
|
|
648
|
+
const runner = this.session.extensionRunner;
|
|
649
|
+
if (runner.hasHandlers("session_shutdown")) {
|
|
650
|
+
await runner.emit({ type: "session_shutdown", reason: "quit" });
|
|
651
|
+
}
|
|
635
652
|
} finally {
|
|
636
653
|
this.session.dispose();
|
|
637
654
|
}
|
|
@@ -1127,6 +1144,11 @@ export class PiAgentSessionFactory implements AgentSessionFactory {
|
|
|
1127
1144
|
await resourceLoader.reload();
|
|
1128
1145
|
for (const extension of resourceLoader.getExtensions().extensions) {
|
|
1129
1146
|
for (const toolName of extension.tools.keys()) names.add(toolName);
|
|
1147
|
+
if (extension.path === "builtin:mcp") {
|
|
1148
|
+
for (const toolName of agent.launch_contract.ordinary_tools) {
|
|
1149
|
+
if (isMcpToolName(toolName)) names.add(toolName);
|
|
1150
|
+
}
|
|
1151
|
+
}
|
|
1130
1152
|
}
|
|
1131
1153
|
return names;
|
|
1132
1154
|
})();
|
|
@@ -1174,7 +1196,6 @@ export class PiAgentSessionFactory implements AgentSessionFactory {
|
|
|
1174
1196
|
];
|
|
1175
1197
|
const runtimeToolAdapters = this.options.getRuntimeToolAdapters?.() ?? [];
|
|
1176
1198
|
const adapterToolNames = new Set(runtimeToolAdapters.flatMap((adapter) => adapter.toolNames));
|
|
1177
|
-
const adaptRuntimeTools = adapterToolNames.size > 0;
|
|
1178
1199
|
const { session } = await createAgentSession({
|
|
1179
1200
|
cwd: this.options.cwd,
|
|
1180
1201
|
agentDir: this.options.agentDir,
|
|
@@ -1187,20 +1208,16 @@ export class PiAgentSessionFactory implements AgentSessionFactory {
|
|
|
1187
1208
|
settingsManager,
|
|
1188
1209
|
modelRuntime,
|
|
1189
1210
|
});
|
|
1190
|
-
|
|
1191
|
-
const
|
|
1192
|
-
|
|
1211
|
+
const loadedNames = new Set(session.getAllTools().map((tool) => tool.name));
|
|
1212
|
+
const missingTools = allowedToolNames.filter(
|
|
1213
|
+
(toolName) => !loadedNames.has(toolName) && !isMcpToolName(toolName),
|
|
1214
|
+
);
|
|
1193
1215
|
if (missingTools.length > 0) {
|
|
1194
1216
|
session.dispose();
|
|
1195
1217
|
throw new Error(`Minimal subagents child tool loading failed: ${missingTools.join(", ")}`);
|
|
1196
1218
|
}
|
|
1197
|
-
// The inner policy bounds extension-selected exposure without restoring hidden ordinary tools.
|
|
1198
|
-
installChildToolCapabilityPolicy(session, allowedToolNames, runtimeToolAdapters, false);
|
|
1199
|
-
await session.bindExtensions({ mode: "print" });
|
|
1200
|
-
// The outer policy filters names before extension wrappers build their own tool catalogues.
|
|
1201
1219
|
installChildToolCapabilityPolicy(session, allowedToolNames, runtimeToolAdapters);
|
|
1202
|
-
|
|
1203
|
-
// require their definitions to remain registered, not necessarily direct.
|
|
1220
|
+
await session.bindExtensions({ mode: "print" });
|
|
1204
1221
|
const registeredNames = new Set(session.getAllTools().map((tool) => tool.name));
|
|
1205
1222
|
const missingCoordinatorTools = coordinatorTools
|
|
1206
1223
|
.map((tool) => tool.name)
|
|
@@ -1,308 +1,72 @@
|
|
|
1
1
|
import type { JsonValue } from "@earendil-works/pi-ai";
|
|
2
|
-
import { CONFIG_DIR_NAME
|
|
3
|
-
import {
|
|
4
|
-
import {
|
|
5
|
-
import { basename, dirname, join, resolve } from "node:path";
|
|
6
|
-
import lockfile from "proper-lockfile";
|
|
2
|
+
import { CONFIG_DIR_NAME } from "@earendil-works/pi-coding-agent";
|
|
3
|
+
import { updateFileLocked } from "@ian-pascoe/pi-utils/locked-file-update";
|
|
4
|
+
import { resolve } from "node:path";
|
|
7
5
|
|
|
8
6
|
/** Identifies the standard Pi settings file changed by a Subagent Access command. */
|
|
9
7
|
export type MinimalSubagentsSettingsScope = "global" | "project";
|
|
10
8
|
|
|
11
|
-
/** Describes why a scoped minimal subagents settings write could not complete. */
|
|
12
|
-
export type MinimalSubagentsSettingsWriteFailureReason =
|
|
13
|
-
| "project-untrusted"
|
|
14
|
-
| "malformed-json"
|
|
15
|
-
| "incompatible-shape"
|
|
16
|
-
| "filesystem";
|
|
17
|
-
|
|
18
|
-
/** Reports an expected scoped settings failure without throwing through the command boundary. */
|
|
19
|
-
export class MinimalSubagentsSettingsWriteError extends Error {
|
|
20
|
-
readonly _tag = "MinimalSubagentsSettingsWriteError" as const;
|
|
21
|
-
|
|
22
|
-
/** Create a settings write failure carrying its exact scope and path. */
|
|
23
|
-
constructor(
|
|
24
|
-
readonly scope: MinimalSubagentsSettingsScope,
|
|
25
|
-
readonly path: string,
|
|
26
|
-
readonly reason: MinimalSubagentsSettingsWriteFailureReason,
|
|
27
|
-
message: string,
|
|
28
|
-
readonly operation?: SettingsFilesystemOperation,
|
|
29
|
-
cause?: unknown,
|
|
30
|
-
) {
|
|
31
|
-
super(message, cause === undefined ? undefined : { cause });
|
|
32
|
-
}
|
|
33
|
-
}
|
|
34
|
-
|
|
35
|
-
/** Returns the path changed by a successful write or a typed expected settings failure. */
|
|
36
|
-
export type MinimalSubagentsSettingsWriteResult =
|
|
37
|
-
| {
|
|
38
|
-
readonly ok: true;
|
|
39
|
-
readonly scope: MinimalSubagentsSettingsScope;
|
|
40
|
-
readonly path: string;
|
|
41
|
-
}
|
|
42
|
-
| {
|
|
43
|
-
readonly ok: false;
|
|
44
|
-
readonly error: MinimalSubagentsSettingsWriteError;
|
|
45
|
-
};
|
|
46
|
-
|
|
47
9
|
/** Supplies only the Root Agent context needed to select and authorize a settings file. */
|
|
48
10
|
export interface MinimalSubagentsSettingsWriteContext {
|
|
49
11
|
readonly cwd: string;
|
|
50
12
|
isProjectTrusted(): boolean;
|
|
51
13
|
}
|
|
52
14
|
|
|
53
|
-
type SettingsFilesystemOperation = "prepare" | "lock" | "read" | "write" | "release";
|
|
54
15
|
type SettingsJsonObject = Record<string, JsonValue>;
|
|
55
16
|
|
|
56
|
-
type ParsedSettingsDocument =
|
|
57
|
-
| { readonly ok: true; readonly settings: SettingsJsonObject }
|
|
58
|
-
| { readonly ok: false; readonly error: MinimalSubagentsSettingsWriteError };
|
|
59
|
-
|
|
60
|
-
interface ExistingSettingsDocument {
|
|
61
|
-
readonly settings: SettingsJsonObject;
|
|
62
|
-
readonly mode: number;
|
|
63
|
-
}
|
|
64
|
-
|
|
65
17
|
function isSettingsJsonObject(value: JsonValue | undefined): value is SettingsJsonObject {
|
|
66
18
|
// oxlint-disable-next-line anti-slop/no-runtime-typeof -- JSON.parse already established JSON data; distinguish object roots and settings blocks from primitives and arrays.
|
|
67
19
|
return value !== null && typeof value === "object" && !Array.isArray(value);
|
|
68
20
|
}
|
|
69
|
-
const SETTINGS_LOCK_RETRY_DELAY_MS = 20;
|
|
70
|
-
const SETTINGS_LOCK_RETRIES = 100;
|
|
71
|
-
const NEW_SETTINGS_FILE_MODE = 0o600;
|
|
72
|
-
|
|
73
|
-
function stripUtf8Bom(content: string): string {
|
|
74
|
-
return content.charCodeAt(0) === 0xfeff ? content.slice(1) : content;
|
|
75
|
-
}
|
|
76
21
|
|
|
77
|
-
function
|
|
78
|
-
|
|
79
|
-
path: string,
|
|
80
|
-
detail: string,
|
|
81
|
-
): MinimalSubagentsSettingsWriteError {
|
|
82
|
-
return new MinimalSubagentsSettingsWriteError(
|
|
83
|
-
scope,
|
|
84
|
-
path,
|
|
85
|
-
"incompatible-shape",
|
|
86
|
-
`Minimal subagents settings shape is incompatible for ${scope} settings at ${path}: ${detail}`,
|
|
87
|
-
);
|
|
88
|
-
}
|
|
89
|
-
|
|
90
|
-
function parseSettingsDocument(
|
|
91
|
-
content: string,
|
|
92
|
-
scope: MinimalSubagentsSettingsScope,
|
|
93
|
-
path: string,
|
|
94
|
-
): ParsedSettingsDocument {
|
|
22
|
+
function parseSettings(content: string | undefined, path: string): SettingsJsonObject {
|
|
23
|
+
if (content === undefined) return {};
|
|
95
24
|
// JSON.parse is the provenance for this JSON type; the object shape is checked below.
|
|
96
25
|
let parsed: JsonValue;
|
|
97
26
|
try {
|
|
98
|
-
parsed = JSON.parse(
|
|
27
|
+
parsed = JSON.parse(content.charCodeAt(0) === 0xfeff ? content.slice(1) : content);
|
|
99
28
|
} catch (cause) {
|
|
100
|
-
|
|
101
|
-
ok: false,
|
|
102
|
-
error: new MinimalSubagentsSettingsWriteError(
|
|
103
|
-
scope,
|
|
104
|
-
path,
|
|
105
|
-
"malformed-json",
|
|
106
|
-
`Minimal subagents settings JSON is malformed for ${scope} settings at ${path}`,
|
|
107
|
-
"read",
|
|
108
|
-
cause,
|
|
109
|
-
),
|
|
110
|
-
};
|
|
29
|
+
throw new Error(`Minimal subagents settings JSON is malformed at ${path}`, { cause });
|
|
111
30
|
}
|
|
112
|
-
|
|
113
31
|
if (!isSettingsJsonObject(parsed)) {
|
|
114
|
-
|
|
115
|
-
}
|
|
116
|
-
const minimalSubagents = parsed.minimalSubagents;
|
|
117
|
-
if (minimalSubagents !== undefined && !isSettingsJsonObject(minimalSubagents)) {
|
|
118
|
-
return {
|
|
119
|
-
ok: false,
|
|
120
|
-
error: settingsContractError(
|
|
121
|
-
scope,
|
|
122
|
-
path,
|
|
123
|
-
"expected minimalSubagents to be an object or absent",
|
|
124
|
-
),
|
|
125
|
-
};
|
|
126
|
-
}
|
|
127
|
-
return { ok: true, settings: parsed };
|
|
128
|
-
}
|
|
129
|
-
|
|
130
|
-
function mutateMinimalSubagentsEnabled(
|
|
131
|
-
settings: SettingsJsonObject,
|
|
132
|
-
enabled: boolean | undefined,
|
|
133
|
-
): void {
|
|
134
|
-
const currentMinimalSubagents = settings.minimalSubagents;
|
|
135
|
-
const minimalSubagents = isSettingsJsonObject(currentMinimalSubagents)
|
|
136
|
-
? currentMinimalSubagents
|
|
137
|
-
: {};
|
|
138
|
-
|
|
139
|
-
if (enabled === undefined) {
|
|
140
|
-
delete minimalSubagents.enabled;
|
|
141
|
-
} else {
|
|
142
|
-
minimalSubagents.enabled = enabled;
|
|
32
|
+
throw new Error(`Minimal subagents settings at ${path} must have an object root`);
|
|
143
33
|
}
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
delete settings.minimalSubagents;
|
|
147
|
-
} else {
|
|
148
|
-
settings.minimalSubagents = minimalSubagents;
|
|
149
|
-
}
|
|
150
|
-
}
|
|
151
|
-
|
|
152
|
-
function filesystemWriteError(
|
|
153
|
-
scope: MinimalSubagentsSettingsScope,
|
|
154
|
-
path: string,
|
|
155
|
-
operation: SettingsFilesystemOperation,
|
|
156
|
-
cause: unknown,
|
|
157
|
-
): MinimalSubagentsSettingsWriteError {
|
|
158
|
-
return new MinimalSubagentsSettingsWriteError(
|
|
159
|
-
scope,
|
|
160
|
-
path,
|
|
161
|
-
"filesystem",
|
|
162
|
-
`Minimal subagents settings ${operation} failed for ${scope} settings at ${path}`,
|
|
163
|
-
operation,
|
|
164
|
-
cause,
|
|
165
|
-
);
|
|
166
|
-
}
|
|
167
|
-
|
|
168
|
-
async function readSettingsDocumentUnderLock(
|
|
169
|
-
scope: MinimalSubagentsSettingsScope,
|
|
170
|
-
path: string,
|
|
171
|
-
): Promise<ExistingSettingsDocument | MinimalSubagentsSettingsWriteError> {
|
|
172
|
-
let fileMode = NEW_SETTINGS_FILE_MODE;
|
|
173
|
-
let content: string;
|
|
174
|
-
try {
|
|
175
|
-
const fileStat = await stat(path);
|
|
176
|
-
fileMode = fileStat.mode & 0o7777;
|
|
177
|
-
content = await readFile(path, "utf8");
|
|
178
|
-
} catch (cause) {
|
|
179
|
-
if (isNodeErrorWithCode(cause, "ENOENT")) return { settings: {}, mode: fileMode };
|
|
180
|
-
return filesystemWriteError(scope, path, "read", cause);
|
|
181
|
-
}
|
|
182
|
-
|
|
183
|
-
const parsed = parseSettingsDocument(content, scope, path);
|
|
184
|
-
return parsed.ok ? { settings: parsed.settings, mode: fileMode } : parsed.error;
|
|
185
|
-
}
|
|
186
|
-
|
|
187
|
-
function isNodeErrorWithCode(cause: unknown, code: string): boolean {
|
|
188
|
-
return cause instanceof Error && "code" in cause && cause.code === code;
|
|
189
|
-
}
|
|
190
|
-
|
|
191
|
-
async function replaceSettingsFileAtomically(
|
|
192
|
-
path: string,
|
|
193
|
-
settings: SettingsJsonObject,
|
|
194
|
-
mode: number,
|
|
195
|
-
temporaryId: string,
|
|
196
|
-
): Promise<void> {
|
|
197
|
-
const temporaryPath = join(dirname(path), `.${basename(path)}.${process.pid}.${temporaryId}.tmp`);
|
|
198
|
-
let temporaryFile: Awaited<ReturnType<typeof open>> | undefined;
|
|
199
|
-
try {
|
|
200
|
-
temporaryFile = await open(temporaryPath, "wx", mode);
|
|
201
|
-
await temporaryFile.chmod(mode);
|
|
202
|
-
await temporaryFile.writeFile(`${JSON.stringify(settings, undefined, 2)}\n`, "utf8");
|
|
203
|
-
await temporaryFile.sync();
|
|
204
|
-
await temporaryFile.close();
|
|
205
|
-
temporaryFile = undefined;
|
|
206
|
-
await rename(temporaryPath, path);
|
|
207
|
-
} catch (cause) {
|
|
208
|
-
if (temporaryFile !== undefined) await temporaryFile.close().catch(() => undefined);
|
|
209
|
-
await rm(temporaryPath, { force: true }).catch(() => undefined);
|
|
210
|
-
throw cause;
|
|
34
|
+
if (parsed.minimalSubagents !== undefined && !isSettingsJsonObject(parsed.minimalSubagents)) {
|
|
35
|
+
throw new Error(`Minimal subagents settings at ${path} must have an object minimalSubagents`);
|
|
211
36
|
}
|
|
37
|
+
return parsed;
|
|
212
38
|
}
|
|
213
39
|
|
|
214
40
|
/**
|
|
215
|
-
*
|
|
216
|
-
*
|
|
41
|
+
* Set or remove `minimalSubagents.enabled` in global or trusted-project Pi settings,
|
|
42
|
+
* preserving every unrelated setting. Writes re-read the file under Pi's settings lock.
|
|
43
|
+
* @returns The changed settings path.
|
|
217
44
|
*/
|
|
218
|
-
export
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
/** Set or remove one authored Subagent Access default, preserving every unrelated setting. */
|
|
233
|
-
async writeMinimalSubagentsEnabled(
|
|
234
|
-
scope: MinimalSubagentsSettingsScope,
|
|
235
|
-
enabled: boolean | undefined,
|
|
236
|
-
): Promise<MinimalSubagentsSettingsWriteResult> {
|
|
237
|
-
const path = scope === "global" ? this.globalSettingsPath : this.projectSettingsPath;
|
|
238
|
-
if (scope === "project" && !this.context.isProjectTrusted()) {
|
|
239
|
-
return {
|
|
240
|
-
ok: false,
|
|
241
|
-
error: new MinimalSubagentsSettingsWriteError(
|
|
242
|
-
scope,
|
|
243
|
-
path,
|
|
244
|
-
"project-untrusted",
|
|
245
|
-
`Minimal subagents project settings write refused because the project is not trusted: ${path}`,
|
|
246
|
-
),
|
|
247
|
-
};
|
|
248
|
-
}
|
|
249
|
-
|
|
250
|
-
return this.writeEnabledUnderLock(scope, path, enabled);
|
|
45
|
+
export async function writeMinimalSubagentsEnabled(
|
|
46
|
+
context: MinimalSubagentsSettingsWriteContext,
|
|
47
|
+
agentDirectory: string,
|
|
48
|
+
scope: MinimalSubagentsSettingsScope,
|
|
49
|
+
enabled: boolean | undefined,
|
|
50
|
+
): Promise<string> {
|
|
51
|
+
const path =
|
|
52
|
+
scope === "global"
|
|
53
|
+
? resolve(agentDirectory, "settings.json")
|
|
54
|
+
: resolve(context.cwd, CONFIG_DIR_NAME, "settings.json");
|
|
55
|
+
if (scope === "project" && !context.isProjectTrusted()) {
|
|
56
|
+
throw new Error(
|
|
57
|
+
`Minimal subagents project settings write refused because the project is not trusted: ${path}`,
|
|
58
|
+
);
|
|
251
59
|
}
|
|
252
60
|
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
let release: (() => Promise<void>) | undefined;
|
|
265
|
-
try {
|
|
266
|
-
release = await lockfile.lock(path, {
|
|
267
|
-
realpath: false,
|
|
268
|
-
retries: {
|
|
269
|
-
retries: SETTINGS_LOCK_RETRIES,
|
|
270
|
-
factor: 1,
|
|
271
|
-
minTimeout: SETTINGS_LOCK_RETRY_DELAY_MS,
|
|
272
|
-
maxTimeout: SETTINGS_LOCK_RETRY_DELAY_MS,
|
|
273
|
-
randomize: false,
|
|
274
|
-
},
|
|
275
|
-
});
|
|
276
|
-
} catch (cause) {
|
|
277
|
-
return { ok: false, error: filesystemWriteError(scope, path, "lock", cause) };
|
|
278
|
-
}
|
|
279
|
-
|
|
280
|
-
let result: MinimalSubagentsSettingsWriteResult;
|
|
281
|
-
try {
|
|
282
|
-
const existing = await readSettingsDocumentUnderLock(scope, path);
|
|
283
|
-
if (existing instanceof MinimalSubagentsSettingsWriteError) {
|
|
284
|
-
result = { ok: false, error: existing };
|
|
285
|
-
} else {
|
|
286
|
-
try {
|
|
287
|
-
mutateMinimalSubagentsEnabled(existing.settings, enabled);
|
|
288
|
-
await replaceSettingsFileAtomically(
|
|
289
|
-
path,
|
|
290
|
-
existing.settings,
|
|
291
|
-
existing.mode,
|
|
292
|
-
this.createTemporaryId(),
|
|
293
|
-
);
|
|
294
|
-
result = { ok: true, scope, path };
|
|
295
|
-
} catch (cause) {
|
|
296
|
-
result = { ok: false, error: filesystemWriteError(scope, path, "write", cause) };
|
|
297
|
-
}
|
|
298
|
-
}
|
|
299
|
-
} finally {
|
|
300
|
-
try {
|
|
301
|
-
await release();
|
|
302
|
-
} catch (cause) {
|
|
303
|
-
result = { ok: false, error: filesystemWriteError(scope, path, "release", cause) };
|
|
304
|
-
}
|
|
305
|
-
}
|
|
306
|
-
return result;
|
|
307
|
-
}
|
|
61
|
+
await updateFileLocked(path, (content) => {
|
|
62
|
+
const settings = parseSettings(content, path);
|
|
63
|
+
const current = settings.minimalSubagents;
|
|
64
|
+
const minimalSubagents = isSettingsJsonObject(current) ? { ...current } : {};
|
|
65
|
+
if (enabled === undefined) delete minimalSubagents.enabled;
|
|
66
|
+
else minimalSubagents.enabled = enabled;
|
|
67
|
+
if (Object.keys(minimalSubagents).length === 0) delete settings.minimalSubagents;
|
|
68
|
+
else settings.minimalSubagents = minimalSubagents;
|
|
69
|
+
return `${JSON.stringify(settings, undefined, 2)}\n`;
|
|
70
|
+
});
|
|
71
|
+
return path;
|
|
308
72
|
}
|
|
@@ -125,6 +125,7 @@ function structuredToolResult<TDetails extends CoordinatorToolResultDetails>(
|
|
|
125
125
|
return {
|
|
126
126
|
content: [{ type: "text" as const, text: truncated.content }],
|
|
127
127
|
details: result,
|
|
128
|
+
structuredContent: JSON.parse(json),
|
|
128
129
|
};
|
|
129
130
|
}
|
|
130
131
|
|
|
@@ -235,13 +236,15 @@ export function createCoordinatorToolDefinitions(
|
|
|
235
236
|
signal,
|
|
236
237
|
parameters.turn_id,
|
|
237
238
|
);
|
|
239
|
+
const details = {
|
|
240
|
+
...result,
|
|
241
|
+
source_agent_id: result.agent_id,
|
|
242
|
+
source_turn_id: result.turn_id,
|
|
243
|
+
};
|
|
238
244
|
return {
|
|
239
245
|
...structuredToolResult(result),
|
|
240
|
-
details
|
|
241
|
-
|
|
242
|
-
source_agent_id: result.agent_id,
|
|
243
|
-
source_turn_id: result.turn_id,
|
|
244
|
-
},
|
|
246
|
+
details,
|
|
247
|
+
structuredContent: JSON.parse(JSON.stringify(details)),
|
|
245
248
|
};
|
|
246
249
|
});
|
|
247
250
|
} finally {
|