@signalridge/pi-subagents 1.10.2 → 1.11.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.
@@ -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
+ }
@@ -22,7 +22,7 @@ export function isScopeModelsEnabled(): boolean { return scopeModelsEnabled; }
22
22
  export function setScopeModelsEnabled(enabled: boolean): void { scopeModelsEnabled = enabled; }
23
23
 
24
24
  export type ModelScopeVerdict =
25
- /** In scope, or nothing to validate against (feature off / no allowlist). */
25
+ /** In scope, or nothing to validate against (feature off / no configured allowlist). */
26
26
  | { kind: "ok" }
27
27
  /** Caller-supplied out-of-scope choice — refuse the spawn with this message. */
28
28
  | { kind: "error"; message: string }
@@ -60,7 +60,9 @@ export function checkModelScope(args: {
60
60
  // `Model not in scope: "undefined"`, which tells the caller nothing it can act on.
61
61
  const modelLabel = modelInput ?? `${model.provider}/${model.id}`;
62
62
  if (callerSupplied) {
63
- const list = [...allowed].sort().map(m => ` ${m}`).join("\n");
63
+ const list = allowed.size > 0
64
+ ? [...allowed].sort().map(m => ` ${m}`).join("\n")
65
+ : " (no configured models are currently available or supported)";
64
66
  return {
65
67
  kind: "error",
66
68
  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";
@@ -176,7 +177,7 @@ export function createNestedSubagentTools(context: NestedToolContext): ToolDefin
176
177
  run_in_background: Type.Optional(Type.Boolean()),
177
178
  resume: Type.Optional(Type.String({ description: "Resume a nested agent owned by this parent." })),
178
179
  isolated: Type.Optional(Type.Boolean()),
179
- inherit_context: Type.Optional(Type.Boolean()),
180
+ inherit_context: Type.Optional(Type.Boolean({ description: "Unavailable: true is rejected. Put an explicitly sanitized summary in the task prompt instead." })),
180
181
  isolation: Type.Optional(Type.Literal("worktree")),
181
182
  }),
182
183
  execute: async (_toolCallId, params, signal, _onUpdate, ctx) => {
@@ -228,6 +229,7 @@ export function createNestedSubagentTools(context: NestedToolContext): ToolDefin
228
229
  ...params,
229
230
  agentTiers: getAgentTiersSettings(),
230
231
  });
232
+ if (invocation.inheritContext) return textResult(INHERIT_CONTEXT_UNAVAILABLE, true);
231
233
  // Leave model/thinking unset whenever an Agent tier may apply. runAgent
232
234
  // is the sole final resolver; pre-resolving here would let a parent or
233
235
  // legacy field bypass the selected profile.
package/src/types.ts CHANGED
@@ -42,10 +42,12 @@ export interface AgentConfig {
42
42
  */
43
43
  color?: string;
44
44
  /**
45
- * Tools whose every call needs the user to agree first (`ask_tools:`). The
46
- * third answer between `tools:` and `disallowed_tools:`, for tools that are
47
- * usually fine and occasionally not. Approval comes from the human, never
48
- * from a model — see `ask-tools.ts`.
45
+ * Tools whose first call needs human approval for the child session
46
+ * (`ask_tools:`), including resumed turns of this in-memory child. A reopened
47
+ * child must ask again. The third answer between
48
+ * `tools:` and `disallowed_tools:` for tools that are usually fine and
49
+ * occasionally not. Approval comes from the human, never from a model —
50
+ * see `ask-tools.ts`.
49
51
  */
50
52
  askTools?: string[];
51
53
  /**