@narumitw/pi-subagents 0.26.0 → 0.27.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.
package/README.md CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  [![npm](https://img.shields.io/npm/v/@narumitw/pi-subagents)](https://www.npmjs.com/package/@narumitw/pi-subagents) [![Pi extension](https://img.shields.io/badge/Pi-extension-blue)](https://pi.dev) [![License: MIT](https://img.shields.io/badge/license-MIT-green.svg)](./LICENSE)
4
4
 
5
- `@narumitw/pi-subagents` is a native [Pi coding agent](https://pi.dev) extension for delegating work to specialized agents. The batch `subagent` tool keeps isolated Pi subprocesses, while addressable lifecycle tools can run reusable agents either as subprocess-backed logical sessions or as public-SDK in-process child sessions.
5
+ `@narumitw/pi-subagents` is a native [Pi coding agent](https://pi.dev) extension for delegating work to specialized agents. The blocking batch `subagent` tool keeps isolated Pi subprocesses, while four detached tools run reusable agents either as subprocess-backed logical sessions or as public-SDK in-process child sessions. Together they form a fixed five-tool surface.
6
6
 
7
7
  Use it to split independent research, planning, implementation, and review work across focused workers. Under the default next-turn delivery policy, background delegation is for work the current response does not depend on. Opt-in auto-resume also supports final-answer-dependent background work by requesting a synthesis turn after completion.
8
8
 
@@ -19,7 +19,7 @@ Use it to split independent research, planning, implementation, and review work
19
19
  - Supports per-task `cwd`, hard subprocess `timeoutMs`, task-selected `thinkingLevel`, abort propagation, and streaming progress.
20
20
  - Bounds JSON lines, captured messages, stderr, final output, chain substitution, and fan-in context.
21
21
  - Enforces a recursion-depth guard and deterministic process-group termination.
22
- - Provides addressable stateful agents with follow-up, mailbox, list, interrupt, close, context selection, and persistence.
22
+ - Provides addressable stateful agents with follow-up, consolidated mailbox/management actions, context selection, and persistence.
23
23
  - Publishes transient runtime status through Pi's generic extension status API while subagents are running.
24
24
  - Returns complete bounded worker output in tool details and a concise result for the main agent.
25
25
 
@@ -211,7 +211,7 @@ Run a chain where each step receives the previous output:
211
211
 
212
212
  Stateful lifecycle tools are available by default. `subagent_spawn` is detached: it schedules work, returns immediately with an opaque `agentId`, and later injects a bounded `pi-subagent-completion` custom message. Completions that settle in the same dispatch window are batched, and the broker allows at most one in-flight root wake until that parent turn starts.
213
213
 
214
- Detached work follows a non-polling policy. With default `next-turn` delivery, prefer one bounded `subagent_spawn` for related asynchronous research or review only when the current response does not depend on its result; if it does, use blocking `subagent`. With opt-in `auto-resume`, detached broad work may be final-answer-dependent because completion requests a synthesis turn after the root settles. In either mode, do useful non-overlapping main-agent work immediately, do not poll `subagent_list` or `subagent_messages`, and do not duplicate delegated work. Add another detached agent only for truly independent work with safe workspace concurrency. Detached lifecycle work intentionally has no `subagent_wait` tool.
214
+ Detached work follows a non-polling policy. With default `next-turn` delivery, prefer one bounded `subagent_spawn` for related asynchronous research or review only when the current response does not depend on its result; if it does, use blocking `subagent`. With opt-in `auto-resume`, detached broad work may be final-answer-dependent because completion requests a synthesis turn after the root settles. In either mode, do useful non-overlapping main-agent work immediately, do not poll `subagent_manage` with `action: "list"` or `subagent_mailbox` with `action: "read"`, and do not duplicate delegated work. Add another detached agent only for truly independent work with safe workspace concurrency. Detached lifecycle work intentionally has no `subagent_wait` tool.
215
215
 
216
216
  A detached agent additionally needs a concrete isolation or specialization benefit such as independent review, bounded context/output, a distinct model/tool profile, or workspace isolation. Simple work that the main agent can perform directly should not be delegated.
217
217
 
@@ -246,20 +246,49 @@ The direct routes remain predictable: `/subagents settings` changes user complet
246
246
  }
247
247
  ```
248
248
 
249
- The settings UI patches the raw JSON atomically and preserves unknown fields; it refuses to overwrite malformed or invalid settings. Set `"enabled": false` to remove all stateful lifecycle tools. Otherwise, the extension registers:
249
+ The settings UI patches the raw JSON atomically and preserves unknown fields; it refuses to overwrite malformed or invalid settings. Set `"enabled": false` to remove all four stateful tools. Otherwise, the extension keeps the following tool membership fixed across spawn, completion, interrupt, close, and mailbox transitions. This avoids lifecycle-driven tool-schema churn and preserves a stable provider prompt prefix for KV caching.
250
250
 
251
251
  | Tool | Purpose |
252
252
  | --- | --- |
253
253
  | `subagent_spawn` | Start detached work with an optional task-selected thinking level, return an opaque `agentId` immediately, and deliver completion asynchronously. |
254
- | `subagent_send` | Send follow-up work to a reusable agent; shared-workspace write conflicts are guarded unless explicitly overridden. |
255
- | `subagent_message` | Queue a bounded mailbox message without starting a turn; sender IDs must be `root` or an agent in the same tree. |
256
- | `subagent_messages` | Read and optionally acknowledge unread mailbox messages. |
257
- | `subagent_list` | List retained agents and lifecycle states. |
258
- | `subagent_interrupt` | Abort the current turn while retaining its identity and history. |
259
- | `subagent_close` | Abort if necessary, close the agent, and remove it from retained persistence. |
254
+ | `subagent_send` | Send follow-up work and trigger a new turn on a reusable agent; shared-workspace write conflicts are guarded unless explicitly overridden. |
255
+ | `subagent_manage` | Use `action: "list"` to inspect agents, `"interrupt"` to retain an agent after aborting active work, or `"close"` to release it; interrupt/close accept optional `subtree`. |
256
+ | `subagent_mailbox` | Use `action: "send"` for queue-only messages that do not start a turn, or `"read"` to read and optionally acknowledge unread messages. |
257
+
258
+ The action schemas are flat for provider compatibility and reject parameters that belong to another action. For example:
259
+
260
+ ```json
261
+ {
262
+ "action": "interrupt",
263
+ "agentId": "sa_example",
264
+ "subtree": true
265
+ }
266
+ ```
267
+
268
+ ```json
269
+ {
270
+ "action": "send",
271
+ "agentId": "sa_example",
272
+ "message": "Check the API compatibility note before finishing."
273
+ }
274
+ ```
260
275
 
261
276
  Use the **Current-session agents** action in `/subagents` to inspect the indented agent tree, lifecycle state, unread count, and available actions, or to confirm clearing retained agents. `/subagents:agents list|clear` remains the compatibility command for the same current-session operations. Active turns are FIFO-limited by `maxActiveTurns`; excess retained work remains in `starting` state until a slot is available. `maxAgents` separately bounds running, queued, and idle records. `parentId` creates a bounded child relationship; subtree interrupt and close operate child-first.
262
277
 
278
+ ### Migrating from the seven-tool lifecycle surface
279
+
280
+ The five replaced names are intentionally not registered as aliases. Update explicit prompts and integrations as follows:
281
+
282
+ | Previous call | Fixed-surface call |
283
+ | --- | --- |
284
+ | `subagent_list({ includeClosed })` | `subagent_manage({ action: "list", includeClosed })` |
285
+ | `subagent_interrupt({ agentId, subtree })` | `subagent_manage({ action: "interrupt", agentId, subtree })` |
286
+ | `subagent_close({ agentId, subtree })` | `subagent_manage({ action: "close", agentId, subtree })` |
287
+ | `subagent_message({ agentId, message, ... })` | `subagent_mailbox({ action: "send", agentId, message, ... })` |
288
+ | `subagent_messages({ agentId, acknowledge, limit })` | `subagent_mailbox({ action: "read", agentId, acknowledge, limit })` |
289
+
290
+ Persisted agent and mailbox records require no migration. If an explicit prompt in a resumed conversation keeps requesting an old name, update it with the mapping above or start a fresh conversation. To roll back after an upgrade, pin the package version used before the upgrade; for this migration, use `pi install npm:@narumitw/pi-subagents@0.26.0`. The previous release can read the same state directory.
291
+
263
292
  A spawn can request a thinking level explicitly:
264
293
 
265
294
  ```json
@@ -462,8 +491,10 @@ Stateful records are stored as versioned mode-0600 JSON under `~/.pi/agent/pi-su
462
491
  ```txt
463
492
  extensions/pi-subagents/
464
493
  ├── src/
465
- │ ├── subagents.ts # Pi entrypoint and tool schema
466
- └── *.ts # Package-local discovery, execution, rendering, and config modules
494
+ │ ├── subagents.ts # Pi entrypoint and blocking tool schema
495
+ ├── stateful.ts # Detached lifecycle registration and dispatch
496
+ │ ├── stateful-tool-params.ts # Consolidated action schemas and validation
497
+ │ └── *.ts # Package-local discovery, execution, rendering, and config modules
467
498
  ├── README.md
468
499
  ├── LICENSE
469
500
  ├── tsconfig.json
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@narumitw/pi-subagents",
3
- "version": "0.26.0",
3
+ "version": "0.27.0",
4
4
  "description": "Pi extension for delegating work to specialized isolated subagents.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -0,0 +1,202 @@
1
+ import { StringEnum } from "@earendil-works/pi-ai";
2
+ import { Type } from "typebox";
3
+
4
+ const MANAGE_ACTIONS = ["list", "interrupt", "close"] as const;
5
+ const MAILBOX_ACTIONS = ["send", "read"] as const;
6
+ const MAX_MAILBOX_MESSAGE_LENGTH = 16 * 1024;
7
+
8
+ export const ManageParamsSchema = Type.Object(
9
+ {
10
+ action: StringEnum(MANAGE_ACTIONS, {
11
+ description:
12
+ "Use list to inspect agents, interrupt to stop active work, or close to release agents.",
13
+ }),
14
+ agentId: Type.Optional(
15
+ Type.String({ minLength: 1, description: "Required for interrupt and close." }),
16
+ ),
17
+ includeClosed: Type.Optional(
18
+ Type.Boolean({
19
+ default: false,
20
+ description: "List closed records as well as retained agents.",
21
+ }),
22
+ ),
23
+ subtree: Type.Optional(
24
+ Type.Boolean({
25
+ default: false,
26
+ description: "Interrupt or close the target and all descendants child-first.",
27
+ }),
28
+ ),
29
+ },
30
+ { additionalProperties: false },
31
+ );
32
+
33
+ export const MailboxParamsSchema = Type.Object(
34
+ {
35
+ action: StringEnum(MAILBOX_ACTIONS, {
36
+ description: "Use send for queue-only delivery or read to inspect unread mailbox messages.",
37
+ }),
38
+ agentId: Type.String({ minLength: 1, description: "Mailbox owner or message recipient." }),
39
+ message: Type.Optional(
40
+ Type.String({
41
+ minLength: 1,
42
+ maxLength: MAX_MAILBOX_MESSAGE_LENGTH,
43
+ description: "Message content required by send; sending does not start a turn.",
44
+ }),
45
+ ),
46
+ senderId: Type.Optional(Type.String({ description: "Optional same-tree sender identity." })),
47
+ deduplicationKey: Type.Optional(
48
+ Type.String({ maxLength: 256, description: "Optional idempotency key for send." }),
49
+ ),
50
+ acknowledge: Type.Optional(
51
+ Type.Boolean({ default: true, description: "Mark returned read messages as acknowledged." }),
52
+ ),
53
+ limit: Type.Optional(
54
+ Type.Number({ minimum: 1, maximum: 20, default: 20, description: "Maximum messages." }),
55
+ ),
56
+ },
57
+ { additionalProperties: false },
58
+ );
59
+
60
+ export type ValidatedManageParams =
61
+ | { action: "list"; includeClosed?: boolean }
62
+ | { action: "interrupt" | "close"; agentId: string; subtree?: boolean };
63
+
64
+ export type ValidatedMailboxParams =
65
+ | {
66
+ action: "send";
67
+ agentId: string;
68
+ message: string;
69
+ senderId?: string;
70
+ deduplicationKey?: string;
71
+ }
72
+ | { action: "read"; agentId: string; acknowledge?: boolean; limit?: number };
73
+
74
+ export function validateManageParams(params: unknown): ValidatedManageParams {
75
+ const values = parameterRecord(params, "subagent_manage");
76
+ const action = values.action;
77
+ if (
78
+ typeof action !== "string" ||
79
+ !MANAGE_ACTIONS.includes(action as (typeof MANAGE_ACTIONS)[number])
80
+ ) {
81
+ throw new Error(`subagent_manage action must be one of: ${MANAGE_ACTIONS.join(", ")}`);
82
+ }
83
+ if (action === "list") {
84
+ assertOnlyActionKeys("subagent_manage", action, values, ["action", "includeClosed"]);
85
+ assertOptionalBoolean("subagent_manage", action, values, "includeClosed");
86
+ return values as ValidatedManageParams;
87
+ }
88
+ assertOnlyActionKeys("subagent_manage", action, values, ["action", "agentId", "subtree"]);
89
+ assertRequiredString("subagent_manage", action, values, "agentId");
90
+ assertOptionalBoolean("subagent_manage", action, values, "subtree");
91
+ return values as ValidatedManageParams;
92
+ }
93
+
94
+ export function validateMailboxParams(params: unknown): ValidatedMailboxParams {
95
+ const values = parameterRecord(params, "subagent_mailbox");
96
+ const action = values.action;
97
+ if (
98
+ typeof action !== "string" ||
99
+ !MAILBOX_ACTIONS.includes(action as (typeof MAILBOX_ACTIONS)[number])
100
+ ) {
101
+ throw new Error(`subagent_mailbox action must be one of: ${MAILBOX_ACTIONS.join(", ")}`);
102
+ }
103
+ if (action === "send") {
104
+ assertOnlyActionKeys("subagent_mailbox", action, values, [
105
+ "action",
106
+ "agentId",
107
+ "message",
108
+ "senderId",
109
+ "deduplicationKey",
110
+ ]);
111
+ assertRequiredString("subagent_mailbox", action, values, "agentId");
112
+ assertRequiredString("subagent_mailbox", action, values, "message");
113
+ const message = values.message as string;
114
+ if (message.length > MAX_MAILBOX_MESSAGE_LENGTH) {
115
+ throw new Error(
116
+ `subagent_mailbox action "send" requires message at most ${MAX_MAILBOX_MESSAGE_LENGTH} characters`,
117
+ );
118
+ }
119
+ assertOptionalString("subagent_mailbox", action, values, "senderId");
120
+ assertOptionalString("subagent_mailbox", action, values, "deduplicationKey");
121
+ if (typeof values.deduplicationKey === "string" && values.deduplicationKey.length > 256) {
122
+ throw new Error(
123
+ 'subagent_mailbox action "send" requires deduplicationKey at most 256 characters',
124
+ );
125
+ }
126
+ return values as ValidatedMailboxParams;
127
+ }
128
+ assertOnlyActionKeys("subagent_mailbox", action, values, [
129
+ "action",
130
+ "agentId",
131
+ "acknowledge",
132
+ "limit",
133
+ ]);
134
+ assertRequiredString("subagent_mailbox", action, values, "agentId");
135
+ assertOptionalBoolean("subagent_mailbox", action, values, "acknowledge");
136
+ if (
137
+ values.limit !== undefined &&
138
+ (typeof values.limit !== "number" ||
139
+ !Number.isFinite(values.limit) ||
140
+ values.limit < 1 ||
141
+ values.limit > 20)
142
+ ) {
143
+ throw new Error('subagent_mailbox action "read" requires limit between 1 and 20');
144
+ }
145
+ return values as ValidatedMailboxParams;
146
+ }
147
+
148
+ function parameterRecord(params: unknown, toolName: string): Record<string, unknown> {
149
+ if (!params || typeof params !== "object" || Array.isArray(params)) {
150
+ throw new Error(`${toolName} parameters must be an object`);
151
+ }
152
+ return params as Record<string, unknown>;
153
+ }
154
+
155
+ function assertOnlyActionKeys(
156
+ toolName: string,
157
+ action: string,
158
+ values: Record<string, unknown>,
159
+ allowed: readonly string[],
160
+ ): void {
161
+ const unexpected = Object.keys(values).find(
162
+ (key) => values[key] !== undefined && !allowed.includes(key),
163
+ );
164
+ if (unexpected) {
165
+ throw new Error(`${toolName} action "${action}" does not accept ${unexpected}`);
166
+ }
167
+ }
168
+
169
+ function assertRequiredString(
170
+ toolName: string,
171
+ action: string,
172
+ values: Record<string, unknown>,
173
+ key: string,
174
+ ): void {
175
+ const value = values[key];
176
+ if (value === undefined) throw new Error(`${toolName} action "${action}" requires ${key}`);
177
+ if (typeof value !== "string" || value.length === 0) {
178
+ throw new Error(`${toolName} action "${action}" requires ${key} to be a non-empty string`);
179
+ }
180
+ }
181
+
182
+ function assertOptionalString(
183
+ toolName: string,
184
+ action: string,
185
+ values: Record<string, unknown>,
186
+ key: string,
187
+ ): void {
188
+ if (values[key] !== undefined && typeof values[key] !== "string") {
189
+ throw new Error(`${toolName} action "${action}" requires ${key} to be a string`);
190
+ }
191
+ }
192
+
193
+ function assertOptionalBoolean(
194
+ toolName: string,
195
+ action: string,
196
+ values: Record<string, unknown>,
197
+ key: string,
198
+ ): void {
199
+ if (values[key] !== undefined && typeof values[key] !== "boolean") {
200
+ throw new Error(`${toolName} action "${action}" requires ${key} to be a boolean`);
201
+ }
202
+ }
package/src/stateful.ts CHANGED
@@ -25,6 +25,12 @@ import { DEFAULT_MAX_CONTEXT_BYTES, truncateUtf8 } from "./limits.js";
25
25
  import { AgentPersistence } from "./persistence.js";
26
26
  import { AgentRegistry, type AgentTurnCompletion, type ManagedAgent } from "./registry.js";
27
27
  import { readSubagentSettings } from "./settings.js";
28
+ import {
29
+ MailboxParamsSchema,
30
+ ManageParamsSchema,
31
+ validateMailboxParams,
32
+ validateManageParams,
33
+ } from "./stateful-tool-params.js";
28
34
  import { SubprocessTransport } from "./subprocess-transport.js";
29
35
  import { WorkspaceManager } from "./workspace.js";
30
36
 
@@ -64,8 +70,8 @@ function createSpawnPromptGuidelines(completionDelivery: CompletionDelivery): st
64
70
  "When subagent_spawn fits the completion-delivery policy, do not choose a blocking parallel subagent merely to keep delegation in the same turn.",
65
71
  "Add another subagent_spawn only for truly independent work with safe workspace concurrency.",
66
72
  noLocalWorkGuidance,
67
- "Consume and synthesize available subagent_spawn completion messages; interrupt or close agents that are no longer needed.",
68
- "subagent_spawn completion is delivered automatically. Do not poll with subagent_list or subagent_messages, repeatedly check progress, or duplicate the delegated work.",
73
+ 'Consume and synthesize available subagent_spawn completion messages; use subagent_manage with action "interrupt" or "close" for agents that are no longer needed.',
74
+ 'Completion from subagent_spawn is delivered automatically. Do not poll with subagent_manage action "list" or subagent_mailbox action "read", repeatedly check progress, or duplicate the delegated work.',
69
75
  ];
70
76
  }
71
77
 
@@ -91,6 +97,11 @@ export interface StatefulSubagentController {
91
97
  clearAgents(): Promise<number>;
92
98
  }
93
99
 
100
+ interface StatefulActionToolResult {
101
+ content: Array<{ type: "text"; text: string }>;
102
+ details: Record<string, unknown>;
103
+ }
104
+
94
105
  export function registerStatefulSubagents(
95
106
  pi: ExtensionAPI,
96
107
  dependencies: StatefulSubagentDependencies = {},
@@ -157,6 +168,11 @@ export function registerStatefulSubagents(
157
168
  if (!registry) throw new Error("Stateful subagents are not initialized for this session");
158
169
  return registry;
159
170
  };
171
+ const requireAgent = (agentId: string) => {
172
+ const agent = requireRegistry().get(agentId);
173
+ if (!agent) throw new Error(`Unknown subagent: ${agentId}`);
174
+ return agent;
175
+ };
160
176
 
161
177
  pi.on("session_start", async (_event, ctx) => {
162
178
  const generation = ++runtimeGeneration;
@@ -374,7 +390,9 @@ export function registerStatefulSubagents(
374
390
  pi.registerTool({
375
391
  name: "subagent_send",
376
392
  label: "Send Subagent Follow-up",
377
- description: "Send a follow-up task to an idle, completed, interrupted, or failed subagent.",
393
+ description:
394
+ "Send follow-up work to an idle, completed, interrupted, or failed subagent and start a new turn. Use subagent_mailbox for queue-only messages.",
395
+ promptSnippet: "Start a new detached follow-up turn on a retained subagent",
378
396
  parameters: Type.Object({
379
397
  agentId: Type.String(),
380
398
  task: Type.String({ minLength: 1, maxLength: DEFAULT_MAX_CONTEXT_BYTES }),
@@ -404,141 +422,66 @@ export function registerStatefulSubagents(
404
422
  });
405
423
 
406
424
  pi.registerTool({
407
- name: "subagent_message",
408
- label: "Message Subagent",
409
- description: "Queue a bounded mailbox message without starting a turn.",
410
- parameters: Type.Object({
411
- agentId: Type.String(),
412
- message: Type.String({ minLength: 1, maxLength: 16 * 1024 }),
413
- senderId: Type.Optional(Type.String()),
414
- deduplicationKey: Type.Optional(Type.String({ maxLength: 256 })),
415
- }),
416
- async execute(_id, params) {
417
- const message = await requireRegistry().sendMessage(
418
- params.agentId,
419
- params.message,
420
- params.senderId,
421
- params.deduplicationKey,
422
- );
423
- return {
424
- content: [{ type: "text", text: `Queued ${message.id} for ${message.recipientId}.` }],
425
- details: { message },
426
- };
427
- },
428
- });
429
-
430
- pi.registerTool({
431
- name: "subagent_messages",
432
- label: "Read Subagent Messages",
433
- description: "Read unread mailbox messages and optionally acknowledge them.",
434
- parameters: Type.Object({
435
- agentId: Type.String(),
436
- acknowledge: Type.Optional(Type.Boolean({ default: true })),
437
- limit: Type.Optional(Type.Number({ minimum: 1, maximum: 20, default: 20 })),
438
- }),
439
- async execute(_id, params) {
440
- const messages = await requireRegistry().readMessages(
441
- params.agentId,
442
- params.acknowledge,
443
- params.limit,
444
- );
445
- const summaries = messages.map((message) => ({
446
- ...message,
447
- content: truncateUtf8(message.content, MAX_TOOL_MESSAGE_BYTES).text,
448
- }));
449
- const text = summaries.length
450
- ? summaries
451
- .map((message) => `${message.id} from ${message.senderId}: ${message.content}`)
452
- .join("\n")
453
- : "No unread messages.";
454
- return {
455
- content: [{ type: "text", text: truncateUtf8(text, DEFAULT_MAX_CONTEXT_BYTES).text }],
456
- details: { messages: summaries },
457
- };
458
- },
459
- });
460
-
461
- pi.registerTool({
462
- name: "subagent_list",
463
- label: "List Subagents",
464
- description: "List stateful subagents and lifecycle states.",
465
- parameters: Type.Object({ includeClosed: Type.Optional(Type.Boolean({ default: false })) }),
466
- async execute(_id, params) {
467
- const agents = requireRegistry().list(params.includeClosed);
468
- return {
469
- content: [
470
- {
471
- type: "text",
472
- text: agents.length ? agents.map(formatLine).join("\n") : "No stateful subagents.",
473
- },
474
- ],
475
- details: { agents: agents.map(summarizeAgent) },
476
- };
477
- },
478
- });
479
-
480
- pi.registerTool({
481
- name: "subagent_interrupt",
482
- label: "Interrupt Subagent",
483
- description: "Interrupt the current turn while retaining the subagent for follow-up work.",
484
- parameters: Type.Object({
485
- agentId: Type.String(),
486
- subtree: Type.Optional(Type.Boolean({ default: false })),
487
- }),
488
- async execute(_id, params) {
489
- if (params.subtree) {
490
- const agents = await requireRegistry().interruptTree(params.agentId);
491
- const root = requireRegistry().get(params.agentId);
492
- if (!root) throw new Error(`Unknown subagent: ${params.agentId}`);
425
+ name: "subagent_manage",
426
+ label: "Manage Subagents",
427
+ description:
428
+ "List retained subagents, interrupt active work while keeping an agent reusable, or close agents and release their resources.",
429
+ promptSnippet: "List or control retained detached subagents",
430
+ parameters: ManageParamsSchema,
431
+ async execute(_id, params): Promise<StatefulActionToolResult> {
432
+ const operation = validateManageParams(params);
433
+ if (operation.action === "list") {
434
+ const agents = requireRegistry().list(operation.includeClosed);
493
435
  return {
494
- content: [{ type: "text", text: `Interrupted ${agents.length} active agent(s).` }],
495
- details: {
496
- agent: summarizeAgent(root),
497
- agents: agents.map(summarizeAgent),
498
- },
436
+ content: [
437
+ {
438
+ type: "text",
439
+ text: agents.length ? agents.map(formatLine).join("\n") : "No stateful subagents.",
440
+ },
441
+ ],
442
+ details: { agents: agents.map(summarizeAgent) },
499
443
  };
500
444
  }
501
- const agent = await requireRegistry().interrupt(params.agentId);
502
- return result(agent, `Interrupted ${agent.id}; it remains reusable.`);
503
- },
504
- });
505
-
506
- pi.registerTool({
507
- name: "subagent_close",
508
- label: "Close Subagent",
509
- description: "Close a stateful subagent and remove it from retained persistence.",
510
- parameters: Type.Object({
511
- agentId: Type.String(),
512
- subtree: Type.Optional(Type.Boolean({ default: false })),
513
- }),
514
- async execute(_id, params) {
515
- const existing = requireRegistry().get(params.agentId);
516
- if (existing?.state === "closed" && !params.subtree) {
445
+ const agentId = operation.agentId;
446
+ if (operation.action === "interrupt") {
447
+ if (operation.subtree) {
448
+ const agents = await requireRegistry().interruptTree(agentId);
449
+ return {
450
+ content: [{ type: "text", text: `Interrupted ${agents.length} active agent(s).` }],
451
+ details: {
452
+ agent: summarizeAgent(requireAgent(agentId)),
453
+ agents: agents.map(summarizeAgent),
454
+ },
455
+ };
456
+ }
457
+ const agent = await requireRegistry().interrupt(agentId);
458
+ return result(agent, `Interrupted ${agent.id}; it remains reusable.`);
459
+ }
460
+ const existing = requireRegistry().get(agentId);
461
+ if (existing?.state === "closed" && !operation.subtree) {
517
462
  const pendingOwner = isolatedAgents.get(existing.id);
518
463
  if (pendingOwner) await workspaceManager.cleanup(pendingOwner);
519
464
  isolatedAgents.delete(existing.id);
520
465
  return result(existing, `Closed ${existing.id}.`);
521
466
  }
522
- if (params.subtree) {
467
+ if (operation.subtree) {
523
468
  let agents: ManagedAgent[];
524
469
  try {
525
- agents = await requireRegistry().closeTree(params.agentId);
470
+ agents = await requireRegistry().closeTree(agentId);
526
471
  } finally {
527
472
  await cleanupClosedWorkspaces(requireRegistry(), isolatedAgents, workspaceManager);
528
473
  }
529
- const root = requireRegistry().get(params.agentId);
530
- if (!root) throw new Error(`Unknown subagent: ${params.agentId}`);
531
474
  return {
532
475
  content: [{ type: "text", text: `Closed ${agents.length} agent(s).` }],
533
476
  details: {
534
- agent: summarizeAgent(root),
477
+ agent: summarizeAgent(requireAgent(agentId)),
535
478
  agents: agents.map(summarizeAgent),
536
479
  },
537
480
  };
538
481
  }
539
482
  let agent: ManagedAgent;
540
483
  try {
541
- agent = await requireRegistry().close(params.agentId);
484
+ agent = await requireRegistry().close(agentId);
542
485
  } finally {
543
486
  await cleanupClosedWorkspaces(requireRegistry(), isolatedAgents, workspaceManager);
544
487
  }
@@ -546,6 +489,48 @@ export function registerStatefulSubagents(
546
489
  },
547
490
  });
548
491
 
492
+ pi.registerTool({
493
+ name: "subagent_mailbox",
494
+ label: "Subagent Mailbox",
495
+ description:
496
+ "Queue a bounded message without starting a turn, or read unread mailbox messages and optionally acknowledge them.",
497
+ promptSnippet: "Send or read queue-only detached-subagent mailbox messages",
498
+ parameters: MailboxParamsSchema,
499
+ async execute(_id, params): Promise<StatefulActionToolResult> {
500
+ const operation = validateMailboxParams(params);
501
+ if (operation.action === "send") {
502
+ const message = await requireRegistry().sendMessage(
503
+ operation.agentId,
504
+ operation.message,
505
+ operation.senderId,
506
+ operation.deduplicationKey,
507
+ );
508
+ return {
509
+ content: [{ type: "text", text: `Queued ${message.id} for ${message.recipientId}.` }],
510
+ details: { message },
511
+ };
512
+ }
513
+ const messages = await requireRegistry().readMessages(
514
+ operation.agentId,
515
+ operation.acknowledge,
516
+ operation.limit,
517
+ );
518
+ const summaries = messages.map((message) => ({
519
+ ...message,
520
+ content: truncateUtf8(message.content, MAX_TOOL_MESSAGE_BYTES).text,
521
+ }));
522
+ const text = summaries.length
523
+ ? summaries
524
+ .map((message) => `${message.id} from ${message.senderId}: ${message.content}`)
525
+ .join("\n")
526
+ : "No unread messages.";
527
+ return {
528
+ content: [{ type: "text", text: truncateUtf8(text, DEFAULT_MAX_CONTEXT_BYTES).text }],
529
+ details: { messages: summaries },
530
+ };
531
+ },
532
+ });
533
+
549
534
  pi.registerCommand("subagents:agents", {
550
535
  description: "Inspect or clear current-session subagents",
551
536
  getArgumentCompletions(prefix: string) {