@d3ara1n/pi-subagent 1.7.1 → 2.1.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
  Role-based subagent orchestration for [pi](https://github.com/earendil-works/pi).
4
4
 
5
- Provides a `subagent_delegate` tool that lets the main model offload tasks to specialized pi child processes with configurable model roles, real-time TUI progress, and AI-generated summaries. Runs can be foreground (blocking) or background (asynchronous, collected later via `subagent_wait`/`subagent_check`, cancellable via `subagent_cancel`). A centered live view (`/subagent:view`) shows every run's activity feed as it happens, and mid-run corrections can be queued into a running subagent from the view's input box or via `subagent_steer`.
5
+ Provides a `subagent_delegate` tool that lets the main model offload tasks to specialized pi child processes with configurable model roles, real-time TUI progress, and AI-generated summaries. Runs can be foreground (blocking) or background (asynchronous, collected later via `subagent_wait`/`subagent_check`, cancellable via `subagent_cancel`). A centered live view (`/subagent:view`) shows every run's activity feed as it happens, with a per-run brief page for inputs and stats; mid-run corrections can be queued into a running subagent from the view's steer editor or via `subagent_steer`.
6
6
 
7
7
  ## Design Philosophy
8
8
 
@@ -23,7 +23,7 @@ This means:
23
23
 
24
24
  1. Main model calls the `subagent_delegate` tool with a role and task description
25
25
  2. The extension resolves the role to a model via pi-model-roles
26
- 3. Spawns an isolated pi child process in RPC mode (`--mode rpc`) with the configured model, tools, and system prompt — agent events stream back over stdout while stdin carries the initial prompt and mid-run steering commands
26
+ 3. Spawns an isolated pi child process in RPC mode (`--mode rpc`) with the configured model, tools, and system prompt — agent events stream back over stdout while stdin carries the initial prompt and mid-run steering commands. Children are headless: interactive extension dialogs (`ctx.ui.select/confirm/input`) are answered automatically with `cancelled` (standard "user declined" semantics), so an extension that asks never hangs the run
27
27
  4. **Real-time TUI progress** shows tool calls, turns, and elapsed time as the subagent runs
28
28
  5. After completion, an **AI-generated one-line summary** is produced for compact display
29
29
  6. Returns the result to the main model with usage statistics (turns, tokens, cost)
@@ -54,18 +54,20 @@ This means:
54
54
 
55
55
  | Command | Description |
56
56
  |---------|-------------|
57
- | `/subagent:view` | Open the live view: a tabbed overlay showing one run's full activity feed at a time, with an input box to steer the focused run |
57
+ | `/subagent:view` | Open the live view: a tabbed overlay with a per-run activity feed and a brief detail page (inputs, files, stats), plus modal steer input for the focused run |
58
58
  | `/subagent:doctor` | Diagnose pi invocation, model-role resolution, configuration, and role references |
59
59
  | `/subagent:status` | List background runs (active + collected) and their current state |
60
60
  | `/subagent:cancel <id\|all> [reason]` | Cancel a live background run (or every live run); the optional reason is recorded with the run |
61
61
 
62
62
  ### Live view (`/subagent:view`)
63
63
 
64
- A centered overlay covering most of the screen. A tab row across the top lists every run (icon · id · role); `Tab` cycles the focused run, and the rest of the viewport belongs to it alone — its complete activity feed, tail-capped to the visible area with a `⋮ earlier activity` marker when older entries roll off.
64
+ A centered overlay covering most of the screen. A tab row across the top lists every run (icon · id · role); `Tab` cycles the focused run, and the rest of the viewport belongs to it alone — showing one of two pages, toggled with `d`.
65
65
 
66
- The feed itself is a continuous, append-only list: each entry is static text with a state icon, and the only animated thing is the ellipsis on a running entry (`.` → `..` → `...`). Finishing freezes an entry in place — its position never changes, only the icon flips. Streamed assistant text grows in place as the run's last line and settles into plain terminal-colored text at the turn boundary. Both foreground and background runs appear here; a foreground run stays listed while its delegate call blocks the main agent.
66
+ The **activity page** (default) is the run's live feed: a continuous, append-only list where each entry is static text with a state icon, and the only animated thing is the ellipsis on a running entry (`.` → `..` → `...`). Finishing freezes an entry in place — its position never changes, only the icon flips. Streamed assistant text grows in place as the run's last line and settles into plain terminal-colored text at the turn boundary. The feed is scrollable (`↑↓`, `PgUp/PgDn`, `Home`/`End`): the view pins to the end and auto-follows new entries; scrolling up unpins (a `⋮ N earlier` marker appears), and reaching the bottom again re-pins. Both foreground and background runs appear here; a foreground run stays listed while its delegate call blocks the main agent. A run leaves the view once its result is in the conversation — when the last one goes, the overlay shows a centered empty notice (with `Esc close` hinted) rather than shrinking away.
67
67
 
68
- Below the feed sits a steer input box: type a correction and press Enter to queue it into the focused run (only while it is running). The message appears immediately in the feed as an `↩ steer:` entry and is delivered to the child after its current tool batch, before its next LLM call — the run keeps its progress. `Esc` closes the overlay.
68
+ The **brief page** shows the run's inputs and vitals at full width: the task and context verbatim (wrapped; head+tail elided beyond 20k chars), the reference file list annotated with `✓`/`·` for whether the child's tool calls actually touched each file, usage and time stats, the fallback trace, and a stderr tail on failures.
69
+
70
+ Steer input is modal so keys never conflict with typing: in browse mode `s` opens the editor, `Enter` queues the message into the focused run (only while it is running) and returns to browse, `Esc` cancels and clears. The message appears immediately in the feed as an `↩ steer:` entry and is delivered to the child after its current tool batch, before its next LLM call — the run keeps its progress. `Esc` in browse mode closes the overlay.
69
71
 
70
72
  ## Dependencies
71
73
 
@@ -150,11 +152,18 @@ Override, disable, or add subagent roles via `agentOverrides`. Built-in and cust
150
152
  }
151
153
  ```
152
154
 
153
- **Required fields for custom roles:** `role`, `description`, `examples`, `decisionTrigger`, `systemPrompt`. `tools` is optional — absent means all tools, a list restricts to those exact tool names.
155
+ **Required fields for custom roles:** `role`, `description`, `examples`, `decisionTrigger`, `systemPrompt`.
156
+
157
+ **Tool policy (optional, pick exactly one):**
158
+
159
+ - `tools` — exact-name allowlist: absent means all tools, a list restricts to exactly those tool names, an **empty array means zero tools**.
160
+ - `excludeTools` — denylist: everything except the listed tool names (handy for e.g. withholding interactive tools from an otherwise full-access role). Absent or empty means no restriction.
161
+
162
+ Configuring both on the same role is an error — the role is skipped with an error notification at session start.
154
163
 
155
164
  **Optional fields:** `subagentRoles` (roles this role can spawn via delegate), `timeout` (per-role active-time timeout in seconds; unset or `0` is unlimited, negative values normalize to `0`), `maxTurns` / `maxCost` (per-role budget overrides; unset uses the top-level `maxTurns` / `maxCost` setting, `0` is unlimited, negative values normalize to `0`), `fallbackRole` (backup pi-model-roles role the whole run is retried on after a provider error; unset means no retry — see [Fallback observability](#fallback-observability)).
156
165
 
157
- Invalid custom roles (missing required fields) are silently skipped with an error notification at session start.
166
+ Invalid custom roles (missing required fields) are skipped with an error notification at session start.
158
167
 
159
168
  ## Usage (by the main model)
160
169
 
@@ -220,7 +229,7 @@ Typical flow:
220
229
 
221
230
  Semantics worth knowing:
222
231
 
223
- - **Results are pull-only for the model.** A purple completion notice is shown to the user, but nothing delivers the result to the model or wakes it up. The model owns the collection point: `subagent_wait`, then `subagent_check` each run. The inbox reminder (below) lists unclaimed runs on every request, but it never pushes results.
232
+ - **Results are pull-only for the model.** A purple completion notice is shown to the user, but nothing delivers the result to the model or wakes it up. The notice is a pure notification in the same visual family as pi's `[compaction]` card — a `[subagent] id (role) outcome` header with the bare task preview beneath, each line truncated to the terminal width — and deliberately unlike the tool rows, so it never reads as model behavior; the result itself never appears in the notice, only in `subagent_check` (model) or `/subagent:status` (user). The model owns the collection point: `subagent_wait`, then `subagent_check` each run. The inbox reminder (below) lists unclaimed runs on every request, but it never pushes results.
224
233
  - **Background runs survive turn cancellation** and are unaffected by a cancelled `subagent_wait` — cancelling the wait never cancels the runs; call `subagent_wait` or `subagent_check` again later.
225
234
  - **Read-once collection:** `subagent_check` on a terminal run returns the result and frees it — the output now lives in the conversation history, and only a lightweight tombstone stays in the registry (`/subagent:status` lists it under "Collected"). Re-checking a collected id explains that its result is already in the history.
226
235
  - **Cancellation keeps the partial output.** `subagent_cancel(id, reason?)` kills the child (SIGTERM, escalating to SIGKILL) and settles the run as `cancelled` — its own stop reason in the same family as `timeout`/`budget_exceeded` (TUI warning styling ⏹, not the error-red ✗ of real failures) — with whatever it had produced. The `reason` becomes the error message verbatim, so whoever reads the partial output later via `subagent_check` — or the audit history — sees `cancelled — <reason>`; the source is distinguishable too (`user: ...` for `/subagent:cancel`, the model's own words for the tool, `session shutdown` for reaping). Cancelling does not collect: `subagent_check` still returns the partial output once, and `subagent_wait` reports the run as `cancelled (partial output kept)`.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@d3ara1n/pi-subagent",
3
- "version": "1.7.1",
3
+ "version": "2.1.0",
4
4
  "type": "module",
5
5
  "description": "Role-based subagent orchestration for pi — delegates tasks to specialized pi child processes with configurable model roles",
6
6
  "main": "src/index.ts",
package/preview.png CHANGED
Binary file
package/src/index.ts CHANGED
@@ -53,6 +53,7 @@ import {
53
53
  renderCancelResult,
54
54
  renderCheckCall,
55
55
  renderCheckResult,
56
+ renderCompletionNotice,
56
57
  renderWaitCall,
57
58
  renderWaitResult,
58
59
  } from "./render-async.ts";
@@ -145,6 +146,12 @@ export default function subagentExtension(pi: ExtensionAPI) {
145
146
  "- Delegate ONLY when a task involves significant work (heavy analysis, multi-step investigation, large-scope changes) AND you only care about the conclusion, not intermediate steps. A good test: the task would clutter your context with 3+ turns of raw tool output.",
146
147
  "- DO NOT delegate simple tasks — a single read, a one-line edit, a basic grep, or straightforward changes touching 1-2 files. Just do them yourself; spawning a child process costs more than the task.",
147
148
  "",
149
+ "SELF-CONTAINED DELEGATION (subagents have NO memory of this conversation):",
150
+ "",
151
+ "- The child sees exactly three things: task, context, files — nothing else of this chat exists for it. Any requirement, decision, or constraint it must know has to be restated in those channels.",
152
+ "- Never write phantom references (\"as discussed above\", \"per the requirements\") — the child has never seen that material. Inline the actual content instead.",
153
+ "- The isolation is deliberate: don't dump your whole conversation either. Pass what the task needs and nothing more, so the child's judgment isn't steered by irrelevant history.",
154
+ "",
148
155
  "AVAILABLE ROLES:",
149
156
  ...entries.map(([name, role]) => ` - ${name}: ${role.description}`),
150
157
  "",
@@ -203,6 +210,22 @@ export default function subagentExtension(pi: ExtensionAPI) {
203
210
  refreshAvailableRoles();
204
211
  applyAgentOverrides(availableRoles, config.agentOverrides);
205
212
 
213
+ // Tool policy is one-dimensional: `tools` is a closed allowlist,
214
+ // `excludeTools` an open denylist — carrying both is a contradiction
215
+ // (intent bug, e.g. "append to the allowlist" written as both fields),
216
+ // never a combination to resolve. Skip the role loudly; the surviving
217
+ // role set self-documents via guidelines, and delegate fast-fails on the
218
+ // missing name.
219
+ for (const [name, role] of Object.entries(availableRoles)) {
220
+ if (role.tools !== undefined && role.excludeTools !== undefined) {
221
+ delete availableRoles[name];
222
+ ctx.ui.notify(
223
+ `[pi-subagent] Role "${name}" skipped — "tools" and "excludeTools" are mutually exclusive; configure exactly one.`,
224
+ "error",
225
+ );
226
+ }
227
+ }
228
+
206
229
  // Validate custom roles (skip built-in roles — they already have all fields)
207
230
  // `tools` is optional — absent means the role gets all tools.
208
231
  const REQUIRED_FIELDS = [
@@ -264,6 +287,10 @@ export default function subagentExtension(pi: ExtensionAPI) {
264
287
  for (const run of liveRuns) run.abort("session shutdown");
265
288
  });
266
289
 
290
+ // The completion notice card — system-notice styling, not the tool-row
291
+ // family, so it never reads as model behavior.
292
+ pi.registerMessageRenderer(BACKGROUND_COMPLETION_MESSAGE_TYPE, renderCompletionNotice);
293
+
267
294
  pi.registerTool({
268
295
  name: "subagent_delegate",
269
296
  label: "Delegate to subagent",
@@ -276,7 +303,7 @@ export default function subagentExtension(pi: ExtensionAPI) {
276
303
  role: Type.String({ description: "Subagent role to use" }),
277
304
  task: Type.String({
278
305
  description:
279
- "The work to do. Instructions only — background material belongs in `context`, reference file paths in `files`.",
306
+ "The work to do — self-contained: the subagent cannot see this conversation, so restate any requirement or constraint it needs here or in `context`. Instructions only — background material belongs in `context`, reference file paths in `files`.",
280
307
  }),
281
308
  context: Type.Optional(
282
309
  Type.String({
@@ -368,19 +395,17 @@ export default function subagentExtension(pi: ExtensionAPI) {
368
395
  : result.stopReason === "cancelled"
369
396
  ? "cancelled"
370
397
  : "finished";
371
- const detail =
372
- outcome === "failed"
373
- ? result.errorMessage || result.stderr
374
- : outcome === "cancelled"
375
- ? result.errorMessage
376
- : result.summary;
377
- const detailText = detail?.trim() ? ` — ${taskPreview(detail)}` : "";
398
+ // Pure notification: id + outcome only. The result itself surfaces
399
+ // through subagent_check (model) or /subagent:status (user) — the
400
+ // notice never previews it.
378
401
  pi.sendMessage(
379
402
  {
380
403
  customType: BACKGROUND_COMPLETION_MESSAGE_TYPE,
381
- content: `Background subagent ${run.id} (${run.role}) ${outcome}: "${taskPreview(run.task)}"${detailText}`,
404
+ content: `Background subagent ${run.id} (${run.role}) ${outcome}: "${taskPreview(run.task)}"`,
382
405
  display: true,
383
- details: { id: run.id, role: run.role, outcome },
406
+ // Structured payload for the notice renderer; the content string
407
+ // stays as the non-TUI fallback (export, print mode).
408
+ details: { id: run.id, role: run.role, outcome, task: run.task },
384
409
  },
385
410
  { triggerTurn: false },
386
411
  );
@@ -1,6 +1,7 @@
1
1
  /**
2
2
  * TUI rendering for background delegation: the background delegate input
3
- * block, the wait live view, and the check snapshot view.
3
+ * block, the wait live view, the check snapshot view, and the completion
4
+ * notice card.
4
5
  *
5
6
  * This module is deliberately independent of ./render.ts (the foreground
6
7
  * delegate family): the two presentation shapes evolve separately and share
@@ -20,12 +21,17 @@
20
21
  * line takes over the icon — never both.
21
22
  */
22
23
 
23
- import { getMarkdownTheme, type ToolDefinition } from "@earendil-works/pi-coding-agent";
24
- import { Container, Markdown, Spacer, Text } from "@earendil-works/pi-tui";
24
+ import {
25
+ getMarkdownTheme,
26
+ type MessageRenderer,
27
+ type ToolDefinition,
28
+ } from "@earendil-works/pi-coding-agent";
29
+ import { Box, Container, Markdown, Spacer, Text } from "@earendil-works/pi-tui";
25
30
  import type {
26
31
  BackgroundDelegateDetails,
27
32
  CancelDetails,
28
33
  CheckDetails,
34
+ CompletionNoticeDetails,
29
35
  RunViewEntry,
30
36
  SubagentResult,
31
37
  WaitDetails,
@@ -35,6 +41,7 @@ import {
35
41
  cancelStopSummary,
36
42
  clearElapsedTimer,
37
43
  collapsedText,
44
+ completionNoticeLines,
38
45
  contentText,
39
46
  deriveRunState,
40
47
  ensureElapsedTimer,
@@ -224,6 +231,53 @@ function checkEntryExpandedContainer(r: SubagentResult, fg: Fg): Container {
224
231
  return container;
225
232
  }
226
233
 
234
+ // ── completion notice: system-notice card, not the tool-row family ──
235
+
236
+ /**
237
+ * Renderer for the `subagent-completion` custom message — the background-run
238
+ * completion notice. Same visual language as pi's [compaction] card
239
+ * (bracket label + purple box) and deliberately unlike the tool rows above:
240
+ * no tool-title prefix, no status icons, no usage lines. The bracket label
241
+ * is the "this is a system event, not model behavior" signal.
242
+ *
243
+ * Collapsed: header row + bare task preview beneath, each line truncated
244
+ * to the box interior width with "…" — the header can never fall off the
245
+ * right edge. Expanded (ctrl+o): the same lines untruncated, wrapped by Text.
246
+ */
247
+ export const renderCompletionNotice: MessageRenderer<CompletionNoticeDetails> = (
248
+ message,
249
+ { expanded },
250
+ theme,
251
+ ) => {
252
+ const fg = theme.fg.bind(theme) as Fg;
253
+ const box = new Box(1, 1, (t) => theme.bg("customMessageBg", t));
254
+
255
+ const details = message.details;
256
+ if (!details) {
257
+ // Sessions persisted before structured details: fall back to the plain
258
+ // content string that was sent alongside them.
259
+ const text =
260
+ typeof message.content === "string"
261
+ ? message.content
262
+ : (message.content ?? [])
263
+ .filter((c) => c.type === "text")
264
+ .map((c) => c.text)
265
+ .join("\n");
266
+ box.addChild(new Text(fg("customMessageText", text), 0, 0));
267
+ return box;
268
+ }
269
+
270
+ const lines = completionNoticeLines(details, fg, theme.bold.bind(theme));
271
+ if (expanded) {
272
+ for (const line of lines) box.addChild(new Text(line, 0, 0));
273
+ } else {
274
+ // Per-line width-aware truncation inside the box (Box hands children the
275
+ // interior width; collapsedText never lets a line wrap).
276
+ box.addChild(collapsedText(lines.join("\n")));
277
+ }
278
+ return box;
279
+ };
280
+
227
281
  // ── Background delegate: static input block ────────────────────
228
282
 
229
283
  export const renderBackgroundDelegateCall: RenderCallFn = (args, theme) => {
package/src/roles.ts CHANGED
@@ -35,7 +35,7 @@ export const BUILTIN_ROLES: Record<string, SubagentRole> = {
35
35
  fallbackRole: "default",
36
36
  timeout: 3600,
37
37
  description:
38
- "READ-ONLY code review & analysis — audit code, assess architecture, review diffs, run tests for evidence.",
38
+ "READ-ONLY code review & analysis — audit code, assess architecture, review diffs, run tests for evidence. Reports findings and suggested fixes but never implements them.",
39
39
  examples: [
40
40
  "Review the error handling in src/api/ for security issues",
41
41
  "Audit this PR diff for performance regressions",
@@ -44,6 +44,7 @@ export const BUILTIN_ROLES: Record<string, SubagentRole> = {
44
44
  tools: ["read", "bash", "grep", "find"],
45
45
  systemPrompt: [
46
46
  "Senior code reviewer. READ-ONLY — you must NOT modify any file.",
47
+ "If the task asks you to fix or implement, do NOT do it: report findings and suggested fixes, and state that implementation is out of scope for this role.",
47
48
  "Run only read-only commands (git diff/log/show, test runs). Never use sed, tee, echo >, or any write command.",
48
49
  "Provide evidence-backed findings with file:line references.",
49
50
  "",
@@ -84,7 +85,7 @@ export const BUILTIN_ROLES: Record<string, SubagentRole> = {
84
85
  fallbackRole: "default",
85
86
  timeout: 2400,
86
87
  description:
87
- "the ONLY role with WEB ACCESS — search docs, fetch pages, verify claims, analyze GitHub repos. Can clone repos & delegate to explorer.",
88
+ "the ONLY role with WEB ACCESS — search docs, fetch pages, verify claims, analyze GitHub repos. Reports verified facts and sources only; decisions and proposals stay with the caller. Can clone repos & delegate to explorer.",
88
89
  examples: ["Find the React 19 migration guide", "Check GitHub issue #1234 for context"],
89
90
  decisionTrigger: "Task searches web or GitHub?",
90
91
  tools: [
@@ -101,6 +102,7 @@ export const BUILTIN_ROLES: Record<string, SubagentRole> = {
101
102
  subagentRoles: ["explorer"],
102
103
  systemPrompt: [
103
104
  "Web researcher. Search with varied angles, prefer official docs over blogs.",
105
+ "Report verified facts and sources only — do not propose solutions or make design decisions; the caller weighs your findings and decides.",
104
106
  "If first results are insufficient, refine queries and search again.",
105
107
  "",
106
108
  "## Research artifacts",
package/src/run.ts CHANGED
@@ -290,6 +290,7 @@ export function startSubagentRun(opts: StartRunOptions): RunHandle {
290
290
  cwd: opts.cwd,
291
291
  thinking,
292
292
  tools: opts.roleDef.tools,
293
+ excludeTools: opts.roleDef.excludeTools,
293
294
  systemPrompt: opts.roleDef.systemPrompt,
294
295
  context: opts.context,
295
296
  contextFiles: opts.files,
@@ -325,6 +326,7 @@ export function startSubagentRun(opts: StartRunOptions): RunHandle {
325
326
  cwd: opts.cwd,
326
327
  thinking: fallback.config.thinking,
327
328
  tools: opts.roleDef.tools,
329
+ excludeTools: opts.roleDef.excludeTools,
328
330
  systemPrompt: opts.roleDef.systemPrompt,
329
331
  context: opts.context,
330
332
  contextFiles: opts.files,
package/src/spawn.test.ts CHANGED
@@ -9,7 +9,7 @@ import assert from "node:assert/strict";
9
9
  import * as fs from "node:fs";
10
10
  import * as os from "node:os";
11
11
  import * as path from "node:path";
12
- import { composeInitialMessage } from "./spawn.ts";
12
+ import { buildChildArgs, composeInitialMessage } from "./spawn.ts";
13
13
 
14
14
  describe("composeInitialMessage", () => {
15
15
  test("wraps reference files in <file> blocks ahead of context and task", async () => {
@@ -47,3 +47,60 @@ describe("composeInitialMessage", () => {
47
47
  assert.equal(message, "<task>\nt\n</task>");
48
48
  });
49
49
  });
50
+
51
+ describe("buildChildArgs", () => {
52
+ test("base invocation: rpc mode, no session, model, then env+policy prompt blocks", () => {
53
+ const tmpDir = "/tmp/pi-subagent-x";
54
+ const args = buildChildArgs("prov/model", {}, tmpDir);
55
+ assert.deepEqual(args.slice(0, 6), [
56
+ "--mode",
57
+ "rpc",
58
+ "--no-session",
59
+ "--model",
60
+ "prov/model",
61
+ "--append-system-prompt",
62
+ ]);
63
+ const joined = args.join(" ");
64
+ assert.ok(joined.includes(`<subagent_env>\nPI_SUBAGENT_TMPDIR=${tmpDir}`));
65
+ assert.ok(joined.includes("<subagent_policy>"));
66
+ assert.ok(!joined.includes("--tools"));
67
+ assert.ok(!joined.includes("--exclude-tools"));
68
+ });
69
+
70
+ test("tools list → exact allowlist flag", () => {
71
+ const args = buildChildArgs("m", { tools: ["read", "grep"] }, "/t");
72
+ assert.ok(args.includes("--tools"));
73
+ assert.equal(args[args.indexOf("--tools") + 1], "read,grep");
74
+ });
75
+
76
+ test("tools: [] → --no-tools (literally zero tools)", () => {
77
+ const args = buildChildArgs("m", { tools: [] }, "/t");
78
+ assert.ok(args.includes("--no-tools"));
79
+ assert.ok(!args.includes("--tools"));
80
+ });
81
+
82
+ test("excludeTools → denylist flag; empty ≡ absent (no flag)", () => {
83
+ const args = buildChildArgs("m", { excludeTools: ["ask_user"] }, "/t");
84
+ assert.equal(args[args.indexOf("--exclude-tools") + 1], "ask_user");
85
+ const empty = buildChildArgs("m", { excludeTools: [] }, "/t");
86
+ assert.ok(!empty.includes("--exclude-tools"));
87
+ });
88
+
89
+ test("tools wins over excludeTools (both set is rejected upstream; belt only)", () => {
90
+ const args = buildChildArgs("m", { tools: ["read"], excludeTools: ["bash"] }, "/t");
91
+ assert.equal(args[args.indexOf("--tools") + 1], "read");
92
+ assert.ok(!args.includes("--exclude-tools"));
93
+ });
94
+
95
+ test("thinking and role system prompt are wrapped in their blocks", () => {
96
+ const args = buildChildArgs(
97
+ "m",
98
+ { thinking: "high", systemPrompt: " Be brief. " },
99
+ "/t",
100
+ );
101
+ assert.equal(args[args.indexOf("--thinking") + 1], "high");
102
+ const roleIdx = args.findIndex((a) => a.startsWith("<subagent_role>"));
103
+ assert.ok(roleIdx > 0);
104
+ assert.equal(args[roleIdx], "<subagent_role>\nBe brief.\n</subagent_role>");
105
+ });
106
+ });
package/src/spawn.ts CHANGED
@@ -131,6 +131,91 @@ export function getPiInvocation(args: string[]): { command: string; args: string
131
131
  return { command: "pi", args };
132
132
  }
133
133
 
134
+ /**
135
+ * Build the pi CLI args for one child run (RPC mode).
136
+ *
137
+ * Tool policy mirrors pi's CLI flags in three mutually exclusive states
138
+ * (tools + excludeTools together is rejected at role resolution):
139
+ * - `tools` set → exact allowlist (`--tools`; empty = `--no-tools`, literally zero tools)
140
+ * - `excludeTools` set → denylist (`--exclude-tools`; empty ≡ absent — nothing excluded)
141
+ * - neither → all tools (no flag)
142
+ *
143
+ * @internal — exported for testing.
144
+ */
145
+ export function buildChildArgs(
146
+ modelRef: string,
147
+ options: {
148
+ thinking?: string;
149
+ tools?: string[];
150
+ excludeTools?: string[];
151
+ systemPrompt?: string;
152
+ },
153
+ tmpDir: string,
154
+ ): string[] {
155
+ const args: string[] = ["--mode", "rpc", "--no-session", "--model", modelRef];
156
+
157
+ if (options.thinking) {
158
+ args.push("--thinking", options.thinking);
159
+ }
160
+
161
+ if (options.tools) {
162
+ if (options.tools.length > 0) {
163
+ args.push("--tools", options.tools.join(","));
164
+ } else {
165
+ args.push("--no-tools");
166
+ }
167
+ } else if (options.excludeTools && options.excludeTools.length > 0) {
168
+ args.push("--exclude-tools", options.excludeTools.join(","));
169
+ }
170
+
171
+ // System prompt channel: inline text via --append-system-prompt. pi's
172
+ // resolvePromptInput treats an existing path as a file to read and any
173
+ // non-path string as literal text, so structured blocks go directly — no
174
+ // temp file, zero disk I/O.
175
+ if (options.systemPrompt?.trim()) {
176
+ args.push(
177
+ "--append-system-prompt",
178
+ `<subagent_role>\n${options.systemPrompt.trim()}\n</subagent_role>`,
179
+ );
180
+ }
181
+ args.push(
182
+ "--append-system-prompt",
183
+ `<subagent_env>\nPI_SUBAGENT_TMPDIR=${tmpDir}\nAvailable as $PI_SUBAGENT_TMPDIR in bash. Use for git clone and scratch files.\n</subagent_env>`,
184
+ );
185
+ // Shared behavioral policy for EVERY subagent run — built-in roles and
186
+ // agentOverrides customs alike. Role prompts (roles.ts) shape WHAT a role
187
+ // does; this shapes HOW any subagent behaves when the task exceeds its
188
+ // actual capabilities: report the gap and stop instead of improvising
189
+ // workarounds until timeout.
190
+ args.push(
191
+ "--append-system-prompt",
192
+ [
193
+ "<subagent_policy>",
194
+ "Before attempting the task, check it against your actual capabilities in this",
195
+ "session — the tool list here is definitive.",
196
+ "- If the task needs a capability you do not have (web access, bash, file",
197
+ " writes, ...) or material that is not present locally or in the provided",
198
+ " context/files, it is out of scope for you. Do NOT improvise workarounds.",
199
+ "- The task may reference material as 'discussed above' or 'the requirements'",
200
+ " — you have NO prior conversation; only this prompt exists. If referenced",
201
+ " material is not in this prompt, report it as Missing — do not guess it.",
202
+ '- "Cannot complete" means a capability or material gap — not "difficult" or',
203
+ ' "uncertain". If it is merely hard, keep working within your tools.',
204
+ "- When you hit a genuine gap, stop early and return:",
205
+ " ## Cannot complete",
206
+ " - Missing: the capability or material that is absent",
207
+ " - Needed: what would complete the task",
208
+ " - Found: partial findings so far (optional)",
209
+ "",
210
+ 'An early "cannot complete" report is a successful outcome; grinding on',
211
+ "impossible workarounds until timeout is the failure.",
212
+ "</subagent_policy>",
213
+ ].join("\n"),
214
+ );
215
+
216
+ return args;
217
+ }
218
+
134
219
  /**
135
220
  * Compose the initial RPC prompt message: reference files as <file> blocks,
136
221
  * then context and task as structured tags — the same shape the child saw in
@@ -177,6 +262,8 @@ export async function spawnSubagent(
177
262
  cwd?: string;
178
263
  /** Thinking level passed to the child pi process when the role defines one. */
179
264
  thinking?: string;
265
+ /** Extra tools withheld from the child (denylist); ignored when `tools` is set. */
266
+ excludeTools?: string[];
180
267
  tools?: string[];
181
268
  systemPrompt?: string;
182
269
  /** Extra context delivered as a separate channel from the task. */
@@ -232,64 +319,12 @@ export async function spawnSubagent(
232
319
  let tmpDir: string | null = null;
233
320
 
234
321
  try {
235
- // Build CLI args. RPC mode (not json): stdin carries the initial prompt and
236
- // mid-run steering commands; stdout streams the same agent events.
237
- const args: string[] = ["--mode", "rpc", "--no-session", "--model", modelRef];
238
-
239
- if (options.thinking) {
240
- args.push("--thinking", options.thinking);
241
- }
242
-
243
- if (options.tools && options.tools.length > 0) {
244
- args.push("--tools", options.tools.join(","));
245
- }
246
-
247
322
  // Scratch dir handed to the child as PI_SUBAGENT_TMPDIR for its bash work
248
323
  // (e.g. git clone). The initial prompt goes over stdin, so there is no
249
324
  // argv length limit and no spill-to-tempfile channel anymore.
250
325
  tmpDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), "pi-subagent-"));
251
326
 
252
- // ── System prompt channel: inline text via --append-system-prompt ──
253
- // pi's resolvePromptInput treats an existing path as a file to read and any
254
- // non-path string as literal text, so we pass structured blocks directly —
255
- // no temp file, zero disk I/O. Multiple flags are joined with "\n\n".
256
- if (options.systemPrompt?.trim()) {
257
- args.push(
258
- "--append-system-prompt",
259
- `<subagent_role>\n${options.systemPrompt.trim()}\n</subagent_role>`,
260
- );
261
- }
262
- args.push(
263
- "--append-system-prompt",
264
- `<subagent_env>\nPI_SUBAGENT_TMPDIR=${tmpDir}\nAvailable as $PI_SUBAGENT_TMPDIR in bash. Use for git clone and scratch files.\n</subagent_env>`,
265
- );
266
- // Shared behavioral policy for EVERY subagent run — built-in roles and
267
- // agentOverrides customs alike. Role prompts (roles.ts) shape WHAT a role
268
- // does; this shapes HOW any subagent behaves when the task exceeds its
269
- // actual capabilities: report the gap and stop instead of improvising
270
- // workarounds until timeout.
271
- args.push(
272
- "--append-system-prompt",
273
- [
274
- "<subagent_policy>",
275
- "Before attempting the task, check it against your actual capabilities in this",
276
- "session — the tool list here is definitive.",
277
- "- If the task needs a capability you do not have (web access, bash, file",
278
- " writes, ...) or material that is not present locally or in the provided",
279
- " context/files, it is out of scope for you. Do NOT improvise workarounds.",
280
- '- "Cannot complete" means a capability or material gap — not "difficult" or',
281
- ' "uncertain". If it is merely hard, keep working within your tools.',
282
- "- When you hit a genuine gap, stop early and return:",
283
- " ## Cannot complete",
284
- " - Missing: the capability or material that is absent",
285
- " - Needed: what would complete the task",
286
- " - Found: partial findings so far (optional)",
287
- "",
288
- 'An early "cannot complete" report is a successful outcome; grinding on',
289
- "impossible workarounds until timeout is the failure.",
290
- "</subagent_policy>",
291
- ].join("\n"),
292
- );
327
+ const args = buildChildArgs(modelRef, options, tmpDir);
293
328
 
294
329
  // ── Initial prompt channel: one RPC prompt command over stdin ──
295
330
  // RPC mode rejects @file argv, so reference files are inlined here as
@@ -367,6 +402,20 @@ export async function spawnSubagent(
367
402
  // RPC command acknowledgements ("response" lines) carry no agent state.
368
403
  if (event.type === "response") return;
369
404
 
405
+ // The child is a headless worker — nobody can answer its dialogs.
406
+ // RPC-mode ctx.ui.select/confirm/input emit extension_ui_request and
407
+ // park the child's extension on a promise that has NO default timeout;
408
+ // without a response the child hangs until the run timeout kills it.
409
+ // Answer immediately with cancelled:true — the standard "user declined"
410
+ // semantics (undefined/false) extensions already handle. Fire-and-forget
411
+ // methods (notify/setStatus/setWidget/setTitle) need no answer.
412
+ if (event.type === "extension_ui_request" && event.id) {
413
+ if (event.method === "select" || event.method === "confirm" || event.method === "input") {
414
+ sendCommand({ type: "extension_ui_response", id: event.id, cancelled: true });
415
+ }
416
+ return;
417
+ }
418
+
370
419
  // RPC mode is a resident server. agent_end only marks one low-level run
371
420
  // and may be followed by an automatic retry, compaction, or queued
372
421
  // continuation. agent_settled is the authoritative terminal event. We run
package/src/types.ts CHANGED
@@ -55,10 +55,17 @@ export interface SubagentRole {
55
55
  /** System prompt for the subagent */
56
56
  systemPrompt: string;
57
57
  /**
58
- * Tools available to this subagent. Empty or absent = all tools (no restriction).
59
- * When set, only the listed tool names are exposed to the child (exact-name allowlist).
58
+ * Tools available to this subagent exact-name allowlist mapped to pi's `--tools`.
59
+ * Absent = all tools (no restriction); empty array = literally zero tools (`--no-tools`).
60
+ * Mutually exclusive with `excludeTools`.
60
61
  */
61
62
  tools?: string[];
63
+ /**
64
+ * Tools withheld from this subagent — everything else stays available (mapped
65
+ * to pi's `--exclude-tools`). Absent or empty = no restriction.
66
+ * Mutually exclusive with `tools`.
67
+ */
68
+ excludeTools?: string[];
62
69
  /** If this role has `delegate`, restrict which roles it may spawn. undefined = no restriction. */
63
70
  subagentRoles?: string[];
64
71
  /** Per-role active-time timeout in seconds. `0` or unset means unlimited; negative values are normalized to `0`. */
@@ -245,3 +252,19 @@ export interface CheckDetails {
245
252
  * reason, never the partial output (check is the result-fetcher).
246
253
  */
247
254
  export type CancelDetails = CheckDetails;
255
+
256
+ /**
257
+ * Details for the background-run completion notice (custom message
258
+ * `subagent-completion`). The renderer lays these out as a structured notice
259
+ * card; the message's plain `content` string stays as the non-TUI fallback
260
+ * (export, print mode) and for sessions persisted before this shape existed
261
+ * (task absent there).
262
+ */
263
+ export interface CompletionNoticeDetails {
264
+ /** Registry id (sub-N). */
265
+ id: string;
266
+ role: string;
267
+ outcome: "finished" | "failed" | "cancelled";
268
+ /** Full task text; the renderer flattens and truncates it. */
269
+ task?: string;
270
+ }