@cad0p/pi-subagents-tintinweb 0.14.2 → 0.22.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,37 +1,106 @@
1
- import type { AgentConfig, IsolationMode, JoinMode, ThinkingLevel } from "./types.js";
1
+ import { Type } from "@sinclair/typebox";
2
+ import type { AgentConfig, IsolationMode, ThinkingLevel } from "./types.js";
3
+
4
+ /**
5
+ * The model-facing `isolation` parameter.
6
+ *
7
+ * Shape matters more than wording here. As a single-value optional literal,
8
+ * models that fill every optional parameter had only `"worktree"` available to
9
+ * fill it with, and kept spawning worktrees across consecutive turns while their
10
+ * own reasoning said to omit the field. Every other optional parameter has an
11
+ * inert filler; this one did not. `"off"` is listed first and described as the
12
+ * default so the harmless value is the obvious one to reach for.
13
+ *
14
+ * The wording tracks Claude Code's own `isolation` parameter, whose phrasing
15
+ * models have the most exposure to: one description on the union rather than
16
+ * per-value ones, opening "Isolation mode.", then a sentence per value in
17
+ * schema order, each with its caveats in a trailing parenthetical. Two clauses
18
+ * are ours, because our shape is not theirs — `"off"` has no counterpart there
19
+ * (their enum is `worktree | remote`, so both of their values do something),
20
+ * and neither does the uncommitted-work warning, which is the specific trap of
21
+ * a fresh copy that cannot see the main checkout's pending work. Deliberately
22
+ * absent is any "only use a worktree when…" restriction: the tool states the
23
+ * capability and stops, and a second legal value is what lets a model decline
24
+ * one, not being told to.
25
+ */
26
+ const isolationParamShape = {
27
+ isolation: Type.Optional(
28
+ Type.Union([Type.Literal("off"), Type.Literal("worktree")], {
29
+ description:
30
+ 'Isolation mode. Default "off". "off" runs the agent in the current checkout, the same as omitting the field. "worktree" creates a temporary git worktree so the agent works on an isolated copy of the repo (a copy cannot see uncommitted or staged changes in the main checkout).',
31
+ }),
32
+ ),
33
+ };
34
+
35
+ /**
36
+ * Build the `isolation` parameter for a tool schema, or nothing when the
37
+ * project disabled worktrees (`worktreeIsolation: false`).
38
+ *
39
+ * Dropping the field beats accepting it and quietly downgrading. The setting is
40
+ * for a project whose model passes `"worktree"` on *every* call, so a
41
+ * per-result "isolation was disabled" note would be noise on every result and
42
+ * would keep raising the salience of a capability that isn't there. With no
43
+ * field there is nothing to pass, nothing to drop, and nothing to explain — the
44
+ * same trade `scheduleParam` makes for disabled scheduling, at zero LLM-context
45
+ * cost. The resolver gate and the `agent-manager` check still cover the paths a
46
+ * schema can't reach: agent files, the scheduler, and cross-extension RPC.
47
+ *
48
+ * Like `scheduleParam`, this is read once at tool registration — flipping the
49
+ * setting needs a new pi session for the schema to change.
50
+ */
51
+ export function isolationParam(enabled: boolean): Partial<typeof isolationParamShape> {
52
+ return enabled ? isolationParamShape : {};
53
+ }
2
54
 
3
55
  interface AgentInvocationParams {
4
- thinking?: string;
5
56
  max_turns?: number;
6
- run_in_background?: boolean;
7
57
  inherit_context?: boolean;
8
58
  isolated?: boolean;
9
- isolation?: IsolationMode;
59
+ /**
60
+ * Untyped on purpose. The tool schema builds this field conditionally and
61
+ * spreads it, which erases TypeBox's literal inference to `unknown`. The
62
+ * resolver below narrows by comparison rather than trusting the declaration,
63
+ * which also makes it safe for the cross-extension RPC path, where options
64
+ * arrive unvalidated.
65
+ */
66
+ isolation?: unknown;
67
+ }
68
+
69
+ interface ResolveOptions {
70
+ /**
71
+ * Whether worktree isolation is permitted at all. False when the project set
72
+ * `worktreeIsolation: false`, which drops a requested worktree rather than
73
+ * failing the call: the fail-loud precedent covers spawns that *cannot* work,
74
+ * while this one is the user opting out, and throwing would break exactly the
75
+ * calls the `"off"` value exists to tolerate. Defaults to allowed.
76
+ */
77
+ worktreeAllowed?: boolean;
10
78
  }
11
79
 
12
80
  export function resolveAgentInvocationConfig(
13
81
  agentConfig: AgentConfig | undefined,
14
82
  params: AgentInvocationParams,
83
+ opts?: ResolveOptions,
15
84
  ): {
16
85
  modelInput?: string;
17
86
  thinking?: ThinkingLevel;
18
87
  maxTurns?: number;
19
88
  inheritContext: boolean;
20
- runInBackground: boolean;
21
89
  isolated: boolean;
22
90
  isolation?: IsolationMode;
23
91
  } {
92
+ // Precedence first, collapse second — reversing these loses the veto, since
93
+ // an agent file's "off" only outranks a caller's "worktree" while it is still
94
+ // a value. Everything downstream then sees "worktree" or nothing at all.
95
+ const requested = agentConfig?.isolation ?? params.isolation;
96
+ const isolation = requested === "worktree" && opts?.worktreeAllowed !== false ? "worktree" : undefined;
97
+
24
98
  return {
25
99
  modelInput: agentConfig?.model,
26
- thinking: (agentConfig?.thinking ?? params.thinking) as ThinkingLevel | undefined,
100
+ thinking: agentConfig?.thinking,
27
101
  maxTurns: agentConfig?.maxTurns ?? params.max_turns,
28
102
  inheritContext: agentConfig?.inheritContext ?? params.inherit_context ?? false,
29
- runInBackground: agentConfig?.runInBackground ?? params.run_in_background ?? false,
30
103
  isolated: agentConfig?.isolated ?? params.isolated ?? false,
31
- isolation: agentConfig?.isolation ?? params.isolation,
104
+ isolation,
32
105
  };
33
106
  }
34
-
35
- export function resolveJoinMode(defaultJoinMode: JoinMode, runInBackground: boolean): JoinMode | undefined {
36
- return runInBackground ? defaultJoinMode : undefined;
37
- }
@@ -14,6 +14,24 @@ export interface ModelRegistry {
14
14
  getAvailable?(): any[];
15
15
  }
16
16
 
17
+ /**
18
+ * Both display forms of a model. The short one goes on tight rows (the widget,
19
+ * the Agent tool result), the canonical one where there is room to disambiguate
20
+ * two providers serving a similarly-named model (the conversation viewer).
21
+ *
22
+ * One function, because `index.ts` labels the model it resolved before the run
23
+ * and `agent-manager.ts` relabels it from the live session afterwards — the two
24
+ * must agree or the label would visibly change the moment the session starts.
25
+ */
26
+ export function describeModel(
27
+ model: { provider: string; id: string; name?: string },
28
+ ): { modelName: string; modelId: string } {
29
+ return {
30
+ modelName: (model.name ?? model.id).replace(/^Claude\s+/i, "").toLowerCase(),
31
+ modelId: `${model.provider}/${model.id}`,
32
+ };
33
+ }
34
+
17
35
  /**
18
36
  * Resolve a model string to a Model instance.
19
37
  * Tries exact match first ("provider/modelId"), then fuzzy match against all available models.
@@ -0,0 +1,62 @@
1
+ /**
2
+ * model-scope.ts — the `scopeModels` policy, shared by the top-level Agent tool
3
+ * and the nested delegation tools so a nested spawn cannot escape the allowlist
4
+ * the top-level path enforces.
5
+ *
6
+ * State lives here (rather than in an index.ts closure) for the same reason
7
+ * `disableDefaults` lives in agent-types.ts: both entry points need it, and a
8
+ * setting only one path can read is a setting the other path silently ignores.
9
+ *
10
+ * This fork has no caller-supplied model (the `model`/`thinking` tool params
11
+ * were removed), so every effective model is either frontmatter-pinned or
12
+ * inherited from the parent — the policy is always warn-and-proceed. See the
13
+ * SubagentsSettings.scopeModels docstring for the rationale.
14
+ */
15
+
16
+ import { isModelInScope, type ModelRegistryRef, readEnabledModels, resolveEnabledModels } from "./enabled-models.js";
17
+ import { toSingleLine } from "./text-safety.js";
18
+
19
+ /**
20
+ * When enabled, subagent model choices are validated against `enabledModels`
21
+ * from pi's settings — both global `<agentDir>/settings.json` and project-local
22
+ * `<cwd>/.pi/settings.json` (project overrides global). Off by default; opt-in
23
+ * via `/agents → Settings`.
24
+ */
25
+ let scopeModelsEnabled = false;
26
+
27
+ export function isScopeModelsEnabled(): boolean { return scopeModelsEnabled; }
28
+ export function setScopeModelsEnabled(enabled: boolean): void { scopeModelsEnabled = enabled; }
29
+
30
+ export interface ModelScopeCheck {
31
+ model: { provider: string; id: string } | undefined;
32
+ /** Where pi's settings are read from — the branch's config root. */
33
+ cwd: string;
34
+ modelRegistry: ModelRegistryRef;
35
+ /** Display name used in the warning. */
36
+ agentLabel: string;
37
+ /** The raw `model:` frontmatter input, when there was one. */
38
+ modelInput?: string;
39
+ }
40
+
41
+ /**
42
+ * Check the effective resolved model against the user's enabledModels list.
43
+ *
44
+ * Returns the warning to surface, or undefined when the model is in scope or
45
+ * there is nothing to validate against (feature off, no allowlist). Both
46
+ * entry points proceed regardless: frontmatter is authoritative (the agent's
47
+ * author/installer chose it) and an inherited model was chosen by the user when
48
+ * starting the session.
49
+ */
50
+ export function modelScopeWarning(args: ModelScopeCheck): string | undefined {
51
+ const { model, cwd, modelRegistry, agentLabel, modelInput } = args;
52
+ if (!scopeModelsEnabled || !model) return undefined;
53
+
54
+ const allowed = resolveEnabledModels(readEnabledModels(cwd), modelRegistry, cwd);
55
+ if (!allowed || isModelInScope(model, allowed)) return undefined;
56
+
57
+ // Both fields come from agent frontmatter (untrusted, LLM- or user-authored)
58
+ // and land in a toast or a tool result: collapse newlines and strip control
59
+ // bytes here so neither caller has to remember.
60
+ const modelLabel = toSingleLine(modelInput ?? `${model.provider}/${model.id}`);
61
+ return `Agent "${toSingleLine(agentLabel)}" using out-of-scope model "${modelLabel}"`;
62
+ }
@@ -0,0 +1,406 @@
1
+ /**
2
+ * nested-tools.ts — the child-safe delegation tools a subagent gets when it
3
+ * opts into nesting (`allowed_subagents`) and the branch still has depth left.
4
+ *
5
+ * These are injected as the child session's `customTools`, so the main session's
6
+ * tool spec cost stays zero: an agent that never opts in never pays for them,
7
+ * and an agent at the depth cap gets nothing at all (which is what makes
8
+ * `maxSubagentDepth` 0/1 mean "nesting off" rather than "nesting always fails").
9
+ *
10
+ * Every tool is ownership-scoped to the parent agent instance that created the
11
+ * set, and nested spawns resolve agent types from a registry built for the
12
+ * branch's own config root instead of the process-global one.
13
+ *
14
+ * This fork launches background runs only, so — unlike upstream's foreground
15
+ * path — the Agent tool always returns an id. `get_subagent_result` therefore
16
+ * keeps the fork's full report (turn/max-turns header, latest checkpoint,
17
+ * artifact paths) and adds an opt-in `wait` so a parent can block on its child
18
+ * without polling; cancelling that wait leaves the child running.
19
+ */
20
+
21
+ import type { Model } from "@earendil-works/pi-ai";
22
+ import { type AgentSession, defineTool, type ExtensionAPI, type ExtensionContext, type ToolDefinition } from "@earendil-works/pi-coding-agent";
23
+ import { Type } from "@sinclair/typebox";
24
+ import { abortable } from "./abortable.js";
25
+ import { buildAgentRegistry, getAgentConfigIn, getAvailableTypesIn, isValidTypeIn, resolveTypeIn } from "./agent-types.js";
26
+ import { saveCheckpoint } from "./checkpoint.js";
27
+ import { loadCustomAgents } from "./custom-agents.js";
28
+ import { isolationParam, resolveAgentInvocationConfig } from "./invocation-config.js";
29
+ import { resolveModel } from "./model-resolver.js";
30
+ import { modelScopeWarning } from "./model-scope.js";
31
+ import { createOutputFilePath, getOutputTranscriptDefault, streamToOutputFile, writeInitialEntry } from "./output-file.js";
32
+ import { renderAgentReport } from "./result-report.js";
33
+ import type { AgentConfig, AgentInvocation, AgentRecord, IsolationMode, ThinkingLevel } from "./types.js";
34
+ import { addUsage } from "./usage.js";
35
+ import { isWorktreeIsolationEnabled } from "./worktree.js";
36
+
37
+ /**
38
+ * Hard ceiling on nesting for every branch: main session = 0, its subagents = 1,
39
+ * their children = 2. `0`/`1` disables nesting entirely. Set from
40
+ * `subagents.json` (`maxSubagentDepth`). Read when a subagent session is built,
41
+ * so a change applies to sessions started after it.
42
+ */
43
+ let maxSubagentDepth = 2;
44
+
45
+ export function getMaxSubagentDepth(): number { return maxSubagentDepth; }
46
+ export function setMaxSubagentDepth(n: number): void { maxSubagentDepth = Math.max(0, Math.floor(n)); }
47
+
48
+ /** Spawn options a nested tool hands the manager. A subset of the manager's
49
+ * SpawnOptions plus the branch metadata it must carry down. */
50
+ export interface NestedSpawnOptions {
51
+ description: string;
52
+ model?: Model<any>;
53
+ maxTurns?: number;
54
+ isolated?: boolean;
55
+ inheritContext?: boolean;
56
+ thinkingLevel?: ThinkingLevel;
57
+ isolation?: IsolationMode;
58
+ invocation?: AgentInvocation;
59
+ /** Nesting depth of the CHILD being spawned. */
60
+ depth: number;
61
+ parentAgentId: string;
62
+ maxSubagentDepth: number;
63
+ configCwd: string;
64
+ rootSessionId?: string;
65
+ /** Resolved from this branch's registry; the run must not re-resolve it globally. */
66
+ agentConfig?: AgentConfig;
67
+ onAssistantUsage?: (usage: { input: number; output: number; cacheWrite: number }) => void;
68
+ onSessionCreated?: (session: AgentSession) => void;
69
+ }
70
+
71
+ /** The manager surface nested tools need. Structural so tests can fake it. */
72
+ export interface NestedAgentManager {
73
+ spawn(pi: ExtensionAPI, ctx: ExtensionContext, type: string, prompt: string, options: NestedSpawnOptions): string;
74
+ resume(
75
+ id: string,
76
+ prompt: string,
77
+ callbacks?: { onAssistantUsage?: (usage: { input: number; output: number; cacheWrite: number }) => void },
78
+ ): AgentRecord | undefined;
79
+ getRecord(id: string): AgentRecord | undefined;
80
+ listAgents(): AgentRecord[];
81
+ }
82
+
83
+ export interface NestedToolContext {
84
+ manager: NestedAgentManager;
85
+ pi: ExtensionAPI;
86
+ /** The agent this set belongs to — the ownership key for every call. */
87
+ parentAgentId: string;
88
+ /** This agent's own depth; children spawn at depth + 1. */
89
+ depth: number;
90
+ maxSubagentDepth: number;
91
+ /** "all" = any enabled agent; string[] = only those types. Never empty. */
92
+ allowedSubagents: "all" | string[];
93
+ /** Root used for agent/config discovery; may differ from the agent's working directory. */
94
+ configCwd: string;
95
+ }
96
+
97
+ function textResult(text: string, isError = false) {
98
+ return { content: [{ type: "text" as const, text }], isError, details: undefined };
99
+ }
100
+
101
+ /** A record is reachable only through the agent that spawned it. */
102
+ function ownsRecord(record: AgentRecord | undefined, parentAgentId: string): record is AgentRecord {
103
+ return record?.parentAgentId === parentAgentId;
104
+ }
105
+
106
+ /** Build child-safe orchestration tools scoped to one parent agent instance. */
107
+ export function createNestedSubagentTools(context: NestedToolContext): ToolDefinition[] {
108
+ // Agents resolve from a registry built for THIS branch's config root (under
109
+ // worktree isolation, the copy). Never via registerAgents — that is
110
+ // process-global state shared with the main session and every other agent.
111
+ const loadRegistry = () => buildAgentRegistry(loadCustomAgents(context.configCwd));
112
+ const allowedTypesIn = (registry: Map<string, AgentConfig>): Set<string> | undefined =>
113
+ context.allowedSubagents === "all"
114
+ ? undefined
115
+ : new Set(context.allowedSubagents.map(name => resolveTypeIn(registry, name) ?? name));
116
+ const availableIn = (registry: Map<string, AgentConfig>): string[] => {
117
+ const allowed = allowedTypesIn(registry);
118
+ return getAvailableTypesIn(registry).filter(name => allowed === undefined || allowed.has(name));
119
+ };
120
+
121
+ const agentTool = defineTool({
122
+ name: "Agent",
123
+ label: "Agent",
124
+ description:
125
+ "Launch a child-safe nested subagent for bounded delegated work. Runs in the background and returns an " +
126
+ "agent ID; poll it with get_subagent_result. Only the agent types this parent is allowed to delegate to " +
127
+ "are accepted, and nesting is depth-limited.",
128
+ promptSnippet: "Launch a nested subagent for bounded delegated work",
129
+ promptGuidelines: [
130
+ "Nested subagents run in the background: keep working, then read the result with get_subagent_result (use wait to block).",
131
+ "Never delegate understanding — hand over the exact task, files, and what you already know.",
132
+ ],
133
+ parameters: Type.Object({
134
+ prompt: Type.String({ description: "Self-contained task for the nested agent." }),
135
+ description: Type.String({ description: "Short 3-5 word task description." }),
136
+ subagent_type: Type.String({
137
+ description: `Allowed nested agent type. Available: ${availableIn(loadRegistry()).join(", ") || "none"}.`,
138
+ }),
139
+ max_turns: Type.Optional(
140
+ Type.Number({ description: "Maximum number of agentic turns before stopping. Omit for the agent's own limit.", minimum: 1 }),
141
+ ),
142
+ resume: Type.Optional(Type.String({ description: "Resume a nested agent owned by this parent." })),
143
+ isolated: Type.Optional(Type.Boolean({ description: "If true, the nested agent gets no extension tools." })),
144
+ inherit_context: Type.Optional(
145
+ Type.Boolean({ description: "If true, fork this agent's conversation into the nested agent." }),
146
+ ),
147
+ // Same shape as the top-level tool: the parameter disappears entirely when
148
+ // worktree isolation is switched off, so a model filling every optional
149
+ // field cannot select a mode the project refuses.
150
+ ...isolationParam(isWorktreeIsolationEnabled()),
151
+ }),
152
+ execute: async (_toolCallId, params, _signal, _onUpdate, ctx) => {
153
+ // Usage a nested child reports belongs to every ancestor: nested records
154
+ // are hidden from every reporting surface, so their spend would otherwise
155
+ // be unattributable. The whole chain is walked — a spawn callback only
156
+ // fires for that child's OWN turns, so stopping at one level would hide a
157
+ // great-grandchild from the only record anyone can see.
158
+ const foldUsageIntoAncestors = (usage: { input: number; output: number; cacheWrite: number }): void => {
159
+ for (let id: string | undefined = context.parentAgentId; id !== undefined; ) {
160
+ const ancestor = context.manager.getRecord(id);
161
+ if (!ancestor) break;
162
+ addUsage(ancestor.lifetimeUsage, usage);
163
+ id = ancestor.parentAgentId;
164
+ }
165
+ };
166
+
167
+ if (params.resume) {
168
+ const existing = context.manager.getRecord(params.resume);
169
+ if (!ownsRecord(existing, context.parentAgentId)) {
170
+ return textResult(`Nested agent not found or not owned by this parent: "${params.resume}".`, true);
171
+ }
172
+ const resumed = context.manager.resume(params.resume, params.prompt, {
173
+ onAssistantUsage: foldUsageIntoAncestors,
174
+ });
175
+ if (!resumed) {
176
+ return textResult(`Failed to resume nested agent "${params.resume}" — it is still active or has no session.`, true);
177
+ }
178
+ return textResult(
179
+ `Nested agent resumed in background.\nAgent ID: ${resumed.id}\n\n` +
180
+ `Use get_subagent_result to retrieve its result. Do not duplicate its work.`,
181
+ );
182
+ }
183
+
184
+ if (context.depth >= context.maxSubagentDepth) {
185
+ return textResult(
186
+ `Nested subagent call blocked (depth=${context.depth}, max=${context.maxSubagentDepth}). Complete the task directly.`,
187
+ true,
188
+ );
189
+ }
190
+
191
+ // Reloaded per call so new agent files are picked up without a restart.
192
+ const registry = loadRegistry();
193
+ const rawType = params.subagent_type;
194
+ const resolvedType = resolveTypeIn(registry, rawType);
195
+ if (!resolvedType || !isValidTypeIn(registry, resolvedType)) {
196
+ return textResult(`Unknown or disabled nested agent type: "${rawType}".`, true);
197
+ }
198
+ const allowed = allowedTypesIn(registry);
199
+ if (allowed !== undefined && !allowed.has(resolvedType)) {
200
+ return textResult(
201
+ `Nested agent type "${resolvedType}" is not allowed for this parent. Allowed: ${[...allowed].join(", ")}.`,
202
+ true,
203
+ );
204
+ }
205
+
206
+ // The config resolved here is handed to the run as well: `runAgent` would
207
+ // otherwise resolve the type against the process-global registry, and an
208
+ // agent that exists only in this branch's config root would silently run
209
+ // as the unknown-type fallback — general-purpose, every tool.
210
+ const config = getAgentConfigIn(registry, resolvedType);
211
+ const invocation = resolveAgentInvocationConfig(config, params, {
212
+ worktreeAllowed: isWorktreeIsolationEnabled(),
213
+ });
214
+ let model = ctx.model;
215
+ if (invocation.modelInput) {
216
+ const resolvedModel = resolveModel(invocation.modelInput, ctx.modelRegistry);
217
+ if (typeof resolvedModel !== "string") model = resolvedModel;
218
+ }
219
+ // Same scopeModels policy as the top-level Agent tool — a nested spawn
220
+ // must not escape the allowlist. A child session has no UI surface to
221
+ // toast to, so the warning rides back in the tool result where the parent
222
+ // model can act on it.
223
+ const scopeWarning = modelScopeWarning({
224
+ model,
225
+ cwd: context.configCwd,
226
+ modelRegistry: ctx.modelRegistry,
227
+ agentLabel: config?.displayName ?? resolvedType,
228
+ modelInput: invocation.modelInput,
229
+ });
230
+
231
+ // The whole branch shares the root session's transcript directory; read it
232
+ // off the owning parent rather than this child session's own id.
233
+ const rootSessionId = context.manager.getRecord(context.parentAgentId)?.rootSessionId;
234
+ const childDepth = context.depth + 1;
235
+
236
+ // Transcript wiring, same gate as the top-level path: the child's
237
+ // `output_transcript` frontmatter wins, else the project default. Filed
238
+ // under the ROOT session so a nested transcript lands in the same
239
+ // `tasks/` directory as its ancestors' rather than in a directory of its
240
+ // own.
241
+ const transcriptSessionId =
242
+ rootSessionId !== undefined && (config?.outputTranscript ?? getOutputTranscriptDefault())
243
+ ? rootSessionId
244
+ : undefined;
245
+ let childId: string | undefined;
246
+
247
+ const options: NestedSpawnOptions = {
248
+ description: params.description,
249
+ model,
250
+ maxTurns: invocation.maxTurns,
251
+ isolated: invocation.isolated,
252
+ inheritContext: invocation.inheritContext,
253
+ thinkingLevel: invocation.thinking,
254
+ isolation: invocation.isolation,
255
+ invocation: {
256
+ thinking: invocation.thinking,
257
+ maxTurns: invocation.maxTurns,
258
+ isolated: invocation.isolated,
259
+ inheritContext: invocation.inheritContext,
260
+ isolation: invocation.isolation,
261
+ },
262
+ agentConfig: config,
263
+ depth: childDepth,
264
+ parentAgentId: context.parentAgentId,
265
+ maxSubagentDepth: context.maxSubagentDepth,
266
+ configCwd: context.configCwd,
267
+ rootSessionId,
268
+ onAssistantUsage: foldUsageIntoAncestors,
269
+ // Fires inside runAgent once the child session exists. The record's
270
+ // outputFile is attached synchronously after spawn returns, so this
271
+ // reads it lazily rather than closing over a value that doesn't exist yet.
272
+ onSessionCreated: (session) => {
273
+ const rec = childId === undefined ? undefined : context.manager.getRecord(childId);
274
+ if (rec?.outputFile && childId !== undefined) {
275
+ rec.outputCleanup = streamToOutputFile(session, rec.outputFile, childId, ctx.cwd);
276
+ }
277
+ },
278
+ };
279
+
280
+ // `ctx` is forwarded to the manager unmodified, never captured at
281
+ // tool-build time: each AgentSession builds its own ExtensionRunner from
282
+ // that session's cwd/sessionManager/modelRegistry, so this is the CHILD's
283
+ // context. Capturing one earlier would silently give a grandchild the
284
+ // wrong worktree base, the wrong conversation under inherit_context, and
285
+ // the wrong inherited model.
286
+ //
287
+ // spawn() throws on strict worktree-isolation failure and cwd validation —
288
+ // report it as a tool error, like the top-level Agent tool does, instead
289
+ // of letting it escape into the child's turn.
290
+ let id: string;
291
+ try {
292
+ id = context.manager.spawn(context.pi, ctx, resolvedType, params.prompt, options);
293
+ } catch (err) {
294
+ return textResult(err instanceof Error ? err.message : String(err), true);
295
+ }
296
+ childId = id;
297
+
298
+ if (transcriptSessionId !== undefined) {
299
+ const rec = context.manager.getRecord(id);
300
+ if (rec) {
301
+ rec.outputFile = createOutputFilePath(context.configCwd, id, transcriptSessionId);
302
+ writeInitialEntry(rec.outputFile, id, params.prompt, ctx.cwd);
303
+ }
304
+ }
305
+
306
+ const record = context.manager.getRecord(id);
307
+ return textResult(
308
+ `Nested agent ${record?.status === "queued" ? "queued" : "started"} in background.\n` +
309
+ `Agent ID: ${id}\n` +
310
+ `Type: ${resolvedType}\n` +
311
+ `Description: ${params.description}\n` +
312
+ (record?.outputFile ? `Output file: ${record.outputFile}\n` : "") +
313
+ (scopeWarning ? `Warning: ${scopeWarning}\n` : "") +
314
+ `\nUse get_subagent_result to retrieve its result. Do not duplicate its work.`,
315
+ );
316
+ },
317
+ });
318
+
319
+ const resultTool = defineTool({
320
+ name: "get_subagent_result",
321
+ label: "Get Nested Agent Result",
322
+ description:
323
+ "Check a nested agent owned by this parent, or block until it settles with wait: true. " +
324
+ "Cancelling the wait leaves the agent running.",
325
+ promptSnippet: "Check or wait for a nested subagent's result",
326
+ parameters: Type.Object({
327
+ agent_id: Type.String({ description: "The nested agent ID returned by the Agent tool." }),
328
+ wait: Type.Optional(
329
+ Type.Boolean({ description: "If true, block until the agent settles before reporting. Default: false." }),
330
+ ),
331
+ }),
332
+ execute: async (_toolCallId, params, signal) => {
333
+ const record = context.manager.getRecord(params.agent_id);
334
+ if (!ownsRecord(record, context.parentAgentId)) {
335
+ return textResult(`Nested agent not found or not owned by this parent: "${params.agent_id}".`, true);
336
+ }
337
+ // Cancellation (the parent's tool call is aborted) stops only this wait;
338
+ // the nested child keeps running and stays fetchable. A queued record has
339
+ // no promise until the manager starts it, so poll — abortably — until it
340
+ // leaves the queue, then await.
341
+ if (params.wait && (record.status === "queued" || record.status === "running")) {
342
+ while (record.status === "queued") {
343
+ await abortable(new Promise<void>(resolve => setTimeout(resolve, 250)), signal);
344
+ }
345
+ if (record.promise) await abortable(record.promise, signal);
346
+ }
347
+ return textResult(renderAgentReport(record), record.status === "error");
348
+ },
349
+ });
350
+
351
+ const steerTool = defineTool({
352
+ name: "steer_subagent",
353
+ label: "Steer Nested Agent",
354
+ description: "Send a steering message to a running nested agent owned by this parent.",
355
+ promptSnippet: "Send a steering message to redirect a nested subagent",
356
+ parameters: Type.Object({
357
+ agent_id: Type.String({ description: "The nested agent ID to steer." }),
358
+ message: Type.String({ description: "The steering message to send." }),
359
+ }),
360
+ execute: async (_toolCallId, params) => {
361
+ const record = context.manager.getRecord(params.agent_id);
362
+ if (!ownsRecord(record, context.parentAgentId)) {
363
+ return textResult(`Nested agent not found or not owned by this parent: "${params.agent_id}".`, true);
364
+ }
365
+ if (record.status !== "running" && record.status !== "queued") {
366
+ return textResult(`Nested agent "${params.agent_id}" is not running (status: ${record.status}).`, true);
367
+ }
368
+ // Session not ready yet — queue the steer. The manager flushes pending
369
+ // steers when the session is created (same contract as the top-level tool).
370
+ if (!record.session) {
371
+ if (!record.pendingSteers) record.pendingSteers = [];
372
+ record.pendingSteers.push(params.message);
373
+ return textResult(`Steering message queued for nested agent ${record.id}. It will be delivered once the session initializes.`);
374
+ }
375
+ try {
376
+ await record.session.steer(params.message);
377
+ } catch (err) {
378
+ return textResult(`Failed to steer nested agent: ${err instanceof Error ? err.message : String(err)}`, true);
379
+ }
380
+ return textResult(`Steering message sent to nested agent ${record.id}.`);
381
+ },
382
+ });
383
+
384
+ const checkpointTool = defineTool({
385
+ name: "checkpoint",
386
+ label: "Checkpoint",
387
+ description:
388
+ "Save a progress checkpoint for the parent. Call at milestones — when you finish a phase, hit a dead end, " +
389
+ "or change approach. Include what you just did and what you're doing next.",
390
+ promptSnippet: "Save a progress checkpoint for the parent",
391
+ parameters: Type.Object({
392
+ summary: Type.String({ description: "1-2 sentences: what you just did, what you're doing next" }),
393
+ }),
394
+ execute: async (_toolCallId, params, _signal, _onUpdate, ctx) => {
395
+ // Records live in the manager that spawned this session — resolve by the
396
+ // child's own session id rather than by ownership, exactly as the
397
+ // top-level registration does.
398
+ const sessionId = ctx.sessionManager.getSessionId();
399
+ const record = context.manager.listAgents().find(r => r.sessionId === sessionId);
400
+ if (!record) return textResult("Checkpoint failed: agent record not found.");
401
+ return textResult(saveCheckpoint(record, params.summary));
402
+ },
403
+ });
404
+
405
+ return [agentTool, resultTool, steerTool, checkpointTool];
406
+ }
@@ -10,6 +10,20 @@ import { tmpdir } from "node:os";
10
10
  import { join } from "node:path";
11
11
  import type { AgentSession, AgentSessionEvent } from "@earendil-works/pi-coding-agent";
12
12
 
13
+ /**
14
+ * Project/global default for writing a subagent's `.output` transcript; a custom
15
+ * agent's `output_transcript` overrides it per agent.
16
+ *
17
+ * State lives here rather than in an index.ts closure because both spawn paths
18
+ * need it — the top-level Agent tool and the nested delegation tools. Same
19
+ * reason `scopeModels` lives in model-scope.ts: a setting only one path can read
20
+ * is a setting the other path silently ignores.
21
+ */
22
+ let outputTranscriptDefault = true;
23
+
24
+ export function getOutputTranscriptDefault(): boolean { return outputTranscriptDefault; }
25
+ export function setOutputTranscriptDefault(b: boolean): void { outputTranscriptDefault = b; }
26
+
13
27
  /**
14
28
  * Encode a cwd path as a filesystem-safe directory name. Handles:
15
29
  * - POSIX: "/home/user/project" → "home-user-project"
@@ -63,8 +77,11 @@ export function streamToOutputFile(
63
77
  path: string,
64
78
  agentId: string,
65
79
  cwd: string,
80
+ startIndex = 1,
66
81
  ): () => void {
67
- let writtenCount = 1; // initial user prompt already written
82
+ // Messages before `startIndex` are already on disk (the initial user prompt
83
+ // for a fresh spawn; the prior run's transcript for a resume).
84
+ let writtenCount = startIndex;
68
85
 
69
86
  const flush = () => {
70
87
  const messages = session.messages;