@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.
@@ -5,7 +5,18 @@
5
5
  * User agents override defaults with the same name. Disabled agents are kept but excluded from spawning.
6
6
  */
7
7
 
8
- import { createCodingTools, createReadOnlyTools } from "@earendil-works/pi-coding-agent";
8
+ import {
9
+ createBashToolDefinition,
10
+ createCodingTools,
11
+ createEditToolDefinition,
12
+ createFindToolDefinition,
13
+ createGrepToolDefinition,
14
+ createLsToolDefinition,
15
+ createPowerShellToolDefinition,
16
+ createReadOnlyTools,
17
+ createReadToolDefinition,
18
+ createWriteToolDefinition,
19
+ } from "@earendil-works/pi-coding-agent";
9
20
  import { DEFAULT_AGENTS } from "./default-agents.js";
10
21
  import type { AgentConfig } from "./types.js";
11
22
 
@@ -21,6 +32,40 @@ export const BUILTIN_TOOL_NAMES: string[] = [
21
32
  ...new Set([...createCodingTools("."), ...createReadOnlyTools(".")].map((t) => t.name)),
22
33
  ];
23
34
 
35
+ /**
36
+ * One-line `<tools>` snippets for the built-in tools, keyed by name.
37
+ *
38
+ * An append-mode child inherits the parent's `<tools>` block, which lists only
39
+ * the tools the parent has *active* — pi's default set is read/bash/edit/write —
40
+ * while an agent's `tools:` defaults to every built-in, grep/find/ls included.
41
+ * Retargeting the block from the inherited text alone would therefore advertise
42
+ * fewer tools than the child can call. These come from pi's own definitions, so
43
+ * the wording cannot drift from what pi prints in a main session.
44
+ */
45
+ let builtinToolSnippets: Record<string, string> | undefined;
46
+
47
+ /** Snippets for pi's built-in tools, built on first use (see above). */
48
+ export function getBuiltinToolSnippets(): Record<string, string> {
49
+ // Built by calling pi's own factories rather than importing a snippet table, so
50
+ // the wording cannot drift. Called with the process cwd: a snippet is static text
51
+ // and nothing here executes the tool.
52
+ builtinToolSnippets ??= Object.fromEntries(
53
+ [
54
+ createReadToolDefinition("."),
55
+ createBashToolDefinition("."),
56
+ createEditToolDefinition("."),
57
+ createWriteToolDefinition("."),
58
+ createGrepToolDefinition("."),
59
+ createFindToolDefinition("."),
60
+ createLsToolDefinition("."),
61
+ createPowerShellToolDefinition("."),
62
+ ].flatMap((definition) =>
63
+ definition.promptSnippet ? [[definition.name, definition.promptSnippet] as const] : [],
64
+ ),
65
+ );
66
+ return builtinToolSnippets;
67
+ }
68
+
24
69
  /** Unified runtime registry of all agents (defaults + user-defined). */
25
70
  const agents = new Map<string, AgentConfig>();
26
71
 
@@ -33,6 +78,47 @@ export function isDefaultsDisabled(): boolean { return disableDefaults; }
33
78
  /** Set whether default agents are disabled. */
34
79
  export function setDefaultsDisabled(b: boolean): void { disableDefaults = b; }
35
80
 
81
+ /** `fallbackSubagent` value that disables the fallback entirely (strict dispatch). */
82
+ export const NO_FALLBACK = "none";
83
+
84
+ /**
85
+ * The agent type an unset `fallbackSubagent` targets. Naming it explicitly is
86
+ * equivalent to leaving the setting unset — see `resolveSpawnTypeIn`.
87
+ */
88
+ export const IMPLICIT_FALLBACK = "general-purpose";
89
+
90
+ /**
91
+ * Agent type substituted when a caller-supplied `subagent_type` doesn't resolve
92
+ * to exactly one enabled agent. `undefined` keeps the historical behavior
93
+ * (general-purpose); `NO_FALLBACK` makes dispatch fail closed. Set from
94
+ * `subagents.json` (`fallbackSubagent`).
95
+ *
96
+ * Module state rather than an index.ts closure because every caller-supplied
97
+ * spawn path needs it — the Agent tool, the scheduler, and cross-extension RPC.
98
+ */
99
+ let fallbackSubagent: string | undefined;
100
+
101
+ /** Get the configured fallback agent type. undefined = general-purpose. */
102
+ export function getFallbackSubagent(): string | undefined { return fallbackSubagent; }
103
+
104
+ /** Set the configured fallback agent type. undefined = general-purpose. */
105
+ export function setFallbackSubagent(v: string | undefined): void { fallbackSubagent = v; }
106
+
107
+ /**
108
+ * Build a registry map: DEFAULT_AGENTS first (unless disabled via settings),
109
+ * then user agents overlaid on top (same name overrides the default).
110
+ * Pure — callers that must not disturb the process-wide registry (nested
111
+ * delegation resolving agents from its own config root) build their own map.
112
+ */
113
+ export function buildAgentRegistry(userAgents: Map<string, AgentConfig>): Map<string, AgentConfig> {
114
+ const registry = new Map<string, AgentConfig>();
115
+ if (!disableDefaults) {
116
+ for (const [name, config] of DEFAULT_AGENTS) registry.set(name, config);
117
+ }
118
+ for (const [name, config] of userAgents) registry.set(name, config);
119
+ return registry;
120
+ }
121
+
36
122
  /**
37
123
  * Register agents into the unified registry.
38
124
  * Starts with DEFAULT_AGENTS, then overlays user agents (overrides defaults with same name).
@@ -40,30 +126,51 @@ export function setDefaultsDisabled(b: boolean): void { disableDefaults = b; }
40
126
  */
41
127
  export function registerAgents(userAgents: Map<string, AgentConfig>): void {
42
128
  agents.clear();
43
-
44
- // Start with defaults (unless disabled via settings)
45
- if (!disableDefaults) {
46
- for (const [name, config] of DEFAULT_AGENTS) {
47
- agents.set(name, config);
48
- }
49
- }
50
-
51
- // Overlay user agents (overrides defaults with same name)
52
- for (const [name, config] of userAgents) {
129
+ for (const [name, config] of buildAgentRegistry(userAgents)) {
53
130
  agents.set(name, config);
54
131
  }
55
132
  }
56
133
 
57
- /** Case-insensitive key resolution. */
58
- function resolveKey(name: string): string | undefined {
59
- if (agents.has(name)) return name;
134
+ /** Case-insensitive key resolution within a registry. */
135
+ function resolveKeyIn(registry: Map<string, AgentConfig>, name: string): string | undefined {
136
+ if (registry.has(name)) return name;
60
137
  const lower = name.toLowerCase();
61
- for (const key of agents.keys()) {
138
+ for (const key of registry.keys()) {
62
139
  if (key.toLowerCase() === lower) return key;
63
140
  }
64
141
  return undefined;
65
142
  }
66
143
 
144
+ /** Case-insensitive key resolution. */
145
+ function resolveKey(name: string): string | undefined {
146
+ return resolveKeyIn(agents, name);
147
+ }
148
+
149
+ /** Resolve a type name case-insensitively in a registry. Returns the canonical key or undefined. */
150
+ export function resolveTypeIn(registry: Map<string, AgentConfig>, name: string): string | undefined {
151
+ return resolveKeyIn(registry, name);
152
+ }
153
+
154
+ /** Get the agent config for a type (case-insensitive) from a registry. */
155
+ export function getAgentConfigIn(registry: Map<string, AgentConfig>, name: string): AgentConfig | undefined {
156
+ const key = resolveKeyIn(registry, name);
157
+ return key ? registry.get(key) : undefined;
158
+ }
159
+
160
+ /** Check if a type is valid and enabled (case-insensitive) in a registry. */
161
+ export function isValidTypeIn(registry: Map<string, AgentConfig>, type: string): boolean {
162
+ const key = resolveKeyIn(registry, type);
163
+ if (!key) return false;
164
+ return registry.get(key)?.enabled !== false;
165
+ }
166
+
167
+ /** Get all enabled type names in a registry (for spawning and tool descriptions). */
168
+ export function getAvailableTypesIn(registry: Map<string, AgentConfig>): string[] {
169
+ return [...registry.entries()]
170
+ .filter(([_, config]) => config.enabled !== false)
171
+ .map(([name]) => name);
172
+ }
173
+
67
174
  /** Resolve a type name case-insensitively. Returns the canonical key or undefined. */
68
175
  export function resolveType(name: string): string | undefined {
69
176
  return resolveKey(name);
@@ -71,15 +178,12 @@ export function resolveType(name: string): string | undefined {
71
178
 
72
179
  /** Get the agent config for a type (case-insensitive). */
73
180
  export function getAgentConfig(name: string): AgentConfig | undefined {
74
- const key = resolveKey(name);
75
- return key ? agents.get(key) : undefined;
181
+ return getAgentConfigIn(agents, name);
76
182
  }
77
183
 
78
184
  /** Get all enabled type names (for spawning and tool descriptions). */
79
185
  export function getAvailableTypes(): string[] {
80
- return [...agents.entries()]
81
- .filter(([_, config]) => config.enabled !== false)
82
- .map(([name]) => name);
186
+ return getAvailableTypesIn(agents);
83
187
  }
84
188
 
85
189
  /** Get all type names including disabled (for UI listing). */
@@ -103,9 +207,146 @@ export function getUserAgentNames(): string[] {
103
207
 
104
208
  /** Check if a type is valid and enabled (case-insensitive). */
105
209
  export function isValidType(type: string): boolean {
106
- const key = resolveKey(type);
107
- if (!key) return false;
108
- return agents.get(key)?.enabled !== false;
210
+ return isValidTypeIn(agents, type);
211
+ }
212
+
213
+ /**
214
+ * Case-insensitive resolution that refuses to guess. An exact match always wins;
215
+ * otherwise the name must match exactly one key. Two agents differing only in
216
+ * case are reachable (`loadCustomAgents` keys by filename across three
217
+ * directories), and picking whichever came first would silently dispatch a
218
+ * different agent, model and tool policy than the caller meant.
219
+ */
220
+ function resolveUnambiguousKeyIn(registry: Map<string, AgentConfig>, name: string): string | undefined {
221
+ if (registry.has(name)) return name;
222
+ const lower = name.toLowerCase();
223
+ const matches = [...registry.keys()].filter(key => key.toLowerCase() === lower);
224
+ return matches.length === 1 ? matches[0] : undefined;
225
+ }
226
+
227
+ /** Whether any key in the registry matches `name` case-insensitively. */
228
+ function isNameClaimed(registry: Map<string, AgentConfig>, name: string): boolean {
229
+ const lower = name.toLowerCase();
230
+ return [...registry.keys()].some((key) => key.toLowerCase() === lower);
231
+ }
232
+
233
+ /**
234
+ * The canonical key for a caller-supplied name that identifies exactly one
235
+ * ENABLED agent, or undefined. Strict by construction: no fallback, no guessing
236
+ * between case-variants.
237
+ */
238
+ export function resolveEnabledTypeIn(
239
+ registry: Map<string, AgentConfig>,
240
+ requested: unknown,
241
+ ): string | undefined {
242
+ const raw = typeof requested === "string" ? requested.trim() : "";
243
+ if (!raw) return undefined;
244
+ const key = resolveUnambiguousKeyIn(registry, raw);
245
+ return key !== undefined && registry.get(key)?.enabled !== false ? key : undefined;
246
+ }
247
+
248
+ /** Outcome of resolving a caller-supplied `subagent_type` into a spawnable type. */
249
+ export type SpawnTypeResolution =
250
+ /** Spawn this type. `fellBackFrom` is set when it isn't what the caller asked for. */
251
+ | { ok: true; type: string; fellBackFrom?: string }
252
+ /** Refuse the spawn and return this message to the caller. */
253
+ | { ok: false; message: string };
254
+
255
+ /**
256
+ * Resolve a caller-supplied agent type against a registry, applying the
257
+ * `fallbackSubagent` policy. The single decision point for every caller-supplied
258
+ * spawn — the Agent tool, the scheduler and cross-extension RPC — so a type that
259
+ * fails here never reaches `runAgent`, where `getConfig` would silently
260
+ * substitute general-purpose.
261
+ *
262
+ * Unknown, disabled, and case-ambiguous names are all treated the same way:
263
+ * the caller named something that doesn't identify exactly one enabled agent.
264
+ *
265
+ * Pure over `registry` — callers that need fresh agent files reload before
266
+ * calling (the Agent tool already does, per spawn). Reloading here would mean
267
+ * importing custom-agents.ts, which imports this module.
268
+ */
269
+ export function resolveSpawnTypeIn(
270
+ registry: Map<string, AgentConfig>,
271
+ requested: unknown,
272
+ ): SpawnTypeResolution {
273
+ const raw = typeof requested === "string" ? requested.trim() : "";
274
+ const available = () => getAvailableTypesIn(registry).join(", ") || "(none)";
275
+
276
+ const key = resolveEnabledTypeIn(registry, raw);
277
+ if (key !== undefined) return { ok: true, type: key };
278
+
279
+ // A missing type follows the same policy as a wrong one rather than always
280
+ // erroring: before this setting existed an empty type fell back like any
281
+ // other unresolvable name, and only opting in should change that.
282
+ const reason = raw ? `Unknown or disabled agent type: "${raw}".` : "No agent type given.";
283
+
284
+ // Trimmed like `requested`: a padded value set programmatically would
285
+ // otherwise be reported as a missing agent.
286
+ const configured = typeof fallbackSubagent === "string" ? fallbackSubagent.trim() : undefined;
287
+
288
+ if (configured !== undefined && configured.toLowerCase() === NO_FALLBACK) {
289
+ return { ok: false, message: `${reason} Available: ${available()}.` };
290
+ }
291
+
292
+ if (configured !== undefined) {
293
+ const fallbackKey = resolveUnambiguousKeyIn(registry, configured);
294
+ if (fallbackKey === undefined || registry.get(fallbackKey)?.enabled === false) {
295
+ // Naming the implicit default is not a misconfiguration when nothing in the
296
+ // registry claims the name at all: an unset fallback already targets it, and
297
+ // the permissive hardcoded tier is what that resolves to even when the
298
+ // default agents are disabled — the case the settings row's own default
299
+ // cycles through. A registered-but-disabled general-purpose, or a
300
+ // case-ambiguous name, stays an error like any other: someone wrote that on
301
+ // purpose, and quietly running the all-tools tier is what `fallbackSubagent`
302
+ // exists to prevent. `none` is how strict dispatch is expressed.
303
+ if (configured.toLowerCase() === IMPLICIT_FALLBACK && !isNameClaimed(registry, configured)) {
304
+ return { ok: true, type: IMPLICIT_FALLBACK, fellBackFrom: raw };
305
+ }
306
+ // An explicitly configured fallback that is itself unusable is a
307
+ // misconfiguration, not a second chance to guess — say so rather than
308
+ // quietly dropping to general-purpose.
309
+ return {
310
+ ok: false,
311
+ message:
312
+ `${reason} The configured fallbackSubagent "${configured}" is itself ` +
313
+ `unknown or disabled. Available: ${available()}.`,
314
+ };
315
+ }
316
+ return { ok: true, type: fallbackKey, fellBackFrom: raw };
317
+ }
318
+
319
+ // Unset: historical behavior, deliberately unchanged. The fallback stays the
320
+ // default, so the pre-existing hole it leaves — an unregistered
321
+ // general-purpose resolving to `getConfig`'s all-tools hardcoded tier — is
322
+ // what `fallbackSubagent: none` is for, not something to close under everyone
323
+ // silently.
324
+ return { ok: true, type: IMPLICIT_FALLBACK, fellBackFrom: raw };
325
+ }
326
+
327
+ /** Resolve a caller-supplied agent type against the process-wide registry. */
328
+ export function resolveSpawnType(requested: unknown): SpawnTypeResolution {
329
+ return resolveSpawnTypeIn(agents, requested);
330
+ }
331
+
332
+ /**
333
+ * Whether a configured `fallbackSubagent` names something dispatch would accept.
334
+ *
335
+ * Mirrors `resolveSpawnTypeIn`'s policy exactly, including the ambiguity over ALL
336
+ * registry keys (not just the enabled ones) and the implicit default's unregistered
337
+ * case. The settings row uses this so it cannot tell the user a value is fine while
338
+ * dispatch refuses it, or the reverse.
339
+ */
340
+ export function isFallbackResolvableIn(registry: Map<string, AgentConfig>, configured: string): boolean {
341
+ const name = configured.trim();
342
+ const key = resolveUnambiguousKeyIn(registry, name);
343
+ if (key !== undefined) return registry.get(key)?.enabled !== false;
344
+ return name.toLowerCase() === IMPLICIT_FALLBACK && !isNameClaimed(registry, name);
345
+ }
346
+
347
+ /** Whether a configured fallback resolves, against the process-wide registry. */
348
+ export function isFallbackResolvable(configured: string): boolean {
349
+ return isFallbackResolvableIn(agents, configured);
109
350
  }
110
351
 
111
352
  /** Tool names required for memory management. */
@@ -187,3 +428,8 @@ export function getConfig(type: string): {
187
428
  };
188
429
  }
189
430
 
431
+ // Seed the defaults at module load so consumers that run before the extension's
432
+ // init (direct store use, tools) see the same registry production uses;
433
+ // registerAgents() re-seeds on every explicit call.
434
+ registerAgents(new Map());
435
+
@@ -0,0 +1,49 @@
1
+ /**
2
+ * checkpoint.ts — the subagent-facing `checkpoint` write path, shared by the
3
+ * top-level extension registration (every subagent session) and the nested
4
+ * delegation tool set, so the two cannot drift on format or failure handling.
5
+ */
6
+
7
+ import { appendFileSync } from "node:fs";
8
+ import type { AgentRecord } from "./types.js";
9
+
10
+ /** Derive the `.checkpoints.md` path from `outputFile`. Mirrors the
11
+ * `record.outputFile` gate (undefined when suppressed). Appends the suffix
12
+ * unconditionally — a `replace(/\.output$/, ...)` would silently return the
13
+ * transcript path unchanged if `outputFile` ever lacked the suffix. */
14
+ export function checkpointsFilePath(outputFile: string | undefined): string | undefined {
15
+ if (!outputFile) return undefined;
16
+ return `${outputFile}.checkpoints.md`;
17
+ }
18
+
19
+ /**
20
+ * Record a checkpoint on `record` and append it to the `.checkpoints.md`
21
+ * history. Returns the tool text for the calling subagent.
22
+ *
23
+ * Best-effort: a write failure (disk full, missing parent dir) returns a soft
24
+ * warning instead of crashing the call. `checkpointsFileOk` is latching-true —
25
+ * see AgentRecord.checkpointsFileOk for the suppression contract.
26
+ */
27
+ export function saveCheckpoint(record: AgentRecord, summary: string): string {
28
+ const turn = record.turnCount ?? 0;
29
+ const maxTurns = record.effectiveMaxTurns;
30
+ const elapsedSeconds = Math.round((Date.now() - record.startedAt) / 1000);
31
+ record.lastCheckpoint = { turn, summary };
32
+
33
+ const maxTurnsLabel = maxTurns != null ? `/${maxTurns}` : "";
34
+ const path = checkpointsFilePath(record.outputFile);
35
+ if (path) {
36
+ try {
37
+ appendFileSync(
38
+ path,
39
+ `## Turn ${turn}${maxTurnsLabel} — ${elapsedSeconds}s elapsed\n${summary}\n\n`,
40
+ "utf-8",
41
+ );
42
+ record.checkpointsFileOk = true;
43
+ } catch (err) {
44
+ return `Checkpoint saved in memory, but file write failed: ${err instanceof Error ? err.message : String(err)}.`;
45
+ }
46
+ }
47
+
48
+ return `Checkpoint saved (turn ${turn}${maxTurnsLabel}, ${elapsedSeconds}s).`;
49
+ }
@@ -0,0 +1,30 @@
1
+ /**
2
+ * child-context.ts — marks the async context in which a subagent's session is
3
+ * loaded and constructed.
4
+ *
5
+ * The extension factory runs during `loader.reload()`, so a child session that
6
+ * loads pi-subagents re-activates it in this process. That activation must not
7
+ * register the parent-facing tools (`Agent`, `get_subagent_result`,
8
+ * `steer_subagent`): its manager is empty, so an `Agent` there could only spawn
9
+ * into a manager nobody reads — and an opted-in child receives the nested
10
+ * `Agent` as an SDK custom tool, where a second definition of the same name
11
+ * would leave the survivor to pi's registration precedence.
12
+ *
13
+ * Async-context-local so concurrent top-level extension work is unaffected, and
14
+ * so a harness that activates the extension directly (no child context) still
15
+ * gets the parent tools.
16
+ */
17
+
18
+ import { AsyncLocalStorage } from "node:async_hooks";
19
+
20
+ const childSessionContext = new AsyncLocalStorage<boolean>();
21
+
22
+ /** True while the current async context is building a subagent session. */
23
+ export function inChildSessionContext(): boolean {
24
+ return childSessionContext.getStore() === true;
25
+ }
26
+
27
+ /** Run `fn` (resource loading / session construction) as a subagent session. */
28
+ export function runInChildSessionContext<T>(fn: () => Promise<T>): Promise<T> {
29
+ return childSessionContext.run(true, fn);
30
+ }
@@ -1,8 +1,8 @@
1
1
  /**
2
2
  * Cross-extension RPC handlers for the subagents extension.
3
3
  *
4
- * Exposes ping, spawn, and stop RPCs over the pi.events event bus,
5
- * using per-request scoped reply channels.
4
+ * Exposes ping, spawn, stop, consume and steer RPCs over the pi.events event
5
+ * bus, using per-request scoped reply channels.
6
6
  *
7
7
  * Reply envelope follows pi-mono convention:
8
8
  * success → { success: true, data?: T }
@@ -10,6 +10,7 @@
10
10
  */
11
11
 
12
12
  import { type ModelRegistry, resolveModel } from "./model-resolver.js";
13
+ import type { AgentRecord } from "./types.js";
13
14
 
14
15
  /** Minimal event bus interface needed by the RPC handlers. */
15
16
  export interface EventBus {
@@ -25,10 +26,27 @@ export type RpcReply<T = void> =
25
26
  /** RPC protocol version — bumped when the envelope or method contracts change. */
26
27
  export const PROTOCOL_VERSION = 2;
27
28
 
28
- /** Minimal AgentManager interface needed by the spawn/stop RPCs. */
29
+ /** Minimal AgentManager interface needed by the spawn/stop/consume/steer RPCs. */
29
30
  export interface SpawnCapable {
30
31
  spawn(pi: unknown, ctx: unknown, type: string, prompt: string, options: any): string;
31
32
  abort(id: string): boolean;
33
+ /**
34
+ * The record behind an id, for the ownership check stop, consume and steer
35
+ * share. Narrowed to the one field that check reads, so the RPC layer keeps
36
+ * its deliberately shallow view of the manager.
37
+ */
38
+ getRecord(id: string): Pick<AgentRecord, "parentAgentId"> | undefined;
39
+ /**
40
+ * Mark a settled agent's result as read by the caller, suppressing the
41
+ * completion notification — what `get_subagent_result` does when it returns
42
+ * one. False when there is no such agent, or it has not settled yet.
43
+ */
44
+ consumeResult(id: string): boolean;
45
+ /**
46
+ * Deliver a message to a running or queued agent's conversation — what
47
+ * `steer_subagent` does. False when the agent is unknown or has settled.
48
+ */
49
+ steer(id: string, message: string): boolean;
32
50
  }
33
51
 
34
52
  export interface RpcDeps {
@@ -42,6 +60,8 @@ export interface RpcHandle {
42
60
  unsubPing: () => void;
43
61
  unsubSpawn: () => void;
44
62
  unsubStop: () => void;
63
+ unsubConsume: () => void;
64
+ unsubSteer: () => void;
45
65
  }
46
66
 
47
67
  /**
@@ -69,7 +89,7 @@ function handleRpc<P extends { requestId: string }>(
69
89
  }
70
90
 
71
91
  /**
72
- * Register ping, spawn, and stop RPC handlers on the event bus.
92
+ * Register ping, spawn, stop, consume and steer RPC handlers on the event bus.
73
93
  * Returns unsub functions for cleanup.
74
94
  */
75
95
  export function registerRpcHandlers(deps: RpcDeps): RpcHandle {
@@ -112,11 +132,51 @@ export function registerRpcHandlers(deps: RpcDeps): RpcHandle {
112
132
  },
113
133
  );
114
134
 
135
+ // Only the session's own agents are this RPC's to stop, steer or consume. A
136
+ // nested child is owned by the agent *waiting on it*, and aborting, redirecting
137
+ // or silencing it out from under that owner turns another extension's request
138
+ // into a failed step here. Defence in depth rather than a live hole: no RPC
139
+ // hands out agent ids, so a caller has no ordinary way to name one it does not
140
+ // own — but the guard is cheap and the id may leak some other way.
141
+ const assertOwned = (agentId: string): void => {
142
+ const record = manager.getRecord(agentId);
143
+ if (!record) throw new Error("Agent not found");
144
+ if (record.parentAgentId) throw new Error("Agent is owned by another agent");
145
+ };
146
+
115
147
  const unsubStop = handleRpc<{ requestId: string; agentId: string }>(
116
148
  events, "subagents:rpc:stop", ({ agentId }) => {
149
+ assertOwned(agentId);
117
150
  if (!manager.abort(agentId)) throw new Error("Agent not found");
118
151
  },
119
152
  );
120
153
 
121
- return { unsubPing, unsubSpawn, unsubStop };
154
+ // A caller that has already shown the model an agent's result — pi-tasks'
155
+ // TaskOutput is the one in practice — says so here, so the completion
156
+ // notification for that same result is not delivered on top of it and does
157
+ // not cost the parent a turn. Deliberately outside the ping version
158
+ // handshake: an extension built against protocol v2 simply never calls it.
159
+ const unsubConsume = handleRpc<{ requestId: string; agentId: string }>(
160
+ events, "subagents:rpc:consume", ({ agentId }) => {
161
+ assertOwned(agentId);
162
+ if (!manager.consumeResult(agentId)) throw new Error("Agent not found or still running");
163
+ },
164
+ );
165
+
166
+ // An extension that spawned an agent over the bus and then learns something
167
+ // that agent needs — a second CI failure on the head it is already fixing, a
168
+ // review that landed mid-run — can hand it over instead of spawning a second
169
+ // agent for the same work or dropping the fact. Outside the ping version
170
+ // handshake, like consume: additive and best-effort.
171
+ const unsubSteer = handleRpc<{ requestId: string; agentId: string; message: unknown }>(
172
+ events, "subagents:rpc:steer", ({ agentId, message }) => {
173
+ if (typeof message !== "string" || message.trim().length === 0) {
174
+ throw new Error("Steer message must be a non-empty string");
175
+ }
176
+ assertOwned(agentId);
177
+ if (!manager.steer(agentId, message)) throw new Error("Agent is not running");
178
+ },
179
+ );
180
+
181
+ return { unsubPing, unsubSpawn, unsubStop, unsubConsume, unsubSteer };
122
182
  }