@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.
@@ -1,6 +1,6 @@
1
1
  /**
2
- * mention-clone.ts — start a mentioned agent through a clone of this
3
- * conversation, without putting anything in the chat.
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. The conversation is cloned into a
13
- * throwaway in-memory session — same messages, same system prompt, same model —
14
- * and that copy takes the turn off-screen. A literal clone: the session's own
15
- * entries, projected by pi's own `sessionEntryToContextMessages`, not
16
- * `inherit_context`'s text rendering of them.
17
- *
18
- * Cloned from memory rather than from the session file, which cannot be relied
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 { agentMentionReminder } from "./mention.js";
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, and the
78
- * source of both the conversation and the live system prompt. */
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 clone actually called `Agent`. */
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
- * Fork the conversation, let the copy make the tool call, throw the copy away.
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
- // One spawn per mention. The clone has a single tool and every reason to
108
- // stop after using it, but a model that decides to "also" launch a second
109
- // agent would do it where nobody can see and nobody asked.
110
- if (spawned) {
111
- return Promise.resolve({
112
- content: [{ type: "text" as const, text: "Already started an agent for this mention. Stop here." }],
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
- spawned = true;
118
- // undefined tool-call id + the main ctx: see the header. Background is
119
- // forced rather than left to the clone: `run_in_background` defaults to
120
- // false, and a foreground agent answers through its TOOL RESULT — which
121
- // here is delivered into a session that is disposed moments later, so the
122
- // agent would run, appear in the widget and the fleet, and reach nobody.
123
- return agentTool.execute(
124
- undefined as never,
125
- { ...(params as Record<string, unknown>), run_in_background: true } as typeof params,
126
- signal,
127
- onUpdate,
128
- ctx,
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
- // Pi 0.80.8 moved createAgentSession from modelRegistry to modelRuntime;
136
- // agent-runner.ts carries the same shim for the same reason — pass both so
137
- // the clone keeps the parent's providers across the supported range.
138
- const parentModelRuntime = (ctx.modelRegistry as unknown as { runtime?: unknown }).runtime;
139
- // The conversation as the main session resolves it: compaction applied,
140
- // branch summaries substituted.
141
- const conversation = buildSessionContext(
142
- ctx.sessionManager.getEntries(),
143
- ctx.sessionManager.getLeafId(),
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
- const created = await runInChildSessionContext(() =>
150
- createAgentSession({
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: SessionManager.inMemory(ctx.cwd),
190
+ sessionManager: cloneManager,
191
+ resourceLoader: loader,
155
192
  model: ctx.model as Model<never> | undefined,
156
193
  ...(thinkingLevel && { thinkingLevel }),
157
- modelRegistry: ctx.modelRegistry,
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
- // The clone rebuilds a system prompt from cwd and agentDir, which is close
174
- // but not the live one — extensions contribute to it per turn. Copy the
175
- // real thing, so the copy reasons under the instructions the user's model
176
- // is actually working under.
177
- const systemPrompt = ctx.getSystemPrompt?.();
178
- if (systemPrompt) session.agent.state.systemPrompt = systemPrompt;
179
-
180
- // The conversation itself. Pushed rather than assigned so the array the
181
- // session was built around stays the one it goes on using.
182
- session.agent.state.messages.push(...conversation.messages);
183
-
184
- // User text first, reminder after — the order Claude Code's attachment
185
- // renderer produces, where the reminder trails the message it is about.
186
- await session.prompt(`${message}\n\n${agentMentionReminder(type)}`);
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
- : { spawned: false, error: "the conversation clone did not start it" };
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
+ }
@@ -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 = [...allowed].sort().map(m => ` ${m}`).join("\n");
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}`,
@@ -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(context.configCwd));
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 to the manager unmodified, never captured at tool-build
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
+ }