@hheei/pi-subagents 0.1.2 → 0.2.3

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.
Files changed (65) hide show
  1. package/README.md +45 -137
  2. package/dist/agent-resolver.d.ts +20 -0
  3. package/dist/agent-resolver.js +155 -21
  4. package/dist/bridge-client.d.ts +55 -0
  5. package/dist/bridge-client.js +453 -0
  6. package/dist/bridge-server.d.ts +72 -0
  7. package/dist/bridge-server.js +490 -0
  8. package/dist/builtin-agents.d.ts +24 -0
  9. package/dist/builtin-agents.js +152 -0
  10. package/dist/child-bridge.d.ts +31 -15
  11. package/dist/child-bridge.js +95 -117
  12. package/dist/child-control.d.ts +58 -0
  13. package/dist/child-control.js +176 -0
  14. package/dist/child-process.d.ts +21 -0
  15. package/dist/child-process.js +93 -0
  16. package/dist/commands.d.ts +2 -2
  17. package/dist/commands.js +52 -60
  18. package/dist/diagnostics.d.ts +0 -2
  19. package/dist/diagnostics.js +0 -8
  20. package/dist/domain.d.ts +57 -27
  21. package/dist/domain.js +60 -11
  22. package/dist/extension.d.ts +26 -1
  23. package/dist/extension.js +243 -46
  24. package/dist/host-adapter.d.ts +37 -12
  25. package/dist/host-adapter.js +214 -63
  26. package/dist/json-lines.js +2 -1
  27. package/dist/launch-spec.d.ts +8 -6
  28. package/dist/launch-spec.js +61 -18
  29. package/dist/manager.d.ts +93 -49
  30. package/dist/manager.js +1175 -528
  31. package/dist/markdown.d.ts +8 -0
  32. package/dist/markdown.js +21 -0
  33. package/dist/protocol.d.ts +33 -52
  34. package/dist/protocol.js +41 -52
  35. package/dist/registry.d.ts +3 -8
  36. package/dist/registry.js +41 -163
  37. package/dist/rpc-events.d.ts +3 -5
  38. package/dist/rpc-events.js +5 -9
  39. package/dist/runtime.d.ts +66 -11
  40. package/dist/runtime.js +182 -208
  41. package/dist/session-bootstrap.d.ts +23 -7
  42. package/dist/session-bootstrap.js +79 -15
  43. package/dist/session-fork.d.ts +16 -0
  44. package/dist/session-fork.js +67 -0
  45. package/dist/state.d.ts +7 -0
  46. package/dist/state.js +60 -25
  47. package/dist/tool-activation.d.ts +18 -0
  48. package/dist/tool-activation.js +227 -0
  49. package/dist/tools.d.ts +6 -3
  50. package/dist/tools.js +213 -72
  51. package/dist/widget.d.ts +4 -8
  52. package/dist/widget.js +55 -64
  53. package/package.json +51 -52
  54. package/dist/child-nudge.d.ts +0 -24
  55. package/dist/child-nudge.js +0 -102
  56. package/dist/connector.d.ts +0 -53
  57. package/dist/connector.js +0 -283
  58. package/dist/pause-gate.d.ts +0 -14
  59. package/dist/pause-gate.js +0 -64
  60. package/dist/rpc-adapter.d.ts +0 -40
  61. package/dist/rpc-adapter.js +0 -194
  62. package/dist/runner-entry.d.ts +0 -24
  63. package/dist/runner-entry.js +0 -166
  64. package/dist/runner.d.ts +0 -52
  65. package/dist/runner.js +0 -930
@@ -2,19 +2,33 @@ import { randomUUID } from "node:crypto";
2
2
  import { open, readdir, stat } from "node:fs/promises";
3
3
  import { join, resolve } from "node:path";
4
4
  import { SessionManager } from "@earendil-works/pi-coding-agent";
5
+ import { isRecord } from "@hheei/pi-ext-core";
5
6
  import { resolveAgent } from "./agent-resolver.js";
6
- import { isRecord, isSessionId } from "./domain.js";
7
+ import { builtinAgentToolProblem } from "./builtin-agents.js";
8
+ import { isSessionId } from "./domain.js";
7
9
  import { assembleChildPrompt, resolveBridgeExtensionPath, resolvePiInvocation, } from "./launch-spec.js";
10
+ import { formatSessionReferencePrompt } from "./session-fork.js";
8
11
  /** Bounded read for a session header; the first line holds the whole identity of the file. */
9
12
  const HEADER_READ_BYTES = 64 * 1024;
10
13
  /**
11
- * The Pi session directory a child would use by default for its cwd. It is asked
12
- * of Pi itself instead of re-deriving the layout, and is always passed to the child
13
- * as an explicit `--session-dir` so recovery can find the file again.
14
+ * The session directory Pi itself would use for this cwd. At the top level it holds the
15
+ * human's own sessions, which is why children never write there directly.
14
16
  */
15
17
  export function resolveDefaultSessionDir(cwd) {
16
18
  return SessionManager.create(cwd).getSessionDir();
17
19
  }
20
+ /** Child sessions live in this subdirectory of the parent-scoped session directory. */
21
+ const SUBAGENT_SESSION_DIR_NAME = "agents";
22
+ /**
23
+ * The session directory a delegated child writes to.
24
+ *
25
+ * Pi discovers sessions with a non-recursive `readdir` of the session directory, so a
26
+ * subdirectory keeps delegated sessions out of the human's session list while the child still
27
+ * receives that directory as an explicit `--session-dir` recovery can find again.
28
+ */
29
+ export function resolveSubagentSessionDir(cwd) {
30
+ return join(resolveDefaultSessionDir(cwd), SUBAGENT_SESSION_DIR_NAME);
31
+ }
18
32
  async function readSessionHeader(path) {
19
33
  const handle = await open(path, "r");
20
34
  try {
@@ -86,7 +100,7 @@ export async function planSessionPlacement(options) {
86
100
  if (!isSessionId(options.sessionId)) {
87
101
  throw new Error(`Invalid session id ${options.sessionId}`);
88
102
  }
89
- const sessionDir = options.sessionDir ?? resolveDefaultSessionDir(options.cwd);
103
+ const sessionDir = options.sessionDir ?? resolveSubagentSessionDir(options.cwd);
90
104
  const sessionPath = options.sessionPath;
91
105
  if (sessionPath !== undefined &&
92
106
  (await stat(sessionPath).then(() => true, () => false))) {
@@ -107,8 +121,27 @@ export async function planSessionPlacement(options) {
107
121
  }
108
122
  return { sessionId: options.sessionId, sessionDir, persistence: "never_flushed" };
109
123
  }
110
- export function createSubagentId() {
111
- return `sa_${randomUUID().replace(/-/gu, "").slice(0, 12)}`;
124
+ let nextSubagentIndex = 0;
125
+ export function resetSubagentIdCounter(value = 0) {
126
+ nextSubagentIndex = value;
127
+ }
128
+ export function createSubagentId(existingIds) {
129
+ if (existingIds !== undefined && existingIds.length > 0) {
130
+ let max = 0;
131
+ for (const id of existingIds) {
132
+ const match = /^agent-(\d+)$/u.exec(id);
133
+ if (match?.[1]) {
134
+ const num = Number.parseInt(match[1], 10);
135
+ if (num > max)
136
+ max = num;
137
+ }
138
+ }
139
+ if (max > 0) {
140
+ nextSubagentIndex = Math.max(nextSubagentIndex, max);
141
+ }
142
+ }
143
+ nextSubagentIndex += 1;
144
+ return `agent-${nextSubagentIndex}`;
112
145
  }
113
146
  /**
114
147
  * One resolution chain for a spawn: agent definition + explicit parent defaults
@@ -118,13 +151,16 @@ export function createSubagentId() {
118
151
  export async function resolveSubagentLaunch(options) {
119
152
  const task = options.input.task.trim();
120
153
  if (task === "")
121
- throw new Error("spawn_subagent requires a non-empty task");
154
+ throw new Error("spawn_agent requires a non-empty task");
122
155
  const name = options.input.agent?.trim();
123
156
  if (name === undefined || name === "") {
124
- throw new Error("spawn_subagent requires an explicit agent name; V1 has no built-in default agent");
157
+ throw new Error("spawn_agent requires an explicit agent name; V1 has no built-in default agent");
125
158
  }
126
159
  const cwd = resolve(options.input.cwd?.trim() || options.cwd);
127
- const subagentId = options.subagentId ?? createSubagentId();
160
+ const requestedTitle = options.input.title?.trim();
161
+ // A blank title is the same as no title: the child derives one from its own identity.
162
+ const title = requestedTitle === "" ? undefined : requestedTitle;
163
+ const subagentId = options.subagentId ?? createSubagentId(options.existingSubagentIds);
128
164
  const sessionId = options.sessionId ?? randomUUID();
129
165
  const bridgeExtensionPath = options.bridgeExtensionPath ?? resolveBridgeExtensionPath();
130
166
  const policy = await resolveAgent({
@@ -134,25 +170,53 @@ export async function resolveSubagentLaunch(options) {
134
170
  parent: options.parent,
135
171
  bridgeExtensionPath,
136
172
  ...(options.homeDirectory === undefined ? {} : { homeDirectory: options.homeDirectory }),
173
+ ...(options.skillCatalog === undefined ? {} : { skillCatalog: options.skillCatalog }),
174
+ ...(options.onWarning === undefined ? {} : { onWarning: options.onWarning }),
137
175
  });
176
+ if (options.enforceEnabled === true && policy.enabled === false) {
177
+ throw new Error(`Agent "${policy.agent.name}" is disabled: no model is specified in user configuration (~/.pi/agent/agents/ or .pi/agents/)`);
178
+ }
179
+ let instructions = policy.agent.instructions;
180
+ if (options.referencedSessionPath !== undefined && options.referencedSessionPath.trim() !== "") {
181
+ instructions = `${instructions.trim()}\n\n${formatSessionReferencePrompt(options.referencedSessionPath)}`;
182
+ }
138
183
  return Object.freeze({
139
184
  subagentId,
140
185
  invocation: options.invocation ?? resolvePiInvocation(),
141
186
  cwd,
142
187
  sessionId,
143
- sessionDir: resolveDefaultSessionDir(cwd),
188
+ sessionDir: resolveSubagentSessionDir(cwd),
189
+ ...(options.sessionPath === undefined ? {} : { sessionPath: options.sessionPath }),
190
+ ...(options.referencedSessionPath === undefined
191
+ ? {}
192
+ : { referencedSessionPath: options.referencedSessionPath }),
144
193
  agent: policy.agent,
145
194
  model: policy.model,
146
195
  thinking: policy.thinking,
147
- tools: policy.tools,
196
+ tools: childTools(policy),
148
197
  excludeTools: policy.excludeTools,
149
198
  extensions: policy.extensions,
150
199
  skills: policy.skills,
151
- prompt: assembleChildPrompt(policy.agent.instructions),
200
+ prompt: assembleChildPrompt(instructions),
152
201
  bridgeExtensionPath,
153
202
  interactive: policy.interactive,
203
+ ...(policy.codemodeOnly ? { codemodeOnly: true } : {}),
204
+ ...(title === undefined ? {} : { title }),
154
205
  });
155
206
  }
207
+ /**
208
+ * The child's tool allowlist: a built-in agent must still resolve to exactly the tools its
209
+ * definition declares. Failing here happens before any process exists, rather than launching
210
+ * a child with different permissions than it advertises.
211
+ */
212
+ function childTools(policy) {
213
+ const tools = policy.tools;
214
+ const problem = builtinAgentToolProblem(policy.agent.sourcePath, tools);
215
+ if (problem !== undefined) {
216
+ throw new Error(`Built-in agent ${policy.agent.name} cannot run: ${problem}`);
217
+ }
218
+ return tools;
219
+ }
156
220
  /**
157
221
  * Writes the spawn intent before anything is launched. Registry failure propagates,
158
222
  * so a child is never started from intent that could not be recovered.
@@ -183,8 +247,8 @@ export async function persistSubagentIntent(options) {
183
247
  cwd: launchConfig.cwd,
184
248
  initialTask: task,
185
249
  intent: "active",
186
- state: "starting",
187
- mode: options.mode ?? "rpc",
250
+ state: "running",
251
+ presentation: options.presentation ?? "background",
188
252
  persistence: placement.persistence,
189
253
  launchConfig: Object.freeze({
190
254
  ...launchConfig,
@@ -0,0 +1,16 @@
1
+ import type { SubagentRegistry } from "./registry.js";
2
+ /** Reads all JSON entries from a session file on disk. */
3
+ export declare function readSessionJsonlEntries(sessionPath: string): Promise<unknown[]>;
4
+ export interface ResolveForkSessionReferenceOptions {
5
+ readonly forkFrom: string;
6
+ readonly parentSessionPath?: string | undefined;
7
+ readonly registry?: Pick<SubagentRegistry, "get"> | undefined;
8
+ readonly onWarning?: ((message: string) => void) | undefined;
9
+ }
10
+ /**
11
+ * Resolves the source session JSONL file path for on-demand context inspection.
12
+ * Rejects 'current' to prevent ambiguous self-references.
13
+ */
14
+ export declare function resolveForkSessionReference(options: ResolveForkSessionReferenceOptions): Promise<string>;
15
+ /** Formats instructions explaining where the referenced session file is and how to inspect it on demand. */
16
+ export declare function formatSessionReferencePrompt(sessionPath: string): string;
@@ -0,0 +1,67 @@
1
+ import { readFile, stat } from "node:fs/promises";
2
+ import { findSessionFile } from "./session-bootstrap.js";
3
+ /** Reads all JSON entries from a session file on disk. */
4
+ export async function readSessionJsonlEntries(sessionPath) {
5
+ const raw = await readFile(sessionPath, "utf8");
6
+ const lines = raw.split("\n").filter((line) => line.trim() !== "");
7
+ const entries = [];
8
+ for (const line of lines) {
9
+ try {
10
+ entries.push(JSON.parse(line));
11
+ }
12
+ catch {
13
+ // ignore trailing malformed line
14
+ }
15
+ }
16
+ return entries;
17
+ }
18
+ /**
19
+ * Resolves the source session JSONL file path for on-demand context inspection.
20
+ * Rejects 'current' to prevent ambiguous self-references.
21
+ */
22
+ export async function resolveForkSessionReference(options) {
23
+ const { forkFrom, parentSessionPath, registry } = options;
24
+ const target = forkFrom.trim();
25
+ if (target === "current") {
26
+ throw new Error("forkFrom cannot be 'current'. Use 'parent' to reference the parent session, or specify a subagent id like 'agent-1'.");
27
+ }
28
+ if (target === "parent" || target === "self") {
29
+ if (parentSessionPath !== undefined && parentSessionPath.trim() !== "") {
30
+ return parentSessionPath;
31
+ }
32
+ throw new Error("Parent session has no associated session file on disk to reference");
33
+ }
34
+ if (registry !== undefined) {
35
+ const record = await registry.get(target).catch(() => undefined);
36
+ if (record !== undefined) {
37
+ const subagentSessionPath = record.sessionPath ??
38
+ (await findSessionFile(record.launchConfig.sessionDir, record.sessionId));
39
+ if (subagentSessionPath !== undefined) {
40
+ return subagentSessionPath;
41
+ }
42
+ throw new Error(`Subagent ${target} has no session file on disk to reference`);
43
+ }
44
+ if (/^agent-\d+$/u.test(target)) {
45
+ throw new Error(`Subagent ${target} was not found in registry`);
46
+ }
47
+ }
48
+ // Treat as explicit session file path
49
+ try {
50
+ const st = await stat(target);
51
+ if (st.isFile())
52
+ return target;
53
+ }
54
+ catch {
55
+ // ignore
56
+ }
57
+ throw new Error(`Referenced session file not found: ${target}`);
58
+ }
59
+ /** Formats instructions explaining where the referenced session file is and how to inspect it on demand. */
60
+ export function formatSessionReferencePrompt(sessionPath) {
61
+ return [
62
+ "## Referenced Session Context",
63
+ "Prior session transcript is available for reference at:",
64
+ sessionPath,
65
+ "Use tools (such as read or grep) to inspect earlier messages, tool outputs, or context from this JSONL file on demand as needed instead of loading the entire history.",
66
+ ].join("\n");
67
+ }
package/dist/state.d.ts CHANGED
@@ -3,11 +3,18 @@ export interface StateSnapshot {
3
3
  readonly state: SubagentState;
4
4
  readonly summary?: string;
5
5
  readonly interrupted?: string;
6
+ readonly activeTool?: string;
6
7
  readonly usage: UsageSummary;
7
8
  }
8
9
  export interface StateProjector {
9
10
  applyEvent(event: unknown): StateSnapshot;
10
11
  rebuild(entries: readonly unknown[]): StateSnapshot;
12
+ /**
13
+ * Puts the projected state back in step with a state the child itself reported. Without it a
14
+ * transcript that still ends in a tool result keeps projecting "running" over a child that has
15
+ * answered that it is idle, and the next state-less event writes that back onto the record.
16
+ */
17
+ syncState(state: SubagentState): StateSnapshot;
11
18
  snapshot(): StateSnapshot;
12
19
  }
13
20
  export declare function summarizeCurrentBranch(entries: readonly unknown[]): string | undefined;
package/dist/state.js CHANGED
@@ -1,3 +1,4 @@
1
+ import { isRecord } from "@hheei/pi-ext-core";
1
2
  const EMPTY_USAGE = {
2
3
  inputTokens: 0,
3
4
  outputTokens: 0,
@@ -7,9 +8,7 @@ const EMPTY_USAGE = {
7
8
  turns: 0,
8
9
  };
9
10
  function record(value) {
10
- return typeof value === "object" && value !== null && !Array.isArray(value)
11
- ? Object.fromEntries(Object.entries(value))
12
- : undefined;
11
+ return isRecord(value) ? Object.fromEntries(Object.entries(value)) : undefined;
13
12
  }
14
13
  function finite(value) {
15
14
  return typeof value === "number" && Number.isFinite(value) && value >= 0 ? value : undefined;
@@ -102,15 +101,17 @@ export function aggregateUsage(entries) {
102
101
  }
103
102
  return total;
104
103
  }
105
- export function createStateProjector(initialState = "starting") {
104
+ export function createStateProjector(initialState = "running") {
106
105
  let state = initialState;
107
106
  let summary;
108
107
  let interrupted;
108
+ let activeTool;
109
109
  let usage = EMPTY_USAGE;
110
110
  const snapshot = () => ({
111
111
  state,
112
112
  ...(summary === undefined ? {} : { summary }),
113
113
  ...(interrupted === undefined ? {} : { interrupted }),
114
+ ...(activeTool === undefined ? {} : { activeTool }),
114
115
  usage,
115
116
  });
116
117
  return {
@@ -118,49 +119,83 @@ export function createStateProjector(initialState = "starting") {
118
119
  const value = record(event);
119
120
  if (value === undefined)
120
121
  return snapshot();
121
- if (value.type === "agent_start")
122
+ if (value.type === "agent_start" ||
123
+ value.type === "auto_retry_start" ||
124
+ value.type === "auto_retry") {
122
125
  state = "running";
126
+ interrupted = undefined;
127
+ activeTool = undefined;
128
+ }
129
+ else if (value.type === "tool_execution_start") {
130
+ activeTool = typeof value.toolName === "string" ? value.toolName : undefined;
131
+ }
132
+ else if (value.type === "tool_execution_end") {
133
+ activeTool = undefined;
134
+ }
135
+ else if (value.type === "message_update") {
136
+ const message = record(value.message);
137
+ if (message?.role === "assistant") {
138
+ const currentText = text(message);
139
+ if (currentText !== undefined) {
140
+ summary = currentText;
141
+ activeTool = undefined;
142
+ }
143
+ }
144
+ }
123
145
  else if (value.type === "agent_end" || value.type === "agent_settled") {
124
- state = "idle";
146
+ activeTool = undefined;
147
+ // agent_end precedes retries, compaction and queued continuation work.
148
+ if (value.type === "agent_settled") {
149
+ if (state !== "error" && state !== "blocked") {
150
+ state = "done";
151
+ interrupted = undefined;
152
+ }
153
+ }
125
154
  const messages = Array.isArray(value.messages) ? value.messages : [];
126
155
  const message = record(messages.length > 0 ? messages[messages.length - 1] : value.message);
127
156
  if (message?.role === "assistant") {
128
157
  summary = text(message) ?? summary;
129
- interrupted = diagnostic(message) ?? interrupted;
130
- if (message.stopReason === "error")
131
- state = "failed";
158
+ if (value.type === "agent_settled") {
159
+ interrupted = diagnostic(message);
160
+ if (message.stopReason === "error")
161
+ state = "error";
162
+ else
163
+ interrupted = undefined;
164
+ }
132
165
  }
133
166
  }
134
167
  else if (value.type === "error") {
135
- state = "failed";
168
+ state = "error";
136
169
  interrupted =
137
170
  typeof value.message === "string" ? value.message : "Runner reported an error";
138
171
  }
139
- else if (value.type === "runner_events_dropped") {
140
- const count = typeof value.count === "number" ? value.count : undefined;
141
- interrupted =
142
- count === undefined
143
- ? "Runner dropped events; live state may be stale"
144
- : `Runner dropped ${count} events; live state may be stale`;
145
- }
146
- else if (value.type === "runner_exit" || value.type === "exit") {
147
- state = value.code === 0 ? "done" : "failed";
148
- if (value.code !== 0)
149
- interrupted = `Runner exited${typeof value.code === "number" ? ` with code ${value.code}` : " unexpectedly"}`;
150
- }
151
172
  return snapshot();
152
173
  },
153
174
  rebuild(entries) {
175
+ activeTool = undefined;
154
176
  summary = summarizeCurrentBranch(entries);
155
177
  usage = aggregateUsage(entries);
178
+ let lastAssistant;
156
179
  for (const entry of entries) {
157
180
  const item = assistant(entry);
158
181
  if (item === undefined || !completed(item.message))
159
182
  continue;
160
- interrupted = diagnostic(item.message) ?? interrupted;
161
- if (item.message.stopReason === "error")
162
- state = "failed";
183
+ lastAssistant = item.message;
184
+ }
185
+ if (lastAssistant !== undefined) {
186
+ interrupted = diagnostic(lastAssistant);
187
+ if (lastAssistant.stopReason === "error")
188
+ state = "error";
189
+ else if (state === "error")
190
+ state = "done";
163
191
  }
192
+ else {
193
+ interrupted = undefined;
194
+ }
195
+ return snapshot();
196
+ },
197
+ syncState(next) {
198
+ state = next;
164
199
  return snapshot();
165
200
  },
166
201
  snapshot,
@@ -0,0 +1,18 @@
1
+ import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
2
+ import type { SubagentManager } from "./manager.js";
3
+ /** Interactive agent tools managed by dynamic activation. */
4
+ export declare const INTERACTIVE_TOOL_NAMES: readonly ["spawn_agent", "send_agent", "get_agent", "stop_agent"];
5
+ export type InteractiveToolName = (typeof INTERACTIVE_TOOL_NAMES)[number];
6
+ export declare const SUBAGENT_LOADER_NAME = "subagent_enable";
7
+ export declare const SUBAGENTS_LOADER_NAME = "subagent_enable";
8
+ export declare function supportsDynamicTools(pi: ExtensionAPI): boolean;
9
+ export interface RegisterInteractiveToolActivationOptions {
10
+ readonly pi: ExtensionAPI;
11
+ readonly manager: SubagentManager;
12
+ readonly context: ExtensionContext;
13
+ }
14
+ export interface InteractiveToolActivationHandle {
15
+ dispose(): void;
16
+ selectFromSession(ctx: ExtensionContext): Promise<void>;
17
+ }
18
+ export declare function registerInteractiveToolActivation(options: RegisterInteractiveToolActivationOptions): InteractiveToolActivationHandle;
@@ -0,0 +1,227 @@
1
+ import { buildSessionContext } from "@earendil-works/pi-coding-agent";
2
+ import { getToolTui, isRecord, textToolResult } from "@hheei/pi-ext-core";
3
+ import { Type } from "typebox";
4
+ import { discoverAgents } from "./agent-resolver.js";
5
+ import { builtinAgents } from "./builtin-agents.js";
6
+ /** Interactive agent tools managed by dynamic activation. */
7
+ export const INTERACTIVE_TOOL_NAMES = [
8
+ "spawn_agent",
9
+ "send_agent",
10
+ "get_agent",
11
+ "stop_agent",
12
+ ];
13
+ export const SUBAGENT_LOADER_NAME = "subagent_enable";
14
+ export const SUBAGENTS_LOADER_NAME = SUBAGENT_LOADER_NAME;
15
+ const DESCRIPTION = "Enable interactive agent tools (spawn_agent, send_agent, get_agent, stop_agent) for running interactive subagents. Enabled tools are available on the next model request.";
16
+ const SNIPPET = "Interactive subagents (spawn_agent, send_agent, get_agent, stop_agent) require activation. Call subagent_enable to activate them.";
17
+ const emptySchema = Type.Object({}, { additionalProperties: false });
18
+ export function supportsDynamicTools(pi) {
19
+ return (typeof pi.getAllTools === "function" &&
20
+ typeof pi.getActiveTools === "function" &&
21
+ typeof pi.setActiveTools === "function");
22
+ }
23
+ function hasToolDeclarations(messages) {
24
+ return messages.some((message) => isRecord(message) && ("toolsAdded" in message || "toolsRemoved" in message));
25
+ }
26
+ function currentTranscriptToolNames(messages) {
27
+ const tools = new Set();
28
+ for (const message of messages) {
29
+ if (!isRecord(message))
30
+ continue;
31
+ const declaration = message;
32
+ for (const tool of declaration.toolsRemoved ?? [])
33
+ tools.delete(tool.name);
34
+ for (const tool of declaration.toolsAdded ?? [])
35
+ tools.add(tool.name);
36
+ }
37
+ return [...tools];
38
+ }
39
+ const LEGACY_TOOL_NAMES = new Set([
40
+ "spawn_subagent",
41
+ "send_subagent",
42
+ "get_subagent",
43
+ "stop_subagent",
44
+ "list_subagents",
45
+ "subagents_enable",
46
+ ]);
47
+ function branchHasSubagentCalls(messages, toolNames) {
48
+ const names = new Set([...toolNames, ...LEGACY_TOOL_NAMES]);
49
+ for (const msg of messages) {
50
+ if (!isRecord(msg))
51
+ continue;
52
+ if (msg.role === "assistant" && Array.isArray(msg.content)) {
53
+ for (const part of msg.content) {
54
+ if (isRecord(part) &&
55
+ (part.type === "tool_use" || part.type === "toolCall") &&
56
+ typeof part.name === "string") {
57
+ if (part.name === SUBAGENT_LOADER_NAME || names.has(part.name))
58
+ return true;
59
+ }
60
+ }
61
+ }
62
+ if (msg.role === "tool" &&
63
+ typeof msg.toolName === "string" &&
64
+ (msg.toolName === SUBAGENT_LOADER_NAME || names.has(msg.toolName))) {
65
+ return true;
66
+ }
67
+ }
68
+ return false;
69
+ }
70
+ export function registerInteractiveToolActivation(options) {
71
+ const { pi, manager, context } = options;
72
+ if (!supportsDynamicTools(pi)) {
73
+ return {
74
+ dispose() { },
75
+ selectFromSession: async () => { },
76
+ };
77
+ }
78
+ const names = INTERACTIVE_TOOL_NAMES;
79
+ const tui = getToolTui(pi);
80
+ const loaderTool = {
81
+ name: SUBAGENT_LOADER_NAME,
82
+ label: "Enable Interactive Subagents",
83
+ description: DESCRIPTION,
84
+ promptSnippet: SNIPPET,
85
+ parameters: emptySchema,
86
+ async execute(_id, _params, _signal, _onUpdate, execCtx) {
87
+ const registered = new Set(pi.getAllTools().map((tool) => tool.name));
88
+ const unavailable = names.filter((name) => !registered.has(name));
89
+ if (unavailable.length > 0) {
90
+ throw new Error(`Cannot enable unavailable tools: ${unavailable.join(", ")}.`);
91
+ }
92
+ const cwd = execCtx !== undefined &&
93
+ typeof execCtx === "object" &&
94
+ "cwd" in execCtx &&
95
+ typeof execCtx.cwd === "string"
96
+ ? execCtx.cwd
97
+ : (context.cwd ?? process.cwd());
98
+ let allAgents;
99
+ try {
100
+ allAgents = await discoverAgents(cwd);
101
+ }
102
+ catch {
103
+ allAgents = builtinAgents();
104
+ }
105
+ const interactiveAgents = allAgents
106
+ .filter((agent) => agent.frontmatter.interactive === true && agent.frontmatter.hidden !== true)
107
+ .map((agent) => ({
108
+ name: agent.name,
109
+ description: typeof agent.frontmatter.description === "string" &&
110
+ agent.frontmatter.description.trim() !== ""
111
+ ? agent.frontmatter.description.trim()
112
+ : "No description provided.",
113
+ }))
114
+ .sort((a, b) => a.name.localeCompare(b.name));
115
+ const currentlyActive = new Set(pi.getActiveTools());
116
+ const alreadyActive = names.every((name) => currentlyActive.has(name));
117
+ if (!alreadyActive) {
118
+ try {
119
+ pi.setActiveTools([...new Set([...pi.getActiveTools(), ...names])]);
120
+ }
121
+ catch (error) {
122
+ const message = error instanceof Error ? error.message : String(error);
123
+ throw new Error(`Activation failed: ${message}.`);
124
+ }
125
+ }
126
+ const lines = [
127
+ alreadyActive
128
+ ? `Interactive agent tools are already enabled: ${names.join(", ")}.`
129
+ : `Enabled interactive agent tools: ${names.join(", ")}. They will be available on the next model request.`,
130
+ ];
131
+ if (interactiveAgents.length > 0) {
132
+ lines.push("");
133
+ lines.push("<interactive_agents>");
134
+ for (const agent of interactiveAgents) {
135
+ lines.push(`- ${agent.name}: ${agent.description}`);
136
+ }
137
+ lines.push("</interactive_agents>");
138
+ }
139
+ return textToolResult(lines.join("\n"), {
140
+ enabled: [...names],
141
+ ...(interactiveAgents.length > 0 ? { interactiveAgents } : {}),
142
+ });
143
+ },
144
+ };
145
+ pi.registerTool(tui.frame(loaderTool, {
146
+ summary: () => "enable interactive subagents",
147
+ headerLine: "truncate",
148
+ footer: (result) => {
149
+ const details = result.details;
150
+ const count = details?.enabled?.length;
151
+ return count !== undefined
152
+ ? count === 1
153
+ ? "1 tool enabled"
154
+ : `${count} tools enabled`
155
+ : undefined;
156
+ },
157
+ }));
158
+ function loaderAvailable() {
159
+ return pi.getAllTools().some((tool) => tool.name === SUBAGENT_LOADER_NAME);
160
+ }
161
+ async function selectFromSession(ctx) {
162
+ if (!loaderAvailable())
163
+ return;
164
+ try {
165
+ let messages = [];
166
+ try {
167
+ const branch = ctx.sessionManager?.getBranch?.();
168
+ if (Array.isArray(branch) && branch.length > 0) {
169
+ messages = buildSessionContext(branch).messages;
170
+ }
171
+ }
172
+ catch {
173
+ // branch or sessionManager may not be available in mock contexts
174
+ }
175
+ let hasLiveChildren = false;
176
+ try {
177
+ const children = await manager.list();
178
+ hasLiveChildren = children.some((child) => child.freshness === "live" || child.state === "running");
179
+ }
180
+ catch {
181
+ // manager may be recovering or unavailable
182
+ }
183
+ const recorded = hasToolDeclarations(messages)
184
+ ? new Set(currentTranscriptToolNames(messages))
185
+ : messages.length > 0 && branchHasSubagentCalls(messages, names)
186
+ ? new Set(names)
187
+ : new Set();
188
+ const shouldBeActive = hasLiveChildren || names.some((name) => recorded.has(name));
189
+ const heavy = new Set(names);
190
+ const active = pi.getActiveTools().filter((name) => !heavy.has(name));
191
+ if (shouldBeActive) {
192
+ for (const name of names)
193
+ active.push(name);
194
+ }
195
+ pi.setActiveTools([...new Set([...active, SUBAGENT_LOADER_NAME])]);
196
+ }
197
+ catch (error) {
198
+ console.warn(`[pi-subagents] Subagent tool activation setup fallback: ${error instanceof Error ? error.message : String(error)}`);
199
+ }
200
+ }
201
+ const unbindSessionStart = pi.on("session_start", (_event, ctx) => {
202
+ void selectFromSession(ctx);
203
+ });
204
+ const unbindSessionTree = pi.on("session_tree", (_event, ctx) => {
205
+ void selectFromSession(ctx);
206
+ });
207
+ const unbindBeforeAgentStart = () => {
208
+ if (!loaderAvailable() || pi.getActiveTools().includes(SUBAGENT_LOADER_NAME))
209
+ return;
210
+ try {
211
+ pi.setActiveTools([...pi.getActiveTools(), SUBAGENT_LOADER_NAME]);
212
+ }
213
+ catch {
214
+ // Best effort
215
+ }
216
+ };
217
+ const unbindBeforeAgent = pi.on("before_agent_start", unbindBeforeAgentStart);
218
+ void selectFromSession(context);
219
+ return {
220
+ dispose() {
221
+ unbindSessionStart();
222
+ unbindSessionTree();
223
+ unbindBeforeAgent();
224
+ },
225
+ selectFromSession,
226
+ };
227
+ }
package/dist/tools.d.ts CHANGED
@@ -1,9 +1,12 @@
1
1
  import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
2
- import type { ChildIdentity } from "./domain.js";
2
+ import { type ChildIdentity } from "./domain.js";
3
3
  import type { SubagentManager } from "./manager.js";
4
4
  export declare function registerParentTools(pi: ExtensionAPI, manager: SubagentManager): void;
5
+ /** Sends a child-initiated request over the bridge and resolves with the parent's answer. */
6
+ export type ChildReport = (operation: string, payload: unknown, signal?: AbortSignal) => Promise<unknown>;
5
7
  export interface RegisterChildToolsOptions {
6
- readonly onReport?: () => void;
8
+ /** The bridge connection to the parent; a child without one cannot report anything. */
9
+ readonly report: ChildReport;
7
10
  readonly isBound?: (sessionId: string) => boolean;
8
11
  }
9
- export declare function registerChildTools(pi: ExtensionAPI, identity: ChildIdentity, onReportOrOptions?: (() => void) | RegisterChildToolsOptions): void;
12
+ export declare function registerChildTools(pi: ExtensionAPI, identity: ChildIdentity, options: RegisterChildToolsOptions): void;