@signalridge/pi-subagents 1.10.3 → 1.11.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/CHANGELOG.md +53 -0
- package/README.md +34 -11
- package/examples/agent-tool-description.md +1 -1
- package/package.json +9 -8
- package/src/agent-manager.ts +1405 -486
- package/src/agent-runner.ts +538 -268
- package/src/agent-types.ts +6 -0
- package/src/ask-tools.ts +31 -21
- package/src/context-boundary.ts +54 -0
- package/src/context.ts +121 -36
- package/src/custom-agents.ts +19 -4
- package/src/enabled-models.ts +84 -83
- package/src/gate.ts +0 -0
- package/src/index.ts +1118 -861
- package/src/mention-clone.ts +129 -83
- package/src/model-runtime-bridge.ts +68 -0
- package/src/model-scope.ts +8 -3
- package/src/nested-tools.ts +13 -5
- package/src/project-trust.ts +146 -0
- package/src/settings.ts +7 -4
- package/src/skill-loader.ts +22 -10
- package/src/types.ts +6 -4
package/src/mention-clone.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* mention-clone.ts — start a mentioned agent through a
|
|
3
|
-
*
|
|
2
|
+
* mention-clone.ts — start a mentioned agent through a hidden, mention-only
|
|
3
|
+
* session without putting anything in the chat.
|
|
4
4
|
*
|
|
5
5
|
* Claude Code routes `@agent-<type>` through the main model: the mention
|
|
6
6
|
* becomes a `<system-reminder>` appended to the prompt and the model makes the
|
|
@@ -9,27 +9,13 @@
|
|
|
9
9
|
* reasoning and its tool block land in the transcript, for a decision the user
|
|
10
10
|
* already made when they typed the handle.
|
|
11
11
|
*
|
|
12
|
-
* So the turn happens somewhere else.
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
19
|
-
* on: `SessionManager._persist` withholds every write until the first assistant
|
|
20
|
-
* message lands, so a fork taken before then reads an empty file and throws.
|
|
21
|
-
* `buildSessionContext()` has no such timing, and is compaction-aware — it walks
|
|
22
|
-
* the leaf path and substitutes the summary for entries folded into it, so a
|
|
23
|
-
* long conversation clones as what the main model is actually working from. A
|
|
24
|
-
* conversation with nothing in it yet clones to nothing in it yet, which is the
|
|
25
|
-
* correct answer rather than a failure.
|
|
26
|
-
*
|
|
27
|
-
* It is also the oldest of the equivalent Pi APIs — `buildContextEntries` on
|
|
28
|
-
* ReadonlySessionManager and the `sessionEntryToContextMessages` export both
|
|
29
|
-
* arrived in 0.80.5 — where this one has been exported unchanged from before
|
|
30
|
-
* the declared peer floor, and is the same code path (`byId` is only an index
|
|
31
|
-
* cache, so passing it or not cannot change the result). Keeping the floor
|
|
32
|
-
* honest costs nothing here: see the `compat-floor-pi` job.
|
|
12
|
+
* So the turn happens somewhere else. A throwaway in-memory session takes it
|
|
13
|
+
* off-screen on the parent's model, but receives only this mention's text.
|
|
14
|
+
* Pi's public ExtensionContext exposes a pre-hook SessionManager projection,
|
|
15
|
+
* not the parent's request-local context hooks (including programmatic hooks).
|
|
16
|
+
* Copying even a compacted or edited branch could send private parent content
|
|
17
|
+
* to the clone's provider before its redaction runs. The clone must start empty:
|
|
18
|
+
* no parent branch, system prompt, tool declarations, or summaries.
|
|
33
19
|
*
|
|
34
20
|
* Its `thinkingLevel` is NOT used, and is the one place the newer API would be
|
|
35
21
|
* better. `getSessionContextSettings` starts at "off" and moves only on an
|
|
@@ -63,19 +49,28 @@
|
|
|
63
49
|
|
|
64
50
|
import type { Model } from "@earendil-works/pi-ai";
|
|
65
51
|
import {
|
|
66
|
-
buildSessionContext,
|
|
67
52
|
createAgentSession,
|
|
53
|
+
DefaultResourceLoader,
|
|
68
54
|
type ExtensionContext,
|
|
55
|
+
getAgentDir,
|
|
69
56
|
SessionManager,
|
|
70
57
|
type ToolDefinition,
|
|
71
58
|
} from "@earendil-works/pi-coding-agent";
|
|
72
59
|
import { runInChildSessionContext } from "./child-context.js";
|
|
73
|
-
import {
|
|
60
|
+
import { parentModelSessionOptions } from "./model-runtime-bridge.js";
|
|
74
61
|
import type { SubagentType, ThinkingLevel } from "./types.js";
|
|
75
62
|
|
|
63
|
+
const MENTION_SYSTEM_PROMPT =
|
|
64
|
+
"You write a task prompt for the Agent tool using only the user's current message. " +
|
|
65
|
+
"Call Agent once to start the requested task. You have no prior conversation context; " +
|
|
66
|
+
"do not invent or request inherited context. If details are missing, pass the user's words through.";
|
|
67
|
+
|
|
68
|
+
/** Internal acknowledgement: the real Agent handler has returned from manager.spawn. */
|
|
69
|
+
export const MENTION_SPAWNED = Symbol("pi-subagents:mention-spawned");
|
|
70
|
+
|
|
76
71
|
export interface MentionCloneOptions {
|
|
77
|
-
/** The MAIN session's context — what the spawn is attributed to,
|
|
78
|
-
* source of
|
|
72
|
+
/** The MAIN session's context — what the spawn is attributed to, not a
|
|
73
|
+
* source of provider history or instructions for the hidden turn. */
|
|
79
74
|
ctx: ExtensionContext;
|
|
80
75
|
/** Agent type the handle resolved to. */
|
|
81
76
|
type: SubagentType;
|
|
@@ -83,79 +78,120 @@ export interface MentionCloneOptions {
|
|
|
83
78
|
message: string;
|
|
84
79
|
/** The registered `Agent` tool, reused so the spawn is an ordinary one. */
|
|
85
80
|
agentTool: ToolDefinition;
|
|
81
|
+
/** False once the originating session or branch has been replaced. */
|
|
82
|
+
isOriginCurrent: () => boolean;
|
|
86
83
|
}
|
|
87
84
|
|
|
88
85
|
export interface MentionCloneResult {
|
|
89
|
-
/** True once the
|
|
86
|
+
/** True once the registered Agent handler confirmed a child was started. */
|
|
90
87
|
spawned: boolean;
|
|
88
|
+
/** The Agent handler ran but refused the spawn; direct fallback must not bypass its policy. */
|
|
89
|
+
refused?: boolean;
|
|
91
90
|
/** Why not, when it didn't. Absent on success. */
|
|
92
91
|
error?: string;
|
|
93
92
|
}
|
|
94
93
|
|
|
95
94
|
/**
|
|
96
|
-
*
|
|
95
|
+
* Let a mention-only throwaway session make the tool call, then discard it.
|
|
97
96
|
* Never rejects: a clone that cannot run is reported so the caller can fall
|
|
98
97
|
* back to starting the agent directly.
|
|
99
98
|
*/
|
|
100
99
|
export async function runMentionClone(opts: MentionCloneOptions): Promise<MentionCloneResult> {
|
|
101
|
-
const { ctx, type, message, agentTool } = opts;
|
|
100
|
+
const { ctx, type, message, agentTool, isOriginCurrent } = opts;
|
|
102
101
|
|
|
103
102
|
let spawned = false;
|
|
103
|
+
let refused = false;
|
|
104
|
+
let attempted = false;
|
|
104
105
|
const cloneAgentTool: ToolDefinition = {
|
|
105
106
|
...agentTool,
|
|
106
|
-
execute: (_cloneToolCallId, params, signal, onUpdate, _cloneCtx) => {
|
|
107
|
-
//
|
|
108
|
-
//
|
|
109
|
-
//
|
|
110
|
-
if (
|
|
111
|
-
return
|
|
112
|
-
content: [{ type: "text" as const, text: "Already
|
|
107
|
+
execute: async (_cloneToolCallId, params, signal, onUpdate, _cloneCtx) => {
|
|
108
|
+
// The model may ask for another type or a schedule, but the user chose
|
|
109
|
+
// exactly one agent by typing its handle. Never let the hidden turn widen
|
|
110
|
+
// that decision or attempt another spawn after a failed call.
|
|
111
|
+
if (attempted) {
|
|
112
|
+
return {
|
|
113
|
+
content: [{ type: "text" as const, text: "Already attempted an agent for this mention. Stop here." }],
|
|
113
114
|
details: undefined,
|
|
114
115
|
isError: true,
|
|
115
|
-
}
|
|
116
|
+
};
|
|
116
117
|
}
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
118
|
+
attempted = true;
|
|
119
|
+
if (!isOriginCurrent()) {
|
|
120
|
+
return {
|
|
121
|
+
content: [{ type: "text" as const, text: "The original session changed. Do not start this agent." }],
|
|
122
|
+
details: undefined,
|
|
123
|
+
isError: true,
|
|
124
|
+
};
|
|
125
|
+
}
|
|
126
|
+
const choice = params as Record<string, unknown>;
|
|
127
|
+
const selected = {
|
|
128
|
+
subagent_type: type,
|
|
129
|
+
prompt: typeof choice.prompt === "string" && choice.prompt.trim() ? choice.prompt : message,
|
|
130
|
+
...(typeof choice.description === "string" && { description: choice.description }),
|
|
131
|
+
run_in_background: true,
|
|
132
|
+
// The handler acknowledges immediately after manager.spawn returns,
|
|
133
|
+
// before UI/event side effects can throw. A tool result alone cannot
|
|
134
|
+
// distinguish a pre-spawn rejection from a post-spawn exception.
|
|
135
|
+
[MENTION_SPAWNED]: () => { spawned = true; },
|
|
136
|
+
} as typeof params;
|
|
137
|
+
// A foreground result would be delivered only into the discarded clone.
|
|
138
|
+
// Attribute the background spawn to the real session, with no dangling
|
|
139
|
+
// tool-call id. A plain text rejection is not a successful spawn.
|
|
140
|
+
// Pi 0.99 tool handlers require a tool context, not the event-handler
|
|
141
|
+
// context captured from the parent. Keep every session-bound field from
|
|
142
|
+
// the parent, and supply the real clone call's nested-tool capabilities;
|
|
143
|
+
// Agent itself does not use those capabilities. Never pretend the parent
|
|
144
|
+
// has executeTool(), which only exists during an actual tool invocation.
|
|
145
|
+
const mainToolCtx = { ...ctx, tools: _cloneCtx.tools, executeTool: _cloneCtx.executeTool };
|
|
146
|
+
const result = await agentTool.execute(undefined as never, selected, signal, onUpdate, mainToolCtx);
|
|
147
|
+
const details = result.details as { agentId?: unknown; status?: unknown } | undefined;
|
|
148
|
+
spawned ||= typeof details?.agentId === "string" &&
|
|
149
|
+
(details.status === "background" || details.status === "queued");
|
|
150
|
+
refused = !spawned;
|
|
151
|
+
return result;
|
|
130
152
|
},
|
|
131
153
|
};
|
|
132
154
|
|
|
133
155
|
let session: Awaited<ReturnType<typeof createAgentSession>>["session"] | undefined;
|
|
134
156
|
try {
|
|
135
|
-
//
|
|
136
|
-
//
|
|
137
|
-
//
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
//
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
);
|
|
157
|
+
// Refuse before loading or prompting when a modern host cannot bridge the
|
|
158
|
+
// parent's providers/virtual routes into this throwaway session.
|
|
159
|
+
// The hidden clone inherits exactly this model; unlike an explicit child
|
|
160
|
+
// selection, a stale parent model cannot be replaced before its request.
|
|
161
|
+
const parentModels = parentModelSessionOptions(ctx, ctx.model);
|
|
162
|
+
// An empty manager works on both old Pi hosts (which read agent state) and
|
|
163
|
+
// new ones (which project the manager). Never inspect the parent's branch:
|
|
164
|
+
// its pre-hook content cannot safely seed a separate provider request.
|
|
165
|
+
const cloneManager = SessionManager.inMemory(ctx.cwd);
|
|
145
166
|
// Pi 0.82.0 added this; below it the field is absent and the clone takes
|
|
146
167
|
// the settings level instead, which is what a session that never ran
|
|
147
168
|
// `/think` is on anyway. Same shim shape as `modelRuntime` below.
|
|
148
169
|
const thinkingLevel = (ctx as { thinkingLevel?: ThinkingLevel }).thinkingLevel;
|
|
149
|
-
|
|
150
|
-
|
|
170
|
+
// No project or inline extensions run in the hidden session. The parent's
|
|
171
|
+
// programmatic context hooks cannot be enumerated from ExtensionContext,
|
|
172
|
+
// so replaying only file-backed hooks would be an unsafe partial policy.
|
|
173
|
+
const loader = new DefaultResourceLoader({
|
|
174
|
+
cwd: ctx.cwd,
|
|
175
|
+
agentDir: getAgentDir(),
|
|
176
|
+
noExtensions: true,
|
|
177
|
+
noSkills: true,
|
|
178
|
+
noPromptTemplates: true,
|
|
179
|
+
noThemes: true,
|
|
180
|
+
noContextFiles: true,
|
|
181
|
+
systemPromptOverride: () => MENTION_SYSTEM_PROMPT,
|
|
182
|
+
appendSystemPromptOverride: () => [],
|
|
183
|
+
});
|
|
184
|
+
const created = await runInChildSessionContext(async () => {
|
|
185
|
+
await loader.reload();
|
|
186
|
+
return createAgentSession({
|
|
151
187
|
cwd: ctx.cwd,
|
|
152
188
|
// Nothing about the copy is worth persisting, and an in-memory manager
|
|
153
189
|
// is also what keeps the real session untouched.
|
|
154
|
-
sessionManager:
|
|
190
|
+
sessionManager: cloneManager,
|
|
191
|
+
resourceLoader: loader,
|
|
155
192
|
model: ctx.model as Model<never> | undefined,
|
|
156
193
|
...(thinkingLevel && { thinkingLevel }),
|
|
157
|
-
|
|
158
|
-
...(parentModelRuntime !== undefined && { modelRuntime: parentModelRuntime as never }),
|
|
194
|
+
...parentModels,
|
|
159
195
|
// An allowlist naming exactly the clone's own tool. NOT `noTools:
|
|
160
196
|
// "all"`, whose doc comment ("start with no tools enabled") reads like
|
|
161
197
|
// it spares custom tools and does not: it resolves to an EMPTY
|
|
@@ -166,24 +202,32 @@ export async function runMentionClone(opts: MentionCloneOptions): Promise<Mentio
|
|
|
166
202
|
// agent-runner's `tools: sessionTools` beside its nested `customTools`.
|
|
167
203
|
tools: [cloneAgentTool.name],
|
|
168
204
|
customTools: [cloneAgentTool],
|
|
169
|
-
} as Parameters<typeof createAgentSession>[0])
|
|
170
|
-
);
|
|
205
|
+
} as Parameters<typeof createAgentSession>[0]);
|
|
206
|
+
});
|
|
171
207
|
session = created.session;
|
|
208
|
+
// Loading resources and creating the session both yield. If the parent was
|
|
209
|
+
// replaced during either step, never make a provider request for a stale
|
|
210
|
+
// mention. The tool-level check below still fences a switch during streaming.
|
|
211
|
+
if (!isOriginCurrent()) return { spawned: false, error: "the original session changed" };
|
|
172
212
|
|
|
173
|
-
//
|
|
174
|
-
//
|
|
175
|
-
//
|
|
176
|
-
// is
|
|
177
|
-
const
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
213
|
+
// A session switch during a slow provider stream may produce no tool call
|
|
214
|
+
// for minutes. The tool fence below prevents a stale spawn, but without
|
|
215
|
+
// cancelling the clone that hidden request keeps running after its parent
|
|
216
|
+
// is gone. Check while the prompt is pending and abort the throwaway turn.
|
|
217
|
+
const clone = session;
|
|
218
|
+
let abortRequested = false;
|
|
219
|
+
const staleCheck = setInterval(() => {
|
|
220
|
+
if (abortRequested || isOriginCurrent()) return;
|
|
221
|
+
abortRequested = true;
|
|
222
|
+
void clone.abort().catch(() => {});
|
|
223
|
+
}, 100);
|
|
224
|
+
try {
|
|
225
|
+
// Only the current mention text reaches the hidden provider request.
|
|
226
|
+
// The selected type is enforced by the Agent wrapper, not extra history.
|
|
227
|
+
await clone.prompt(message);
|
|
228
|
+
} finally {
|
|
229
|
+
clearInterval(staleCheck);
|
|
230
|
+
}
|
|
187
231
|
} catch (err) {
|
|
188
232
|
return { spawned, error: err instanceof Error ? err.message : String(err) };
|
|
189
233
|
} finally {
|
|
@@ -192,5 +236,7 @@ export async function runMentionClone(opts: MentionCloneOptions): Promise<Mentio
|
|
|
192
236
|
|
|
193
237
|
return spawned
|
|
194
238
|
? { spawned: true }
|
|
195
|
-
:
|
|
239
|
+
: refused
|
|
240
|
+
? { spawned: false, refused: true, error: "the Agent tool refused this mention" }
|
|
241
|
+
: { spawned: false, error: "the conversation clone did not start it" };
|
|
196
242
|
}
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import type { ExtensionContext, ModelRuntime } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import * as PiCodingAgent from "@earendil-works/pi-coding-agent";
|
|
3
|
+
|
|
4
|
+
function isSameModel(candidate: unknown, selected: { provider: string; id: string }): boolean {
|
|
5
|
+
if (!candidate || typeof candidate !== "object") return false;
|
|
6
|
+
const model = candidate as { provider?: unknown; id?: unknown };
|
|
7
|
+
return model.provider === selected.provider && model.id === selected.id;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Pi 0.80.8+ requires the parent's ModelRuntime, not just its public registry
|
|
12
|
+
* facade. ExtensionContext does not expose that runtime. Keep the one private
|
|
13
|
+
* compatibility read here until Pi provides a public accessor. Passing no
|
|
14
|
+
* runtime on a modern host silently creates a fresh one, losing extension
|
|
15
|
+
* providers, credentials and virtual routes before the first child request.
|
|
16
|
+
* Without selectedModel, check only the runtime's shape: the parent ctx.model
|
|
17
|
+
* may be stale while the child explicitly selects a valid physical model.
|
|
18
|
+
*/
|
|
19
|
+
export function parentModelSessionOptions(
|
|
20
|
+
ctx: ExtensionContext,
|
|
21
|
+
selectedModel?: { provider: string; id: string },
|
|
22
|
+
): {
|
|
23
|
+
modelRegistry: ExtensionContext["modelRegistry"];
|
|
24
|
+
modelRuntime?: ModelRuntime;
|
|
25
|
+
} {
|
|
26
|
+
const modelRegistry = ctx.modelRegistry;
|
|
27
|
+
const modernHost = typeof PiCodingAgent.ModelRuntime?.create === "function";
|
|
28
|
+
let candidate: unknown;
|
|
29
|
+
try {
|
|
30
|
+
candidate = (modelRegistry as unknown as { runtime?: unknown }).runtime;
|
|
31
|
+
} catch {
|
|
32
|
+
// Treat an inaccessible private bridge exactly like a missing one; never
|
|
33
|
+
// include accessor errors, which might contain provider configuration.
|
|
34
|
+
}
|
|
35
|
+
let compatible = false;
|
|
36
|
+
try {
|
|
37
|
+
if (candidate !== null && typeof candidate === "object") {
|
|
38
|
+
const runtime = candidate as Record<string, unknown>;
|
|
39
|
+
// Pi 0.84/0.87 already have ModelRuntime.create but no resolveModel;
|
|
40
|
+
// virtual routing is a 0.99 feature. Check the shared child-session
|
|
41
|
+
// capabilities available across all supported runtime generations.
|
|
42
|
+
compatible = typeof runtime.getAuth === "function" && typeof runtime.stream === "function" &&
|
|
43
|
+
typeof runtime.getModel === "function" && typeof runtime.streamSimple === "function";
|
|
44
|
+
if (compatible && modernHost && selectedModel) {
|
|
45
|
+
const model = (runtime.getModel as (provider: string, id: string) => unknown)(selectedModel.provider, selectedModel.id);
|
|
46
|
+
compatible = isSameModel(model, selectedModel);
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
} catch {
|
|
50
|
+
compatible = false;
|
|
51
|
+
}
|
|
52
|
+
if (modernHost && !compatible) {
|
|
53
|
+
throw new Error(
|
|
54
|
+
"Cannot start a child session: Pi exposes ModelRuntime.create but the parent's model runtime is unavailable or incompatible. " +
|
|
55
|
+
"Update Pi to a host that exposes the parent runtime to extensions, or disable subagent/mention dispatch; " +
|
|
56
|
+
"a fresh runtime would lose custom providers and virtual model routes.",
|
|
57
|
+
);
|
|
58
|
+
}
|
|
59
|
+
return {
|
|
60
|
+
modelRegistry, // older supported hosts still consume this option
|
|
61
|
+
...(compatible ? { modelRuntime: candidate as ModelRuntime } : {}),
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** Admit a spawn before allocating any identity; final model validation stays in runAgent. */
|
|
66
|
+
export function assertParentModelRuntimeAvailable(ctx: ExtensionContext): void {
|
|
67
|
+
parentModelSessionOptions(ctx);
|
|
68
|
+
}
|
package/src/model-scope.ts
CHANGED
|
@@ -8,6 +8,7 @@
|
|
|
8
8
|
*/
|
|
9
9
|
|
|
10
10
|
import { isModelInScope, type ModelRegistryRef, readEnabledModels, resolveEnabledModels } from "./enabled-models.js";
|
|
11
|
+
import type { ProjectTrust } from "./project-trust.js";
|
|
11
12
|
|
|
12
13
|
/**
|
|
13
14
|
* When enabled, subagent model choices are validated against `enabledModels`
|
|
@@ -22,7 +23,7 @@ export function isScopeModelsEnabled(): boolean { return scopeModelsEnabled; }
|
|
|
22
23
|
export function setScopeModelsEnabled(enabled: boolean): void { scopeModelsEnabled = enabled; }
|
|
23
24
|
|
|
24
25
|
export type ModelScopeVerdict =
|
|
25
|
-
/** In scope, or nothing to validate against (feature off / no allowlist). */
|
|
26
|
+
/** In scope, or nothing to validate against (feature off / no configured allowlist). */
|
|
26
27
|
| { kind: "ok" }
|
|
27
28
|
/** Caller-supplied out-of-scope choice — refuse the spawn with this message. */
|
|
28
29
|
| { kind: "error"; message: string }
|
|
@@ -41,6 +42,8 @@ export type ModelScopeVerdict =
|
|
|
41
42
|
export function checkModelScope(args: {
|
|
42
43
|
model: { provider: string; id: string } | undefined;
|
|
43
44
|
cwd: string;
|
|
45
|
+
/** Captured config root/trust and retained ancestor denials; omit only for standalone helpers. */
|
|
46
|
+
authority?: ProjectTrust;
|
|
44
47
|
modelRegistry: ModelRegistryRef;
|
|
45
48
|
/** True when the model came from the tool call rather than frontmatter. */
|
|
46
49
|
callerSupplied: boolean;
|
|
@@ -52,7 +55,7 @@ export function checkModelScope(args: {
|
|
|
52
55
|
const { model, cwd, modelRegistry, callerSupplied, agentLabel, modelInput } = args;
|
|
53
56
|
if (!scopeModelsEnabled || !model) return { kind: "ok" };
|
|
54
57
|
|
|
55
|
-
const allowed = resolveEnabledModels(readEnabledModels(cwd), modelRegistry, cwd);
|
|
58
|
+
const allowed = resolveEnabledModels(readEnabledModels(cwd, args.authority), modelRegistry, cwd);
|
|
56
59
|
if (!allowed || isModelInScope(model, allowed)) return { kind: "ok" };
|
|
57
60
|
|
|
58
61
|
// A tier whose profile says `inherit` names no model of its own, so fall back
|
|
@@ -60,7 +63,9 @@ export function checkModelScope(args: {
|
|
|
60
63
|
// `Model not in scope: "undefined"`, which tells the caller nothing it can act on.
|
|
61
64
|
const modelLabel = modelInput ?? `${model.provider}/${model.id}`;
|
|
62
65
|
if (callerSupplied) {
|
|
63
|
-
const list =
|
|
66
|
+
const list = allowed.size > 0
|
|
67
|
+
? [...allowed].sort().map(m => ` ${m}`).join("\n")
|
|
68
|
+
: " (no configured models are currently available or supported)";
|
|
64
69
|
return {
|
|
65
70
|
kind: "error",
|
|
66
71
|
message: `Model not in scope: "${modelLabel}".\n\nAllowed models (from enabledModels):\n${list}`,
|
package/src/nested-tools.ts
CHANGED
|
@@ -16,6 +16,7 @@ import {
|
|
|
16
16
|
resolveEnabledTypeIn,
|
|
17
17
|
resolveTypeIn,
|
|
18
18
|
} from "./agent-types.js";
|
|
19
|
+
import { INHERIT_CONTEXT_UNAVAILABLE } from "./context-boundary.js";
|
|
19
20
|
import { loadCustomAgents } from "./custom-agents.js";
|
|
20
21
|
import { resolveAgentInvocationConfig } from "./invocation-config.js";
|
|
21
22
|
import { resolveModel } from "./model-resolver.js";
|
|
@@ -26,6 +27,7 @@ import {
|
|
|
26
27
|
streamToOutputFile,
|
|
27
28
|
writeInitialEntry,
|
|
28
29
|
} from "./output-file.js";
|
|
30
|
+
import { configurationContext, type ProjectTrust } from "./project-trust.js";
|
|
29
31
|
import { getForegroundOutcomeNote, getStatusNote, partialOutputSuffix } from "./status-note.js";
|
|
30
32
|
import type {
|
|
31
33
|
AgentConfig,
|
|
@@ -103,6 +105,8 @@ export interface NestedToolContext {
|
|
|
103
105
|
allowedSubagents: "all" | string[];
|
|
104
106
|
/** Root used for agent/config discovery; may differ from the agent's working directory. */
|
|
105
107
|
configCwd: string;
|
|
108
|
+
/** Immutable configuration authority captured by the runner, including ancestor denials. */
|
|
109
|
+
projectTrust: ProjectTrust;
|
|
106
110
|
}
|
|
107
111
|
|
|
108
112
|
function textResult(text: string, isError = false) {
|
|
@@ -151,7 +155,9 @@ export function createNestedSubagentTools(context: NestedToolContext): ToolDefin
|
|
|
151
155
|
// Agents resolve from a registry built for THIS branch's config root (under
|
|
152
156
|
// worktree isolation, the copy). Never via registerAgents — that is
|
|
153
157
|
// process-global state shared with the main session and every other agent.
|
|
154
|
-
const loadRegistry = () => buildAgentRegistry(loadCustomAgents(
|
|
158
|
+
const loadRegistry = () => buildAgentRegistry(loadCustomAgents(
|
|
159
|
+
context.configCwd, false, context.projectTrust.trusted, context.projectTrust.deniedRoots,
|
|
160
|
+
));
|
|
155
161
|
const allowedTypesIn = (registry: Map<string, AgentConfig>): Set<string> | undefined =>
|
|
156
162
|
context.allowedSubagents === "all"
|
|
157
163
|
? undefined
|
|
@@ -176,7 +182,7 @@ export function createNestedSubagentTools(context: NestedToolContext): ToolDefin
|
|
|
176
182
|
run_in_background: Type.Optional(Type.Boolean()),
|
|
177
183
|
resume: Type.Optional(Type.String({ description: "Resume a nested agent owned by this parent." })),
|
|
178
184
|
isolated: Type.Optional(Type.Boolean()),
|
|
179
|
-
inherit_context: Type.Optional(Type.Boolean()),
|
|
185
|
+
inherit_context: Type.Optional(Type.Boolean({ description: "Unavailable: true is rejected. Put an explicitly sanitized summary in the task prompt instead." })),
|
|
180
186
|
isolation: Type.Optional(Type.Literal("worktree")),
|
|
181
187
|
}),
|
|
182
188
|
execute: async (_toolCallId, params, signal, _onUpdate, ctx) => {
|
|
@@ -228,6 +234,7 @@ export function createNestedSubagentTools(context: NestedToolContext): ToolDefin
|
|
|
228
234
|
...params,
|
|
229
235
|
agentTiers: getAgentTiersSettings(),
|
|
230
236
|
});
|
|
237
|
+
if (invocation.inheritContext) return textResult(INHERIT_CONTEXT_UNAVAILABLE, true);
|
|
231
238
|
// Leave model/thinking unset whenever an Agent tier may apply. runAgent
|
|
232
239
|
// is the sole final resolver; pre-resolving here would let a parent or
|
|
233
240
|
// legacy field bypass the selected profile.
|
|
@@ -248,6 +255,7 @@ export function createNestedSubagentTools(context: NestedToolContext): ToolDefin
|
|
|
248
255
|
const scopeVerdict = checkModelScope({
|
|
249
256
|
model,
|
|
250
257
|
cwd: context.configCwd,
|
|
258
|
+
authority: context.projectTrust,
|
|
251
259
|
modelRegistry: ctx.modelRegistry,
|
|
252
260
|
callerSupplied: invocation.modelFromParams,
|
|
253
261
|
agentLabel: config?.displayName ?? resolvedType,
|
|
@@ -337,7 +345,7 @@ export function createNestedSubagentTools(context: NestedToolContext): ToolDefin
|
|
|
337
345
|
}
|
|
338
346
|
};
|
|
339
347
|
|
|
340
|
-
// `ctx` is forwarded
|
|
348
|
+
// `ctx` is forwarded with configuration-root provenance, never captured at tool-build
|
|
341
349
|
// time: each AgentSession builds its own ExtensionRunner from that session's
|
|
342
350
|
// cwd/sessionManager/modelRegistry, so this is the CHILD's context. Capturing
|
|
343
351
|
// one earlier would silently give a grandchild the wrong worktree base, the
|
|
@@ -348,7 +356,7 @@ export function createNestedSubagentTools(context: NestedToolContext): ToolDefin
|
|
|
348
356
|
// letting it escape into the child's turn.
|
|
349
357
|
try {
|
|
350
358
|
if (invocation.runInBackground) {
|
|
351
|
-
const id = context.manager.spawn(context.pi, ctx, resolvedType, params.prompt, {
|
|
359
|
+
const id = context.manager.spawn(context.pi, configurationContext(ctx, context.configCwd, context.projectTrust), resolvedType, params.prompt, {
|
|
352
360
|
...options,
|
|
353
361
|
isBackground: true,
|
|
354
362
|
});
|
|
@@ -360,7 +368,7 @@ export function createNestedSubagentTools(context: NestedToolContext): ToolDefin
|
|
|
360
368
|
|
|
361
369
|
const { record } = await context.manager.spawnAndWait(
|
|
362
370
|
context.pi,
|
|
363
|
-
ctx,
|
|
371
|
+
configurationContext(ctx, context.configCwd, context.projectTrust),
|
|
364
372
|
resolvedType,
|
|
365
373
|
params.prompt,
|
|
366
374
|
{ ...options, signal },
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
import { lstatSync, readlinkSync, realpathSync } from "node:fs";
|
|
2
|
+
import { dirname, isAbsolute, parse, relative, resolve, sep } from "node:path";
|
|
3
|
+
import type { ExtensionAPI, ExtensionContext, ExtensionFactory, SettingsManager } from "@earendil-works/pi-coding-agent";
|
|
4
|
+
|
|
5
|
+
// Child contexts report the execution cwd, but their SettingsManager may own
|
|
6
|
+
// a different configuration root. Keep that provenance package-internal when
|
|
7
|
+
// nested tools forward the child's public context to the manager.
|
|
8
|
+
export interface ProjectTrust {
|
|
9
|
+
readonly cwd: string;
|
|
10
|
+
readonly trusted: boolean;
|
|
11
|
+
readonly deniedRoots: readonly string[];
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
const configurationRoots = new WeakMap<ExtensionContext, {
|
|
15
|
+
cwd: string;
|
|
16
|
+
trusted?: boolean;
|
|
17
|
+
deniedRoots: readonly string[];
|
|
18
|
+
}>();
|
|
19
|
+
|
|
20
|
+
export function configurationContext(ctx: ExtensionContext, cwd: string, trust?: ProjectTrust): ExtensionContext {
|
|
21
|
+
const previous = configurationRoots.get(ctx);
|
|
22
|
+
configurationRoots.set(ctx, {
|
|
23
|
+
cwd,
|
|
24
|
+
trusted: trust?.trusted ?? previous?.trusted,
|
|
25
|
+
deniedRoots: Object.freeze([...new Set([...(previous?.deniedRoots ?? []), ...(trust?.deniedRoots ?? [])])]),
|
|
26
|
+
});
|
|
27
|
+
return ctx;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** A changed configuration root is denied; ancestor denials survive every hop. */
|
|
31
|
+
export function childProjectTrust(parent: ProjectTrust, cwd: string): ProjectTrust {
|
|
32
|
+
const trusted = parent.trusted && isSameConfiguration(cwd, parent.cwd);
|
|
33
|
+
return Object.freeze({
|
|
34
|
+
cwd: canonicalPath(cwd),
|
|
35
|
+
trusted,
|
|
36
|
+
deniedRoots: Object.freeze([...new Set([...parent.deniedRoots, ...(trusted ? [] : [canonicalPath(cwd)])])]),
|
|
37
|
+
});
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** Pi's MCP factory reads config from the session cwd; pin only its bootstrap to the config root. */
|
|
41
|
+
export function configurationMcpFactory(factory: ExtensionFactory, cwd: string, settings: SettingsManager): ExtensionFactory {
|
|
42
|
+
return (pi) => factory(new Proxy(pi, {
|
|
43
|
+
get(target, key, receiver) {
|
|
44
|
+
if (key === "on") return ((event: string, handler: (event: unknown, ctx: ExtensionContext) => unknown) => {
|
|
45
|
+
if (event === "session_start") {
|
|
46
|
+
return target.on("session_start", async (event, ctx) => {
|
|
47
|
+
await handler(event, {
|
|
48
|
+
...ctx,
|
|
49
|
+
cwd,
|
|
50
|
+
isProjectTrusted: () => settings.isProjectTrusted(),
|
|
51
|
+
});
|
|
52
|
+
});
|
|
53
|
+
}
|
|
54
|
+
return Reflect.apply(target.on, target, [event, handler]);
|
|
55
|
+
}) as ExtensionAPI["on"];
|
|
56
|
+
return Reflect.get(target, key, receiver);
|
|
57
|
+
},
|
|
58
|
+
}));
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function canonicalPath(path: string): string {
|
|
62
|
+
try {
|
|
63
|
+
return realpathSync(path);
|
|
64
|
+
} catch {
|
|
65
|
+
return resolve(path);
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** Capture before child resolution/async work; missing or failed trust is denial. */
|
|
70
|
+
export function captureProjectTrust(ctx: ExtensionContext): ProjectTrust {
|
|
71
|
+
const provenance = configurationRoots.get(ctx);
|
|
72
|
+
const cwd = canonicalPath(provenance?.cwd ?? ctx.cwd);
|
|
73
|
+
let trusted = false;
|
|
74
|
+
try {
|
|
75
|
+
// Validate the public trust API even with retained authority. If present,
|
|
76
|
+
// prefer the captured configuration trust to a late execution-cwd callback.
|
|
77
|
+
if (typeof ctx.isProjectTrusted === "function") {
|
|
78
|
+
const publicTrust = ctx.isProjectTrusted() === true;
|
|
79
|
+
trusted = provenance?.trusted ?? publicTrust;
|
|
80
|
+
}
|
|
81
|
+
} catch {
|
|
82
|
+
// A context that cannot establish trust must not authorize project code.
|
|
83
|
+
}
|
|
84
|
+
return Object.freeze({
|
|
85
|
+
cwd,
|
|
86
|
+
trusted,
|
|
87
|
+
deniedRoots: Object.freeze([...new Set([...(provenance?.deniedRoots ?? []), ...(trusted ? [] : [cwd])])]),
|
|
88
|
+
});
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export function isSameConfiguration(cwd: string, parentCwd: string): boolean {
|
|
92
|
+
return canonicalPath(cwd) === parentCwd;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Check every traversed prefix, including intermediate symlink targets. A
|
|
97
|
+
* global alias into denied metadata cannot gain authority by pointing its
|
|
98
|
+
* descendants back outside. This is provenance admission, not a race-free
|
|
99
|
+
* filesystem sandbox; an absent leaf still has its existing ancestors checked.
|
|
100
|
+
*/
|
|
101
|
+
export function isProjectResource(path: string, cwd: string): boolean {
|
|
102
|
+
if (path.startsWith("builtin:")) return false;
|
|
103
|
+
const roots = [resolve(cwd), canonicalPath(cwd)];
|
|
104
|
+
const within = (file: string): boolean => roots.some(root => {
|
|
105
|
+
const rel = relative(root, file);
|
|
106
|
+
return rel === "" || (rel !== ".." && !rel.startsWith(`..${sep}`) && !isAbsolute(rel));
|
|
107
|
+
});
|
|
108
|
+
const visited = new Set<string>();
|
|
109
|
+
const traverse = (file: string): boolean => {
|
|
110
|
+
// Normalize separator variants, never dot segments. Both resolve and join
|
|
111
|
+
// would erase a traversed symlink before a following ".." is inspected.
|
|
112
|
+
let absolute = sep === "\\" ? file.replaceAll("/", sep) : file;
|
|
113
|
+
if (!isAbsolute(absolute)) {
|
|
114
|
+
const root = parse(absolute).root;
|
|
115
|
+
// Resolve only the base (including Windows drive-relative bases), not
|
|
116
|
+
// the unexamined components supplied by the caller or readlink.
|
|
117
|
+
absolute = `${resolve(root || ".")}${sep}${absolute.slice(root.length)}`;
|
|
118
|
+
}
|
|
119
|
+
let prefix = parse(absolute).root;
|
|
120
|
+
if (within(prefix) || within(canonicalPath(prefix))) return true;
|
|
121
|
+
for (const component of absolute.slice(prefix.length).split(sep)) {
|
|
122
|
+
if (!component) continue;
|
|
123
|
+
prefix = `${prefix}${prefix.endsWith(sep) ? "" : sep}${component}`;
|
|
124
|
+
if (within(prefix) || within(canonicalPath(prefix))) return true;
|
|
125
|
+
try {
|
|
126
|
+
if (!lstatSync(prefix).isSymbolicLink()) continue;
|
|
127
|
+
} catch {
|
|
128
|
+
// No readable descendant can exist beyond an absent/invalid prefix.
|
|
129
|
+
return false;
|
|
130
|
+
}
|
|
131
|
+
// Unknown/cyclic resolution is not evidence of global authority.
|
|
132
|
+
if (visited.has(prefix) || visited.size >= 64) return true;
|
|
133
|
+
visited.add(prefix);
|
|
134
|
+
try {
|
|
135
|
+
const target = readlinkSync(prefix);
|
|
136
|
+
if (traverse(isAbsolute(target) ? target : `${dirname(prefix)}${sep}${target}`)) return true;
|
|
137
|
+
} catch {
|
|
138
|
+
return true;
|
|
139
|
+
} finally {
|
|
140
|
+
visited.delete(prefix);
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
return false;
|
|
144
|
+
};
|
|
145
|
+
return traverse(path);
|
|
146
|
+
}
|