@d3ara1n/pi-subagent 0.1.0 → 0.3.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,22 @@
2
2
 
3
3
  Role-based subagent orchestration for [pi](https://github.com/earendil-works/pi).
4
4
 
5
- Provides a `delegate` tool that lets the main model delegate tasks to specialized pi child processes with configurable model roles, real-time TUI progress, and AI-generated summaries.
5
+ Provides a `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.
6
+
7
+ ## Design Philosophy
8
+
9
+ **The main model is the decision maker; subagents are executors.**
10
+
11
+ Your primary AI has the most complete context — it knows the full conversation history, project structure, and task at hand. Subagents are spawned with **clean, isolated contexts** to handle specific, well-defined tasks without polluting the main model's context window.
12
+
13
+ This means:
14
+ - **Subagents don't plan** — the main model decides what needs to be done and provides a clear task description
15
+ - **Subagents don't orchestrate** — if a task requires multiple steps, the main model examines each result and decides the next move
16
+ - **Subagents don't inherit history** — they don't need the full conversation; just a precise task description
17
+ - **Multiple subagents can run in parallel** — emit multiple `delegate` calls in one turn; pi executes them concurrently
18
+ - **Subagents can nest subagents** — a `worker` can delegate exploration to `explorer` without returning to the main model
19
+
20
+ > This design intentionally excludes chain pipelines and context-forking — those patterns are better suited when subagents act as advisors (planner, oracle), not executors.
6
21
 
7
22
  ## How it works
8
23
 
@@ -15,17 +30,21 @@ Provides a `delegate` tool that lets the main model delegate tasks to specialize
15
30
 
16
31
  ## Built-in Roles
17
32
 
18
- | Role | Model Role | Tools | Description |
19
- |------|-----------|-------|-------------|
20
- | `explorer` | fast | read, bash, find, grep, glob | Fast code search (read-only) |
21
- | `reviewer` | heavy | read, bash, grep, glob | Deep code review (read-only) |
22
- | `worker` | default | read, bash, edit, write, grep, glob | Implementation with file editing |
23
- | `researcher` | fast | web_search, fetch_content, read | Web research and docs lookup |
33
+ | Role | Model Role | Tools | Can Delegate To | Description |
34
+ |------|-----------|-------|-----------------|-------------|
35
+ | `explorer` | fast | read, find, grep, glob | — | Fast code search (read-only, no bash) |
36
+ | `reviewer` | heavy | read, bash, grep, glob | — | Deep code review (read-only, bash for git/log) |
37
+ | `worker` | default | read, bash, edit, write, grep, glob, delegate | explorer, researcher | Implementation the only role that can modify files |
38
+ | `researcher` | fast | web_search, fetch_content, read, bash, delegate | explorer | Web research + GitHub repo analysis |
39
+
40
+ **Nested delegation**: `worker` and `researcher` can spawn their own subagents. This keeps the main model's context clean — a worker can explore unfamiliar code via an `explorer` subagent without returning intermediate results to the main model.
41
+
42
+ **Parallel execution**: To run multiple subagents concurrently, emit multiple `delegate` calls in a single turn. Pi's framework executes them in parallel automatically, with each subagent getting its own TUI progress display.
24
43
 
25
44
  ## TUI Display
26
45
 
27
46
  - **During execution**: Shows role, elapsed time, turn count, and live tool calls
28
- - **Collapsed result**: `✓ explorer · 找到了登录/注册/token三块逻辑` + recent tool calls + usage stats
47
+ - **Collapsed result**: `✓ explorer · Found login, registration, and token logic` + recent tool calls + usage stats
29
48
  - **Expanded result** (Ctrl+O): Full task text, all tool calls, final output as rendered Markdown, and usage details
30
49
 
31
50
  ## Requirements
@@ -37,7 +56,7 @@ Provides a `delegate` tool that lets the main model delegate tasks to specialize
37
56
  ## Installation
38
57
 
39
58
  ```bash
40
- pi extension add @d3ara1n/pi-subagent
59
+ pi install @d3ara1n/pi-subagent
41
60
  ```
42
61
 
43
62
  ## Configuration
@@ -51,7 +70,7 @@ Edit `~/.pi/agent/settings.json`:
51
70
  "timeoutMs": 300000,
52
71
 
53
72
  // Summary generation — uses a lightweight model to create
54
- // a one-line Chinese summary for the TUI display
73
+ // a one-line summary for the TUI display
55
74
  "summary": {
56
75
  "role": "utility", // pi-model-roles role for summarization
57
76
  "enabled": true // set false to disable
@@ -62,8 +81,51 @@ Edit `~/.pi/agent/settings.json`:
62
81
 
63
82
  All fields are optional. Defaults: `timeoutMs: 300000`, `summary.role: "utility"`, `summary.enabled: true`.
64
83
 
84
+ ### Agent Overrides
85
+
86
+ Override, disable, or add subagent roles via `agentOverrides`. Built-in and custom roles are treated equally — all descriptions, examples, and decision triggers feed into the LLM's prompt dynamically.
87
+
88
+ ```jsonc
89
+ {
90
+ "subagent": {
91
+ "agentOverrides": {
92
+ // ── Override a built-in role (only specify changed fields) ──
93
+ "worker": {
94
+ "role": "heavy" // use a stronger model
95
+ },
96
+
97
+ // ── Disable a built-in role ──
98
+ "reviewer": {
99
+ "disabled": true
100
+ },
101
+
102
+ // ── Add a custom role (all required fields must be provided) ──
103
+ "tester": {
104
+ "role": "default",
105
+ "description": "Test automation & QA — write and run tests, validate fixes. Tools: read, bash, edit, write, grep. Can delegate to explorer.",
106
+ "examples": [
107
+ "Write unit tests for the auth module",
108
+ "Run the test suite and fix failing tests"
109
+ ],
110
+ "decisionTrigger": "Task writes or runs tests?",
111
+ "tools": ["read", "bash", "edit", "write", "grep"],
112
+ "systemPrompt": "QA engineer. Write tests, run them, fix failures. After each change, re-run affected tests."
113
+ }
114
+ }
115
+ }
116
+ }
117
+ ```
118
+
119
+ **Required fields for custom roles:** `role`, `description`, `examples`, `decisionTrigger`, `tools`, `systemPrompt`.
120
+
121
+ **Optional fields:** `subagentRoles` (roles this role can spawn via delegate), `fallbackRole` (backup pi-model-roles role on provider errors).
122
+
123
+ Invalid custom roles (missing required fields) are silently skipped with an error notification at session start.
124
+
65
125
  ## Usage (by the main model)
66
126
 
127
+ Delegate tasks that would generate many tool calls or verbose output to keep your own context clean:
128
+
67
129
  ```json
68
130
  {
69
131
  "role": "explorer",
@@ -71,6 +133,24 @@ All fields are optional. Defaults: `timeoutMs: 300000`, `summary.role: "utility"
71
133
  }
72
134
  ```
73
135
 
136
+ **Role-specific examples:**
137
+
138
+ | Role | Example task | Why delegate? |
139
+ |------|-------------|---------------|
140
+ | `explorer` | `"Map the routing structure of src/api/"` | You only need the conclusion, not every grep result |
141
+ | `reviewer` | `"Review error handling in auth.ts for security issues"` | Review output is longform; keep it isolated |
142
+ | `worker` | `"Rename all snake_case fields to camelCase in src/models/"` | Your context stays focused on high-level intent |
143
+ | `researcher` | `"Find the React 19 migration guide and summarize breaking changes"` | Search results are noisy; get a clean summary |
144
+
145
+ **Parallel usage:** emit multiple `delegate` calls in a single turn:
146
+
147
+ ```json
148
+ [
149
+ { "role": "explorer", "task": "Map the repository structure" },
150
+ { "role": "researcher", "task": "Find latest docs on the library used here" }
151
+ ]
152
+ ```
153
+
74
154
  ## License
75
155
 
76
156
  MIT
package/package.json CHANGED
@@ -1,6 +1,7 @@
1
1
  {
2
2
  "name": "@d3ara1n/pi-subagent",
3
- "version": "0.1.0",
3
+ "version": "0.3.0",
4
+ "type": "module",
4
5
  "description": "Role-based subagent orchestration for pi — delegates tasks to specialized pi child processes with configurable model roles",
5
6
  "main": "src/index.ts",
6
7
  "keywords": [
package/src/config.ts CHANGED
@@ -59,5 +59,6 @@ export function loadSubagentConfig(cwd?: string): SubagentConfig {
59
59
  role: rawSummary?.role ?? DEFAULT_CONFIG.summary.role,
60
60
  enabled: rawSummary?.enabled ?? DEFAULT_CONFIG.summary.enabled,
61
61
  },
62
+ agentOverrides: raw.agentOverrides ?? {},
62
63
  };
63
64
  }
package/src/index.ts CHANGED
@@ -15,11 +15,11 @@ import { Container, Markdown, Spacer, Text } from "@earendil-works/pi-tui";
15
15
  import { Type } from "typebox";
16
16
  import type { ModelRolesAPI } from "@d3ara1n/pi-model-roles";
17
17
  import { getModelRolesAPI } from "@d3ara1n/pi-model-roles";
18
- import type { SubagentConfig, SubagentDetails, SubagentResult } from "./types.ts";
18
+ import type { SubagentConfig, SubagentDetails, SubagentResult, SubagentRole, ToolStatus, ActivityEntry } from "./types.ts";
19
19
  import { DEFAULT_CONFIG } from "./types.ts";
20
20
  import { loadSubagentConfig } from "./config.ts";
21
21
  import { BUILTIN_ROLES } from "./roles.ts";
22
- import { spawnSubagent } from "./spawn.ts";
22
+ import { spawnSubagent, getPiInvocation } from "./spawn.ts";
23
23
  import * as os from "node:os";
24
24
 
25
25
  // ── Helpers ────────────────────────────────────────────────────────
@@ -44,25 +44,23 @@ function formatUsageStats(usage: SubagentResult["usage"], model?: string): strin
44
44
  }
45
45
 
46
46
  type DisplayItem =
47
- | { type: "text"; text: string }
48
- | { type: "toolCall"; name: string; args: Record<string, any> };
49
-
50
- function getDisplayItems(messages: SubagentResult["messages"]): DisplayItem[] {
51
- const items: DisplayItem[] = [];
52
- for (const msg of messages) {
53
- if (msg.role === "assistant") {
54
- for (const part of msg.content) {
55
- if (part.type === "text" && part.text) items.push({ type: "text", text: part.text });
56
- else if (part.type === "toolCall" && part.name)
57
- items.push({ type: "toolCall", name: part.name, args: part.arguments ?? {} });
58
- }
59
- }
60
- }
61
- return items;
47
+ | { type: "toolCall"; name: string; args: Record<string, any>; status?: ToolStatus }
48
+ | { type: "thinking"; status?: ToolStatus };
49
+
50
+ /** Map the real-time activity log into renderable display items (in order). */
51
+ function buildDisplayItems(activityLog: ActivityEntry[]): DisplayItem[] {
52
+ return activityLog.map((a) =>
53
+ a.kind === "thinking"
54
+ ? { type: "thinking", status: a.status }
55
+ : { type: "toolCall", name: a.toolName ?? "?", args: a.args ?? {}, status: a.status },
56
+ );
62
57
  }
63
58
 
64
59
  function shortenPath(p: string): string {
65
60
  const home = os.homedir();
61
+ if (process.platform === "win32") {
62
+ return p.toLowerCase().startsWith(home.toLowerCase()) ? `~${p.slice(home.length)}` : p;
63
+ }
66
64
  return p.startsWith(home) ? `~${p.slice(home.length)}` : p;
67
65
  }
68
66
 
@@ -123,6 +121,35 @@ function formatToolCall(
123
121
  }
124
122
  }
125
123
 
124
+ /** Per-tool-call visual styling: prefix glyph + color function keyed by status. */
125
+ function statusStyle(
126
+ status: ToolStatus | undefined,
127
+ fg: (color: string, text: string) => string,
128
+ ): { prefix: string; color: (c: string, text: string) => string } {
129
+ switch (status) {
130
+ case "running":
131
+ return { prefix: fg("accent", "\u25CF "), color: fg };
132
+ case "failed":
133
+ return { prefix: fg("error", "\u2717 "), color: (_c, text) => fg("error", text) };
134
+ case "done":
135
+ default:
136
+ return { prefix: fg("dim", "\u2192 "), color: (_c, text) => fg("dim", text) };
137
+ }
138
+ }
139
+
140
+ /** Render a thinking-block row: diamond glyph + label, colored by status.
141
+ * Running = hollow diamond (unformed thought); done = solid diamond (settled). */
142
+ function formatThinking(
143
+ status: ToolStatus | undefined,
144
+ fg: (color: string, text: string) => string,
145
+ ): string {
146
+ if (status === "running") {
147
+ return fg("accent", "\u25C7 thinking");
148
+ }
149
+ // done (or unknown) — dim past tense, solid diamond
150
+ return fg("dim", "\u25C6 thought");
151
+ }
152
+
126
153
  function renderDisplayItems(
127
154
  items: DisplayItem[],
128
155
  limit: number | undefined,
@@ -133,11 +160,11 @@ function renderDisplayItems(
133
160
  let text = "";
134
161
  if (skipped > 0) text += fg("muted", `... ${skipped} earlier items\n`);
135
162
  for (const item of toShow) {
136
- if (item.type === "text") {
137
- const preview = item.text.split("\n").slice(0, 3).join("\n");
138
- text += `${fg("toolOutput", preview.length > 120 ? preview.slice(0, 120) + "..." : preview)}\n`;
163
+ if (item.type === "thinking") {
164
+ text += `${formatThinking(item.status, fg)}\n`;
139
165
  } else {
140
- text += `${fg("muted", "\u2192 ")}${formatToolCall(item.name, item.args, fg)}\n`;
166
+ const { prefix, color } = statusStyle(item.status, fg);
167
+ text += `${prefix}${formatToolCall(item.name, item.args, color)}\n`;
141
168
  }
142
169
  }
143
170
  return text.trimEnd();
@@ -172,12 +199,20 @@ async function generateSummary(
172
199
  const resolved = await rolesApi.resolveRoleAsync(summaryConfig.role);
173
200
  if (!resolved.model) return undefined;
174
201
 
202
+ // Truncate large outputs to avoid wasting summary tokens (keep head + tail)
203
+ const SUMMARY_MAX_INPUT = 4000;
204
+ let summaryInput = outputText;
205
+ if (summaryInput.length > SUMMARY_MAX_INPUT) {
206
+ const half = Math.floor(SUMMARY_MAX_INPUT / 2);
207
+ summaryInput = summaryInput.slice(0, half) + "\n\n... [truncated for summary] ...\n\n" + summaryInput.slice(-half);
208
+ }
209
+
175
210
  const result = await complete(
176
211
  resolved.model,
177
212
  {
178
213
  systemPrompt:
179
- "Summarize the following agent output in one concise Chinese sentence (max 60 characters). Focus on what was accomplished, not how. Output only the summary, no preamble.",
180
- messages: [{ role: "user", content: outputText }],
214
+ "Summarize the following agent output in one concise sentence (max 60 characters). Respond in the same language as the input. Focus on what was accomplished, not how. Output only the summary, no preamble.",
215
+ messages: [{ role: "user", content: summaryInput, timestamp: Date.now() }],
181
216
  },
182
217
  {
183
218
  maxTokens: 100,
@@ -194,7 +229,12 @@ async function generateSummary(
194
229
 
195
230
  return text || undefined;
196
231
  } catch {
197
- return undefined;
232
+ // Fall back to manual truncation: use first line of output as summary
233
+ const trimmed = outputText.trim();
234
+ if (!trimmed) return undefined;
235
+ const firstLine = trimmed.split("\n")[0];
236
+ if (firstLine.length <= 65) return firstLine;
237
+ return firstLine.slice(0, 62) + "...";
198
238
  }
199
239
  }
200
240
 
@@ -203,51 +243,126 @@ async function generateSummary(
203
243
  export default function subagentExtension(pi: ExtensionAPI) {
204
244
  let config: SubagentConfig = DEFAULT_CONFIG;
205
245
 
246
+ // If spawned as a child by a parent subagent, PI_SUBAGENT_ALLOWED restricts
247
+ // which roles are available. Filter before any tool description sees them.
248
+ const ALLOWLIST: string[] | undefined = (() => {
249
+ const raw = process.env.PI_SUBAGENT_ALLOWED;
250
+ if (!raw) return undefined;
251
+ const list = raw.split(",").map((s) => s.trim()).filter(Boolean);
252
+ return list.length > 0 ? list : undefined;
253
+ })();
254
+
255
+ const availableRoles: Record<string, SubagentRole> = {};
256
+ for (const [name, role] of Object.entries(BUILTIN_ROLES)) {
257
+ if (!ALLOWLIST || ALLOWLIST.includes(name)) {
258
+ availableRoles[name] = role;
259
+ }
260
+ }
261
+
262
+ // Mutable guidelines array — rebuilt in session_start to reflect agentOverrides
263
+ const guidelines: string[] = [];
264
+
265
+ function rebuildGuidelines(roles: Record<string, SubagentRole>): void {
266
+ const entries = Object.entries(roles);
267
+ const exampleLines: string[] = [];
268
+ const decisionLines: string[] = [];
269
+
270
+ for (const [name, role] of entries) {
271
+ // Decision flow
272
+ decisionLines.push(` ${role.decisionTrigger} → delegate(${name})`);
273
+
274
+ // Concrete examples — one line per role with comma-separated examples
275
+ const quotedExamples = role.examples.map((e) => `"${e}"`).join(", ");
276
+ exampleLines.push(` delegate(${name}): ${quotedExamples}`);
277
+ }
278
+
279
+ guidelines.length = 0;
280
+ guidelines.push(
281
+ "WHEN TO DELEGATE — offload substantial work when you only need the result:",
282
+ "",
283
+ "- 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.",
284
+ "- DO NOT delegate simple tasks: a single read, a one-line edit, a basic grep. Just do them yourself.",
285
+ "- DO NOT delegate straightforward file modifications touching 1-2 files. Use edit/write directly.",
286
+ "- Delegation has overhead (spawning a child process). Reserve it for tasks that would genuinely clutter your context with 3+ turns of raw tool output.",
287
+ "",
288
+ "AVAILABLE ROLES:",
289
+ ...entries.map(([name, role]) => ` - ${name}: ${role.description}`),
290
+ "",
291
+ "DECISION FLOW (which role for what):",
292
+ "",
293
+ ...decisionLines,
294
+ "",
295
+ "CONCRETE EXAMPLES of good delegation targets:",
296
+ "",
297
+ ...exampleLines,
298
+ "",
299
+ "For multiple independent substantial tasks, emit multiple delegate calls in one turn — they run in parallel.",
300
+ "Include ALL necessary context — subagents have no access to this conversation.",
301
+ );
302
+ }
303
+
304
+ // Apply agent overrides on top of built-in roles
305
+ function applyAgentOverrides(roles: Record<string, SubagentRole>, overrides: Record<string, any>): void {
306
+ for (const [name, override] of Object.entries(overrides)) {
307
+ if (override.disabled) {
308
+ delete roles[name];
309
+ } else if (roles[name]) {
310
+ roles[name] = { ...roles[name], ...override };
311
+ } else {
312
+ // Custom role — must provide all required fields (validated in session_start)
313
+ roles[name] = override as SubagentRole;
314
+ }
315
+ }
316
+ }
317
+
318
+ // Initial guidelines from built-in roles
319
+ rebuildGuidelines(availableRoles);
320
+
206
321
  pi.on("session_start", async (_event, ctx) => {
207
322
  config = loadSubagentConfig(ctx.cwd);
323
+ applyAgentOverrides(availableRoles, config.agentOverrides);
324
+
325
+ // Validate custom roles (skip built-in roles — they already have all fields)
326
+ const REQUIRED_FIELDS = ["role", "description", "examples", "decisionTrigger", "tools", "systemPrompt"] as const;
327
+ for (const [name, role] of Object.entries(availableRoles)) {
328
+ if (name in BUILTIN_ROLES) continue;
329
+ const missing = REQUIRED_FIELDS.filter((f) => !(f in (role as any)));
330
+ if (missing.length > 0) {
331
+ delete availableRoles[name];
332
+ ctx.ui.notify(
333
+ `[pi-subagent] Custom role "${name}" skipped — missing: ${missing.join(", ")}. Required: ${REQUIRED_FIELDS.join(", ")}.`,
334
+ "error",
335
+ );
336
+ }
337
+ }
338
+
339
+ rebuildGuidelines(availableRoles);
208
340
  });
209
341
 
210
342
  pi.registerTool({
211
343
  name: "delegate",
212
344
  label: "Delegate to subagent",
213
- description: [
214
- "Delegate a task to a specialized subagent with isolated context.",
215
- "Available roles:",
216
- " - explorer: fast code search and navigation (read-only)",
217
- " - reviewer: deep code review with evidence (read-only)",
218
- " - worker: implementation with file editing capabilities",
219
- " - researcher: web research and documentation lookup",
220
- "",
221
- "Progress is shown in real-time via TUI (tool calls, turns, elapsed time).",
222
- "Use Ctrl+O on a completed result to see full details.",
223
- "",
224
- "Note: Subagents only have built-in tools (read, bash, edit, write, grep, glob, find, web_search, fetch_content). They do NOT have access to MCP tools or custom tools from the main session.",
225
- ].join("\n"),
345
+ description: "Offload work to a specialized subagent to keep your own context clean and focused. Prefer this over doing work yourself when a task would generate many tool calls or verbose output. Subagents have isolated context — include all necessary info in the task description.",
346
+ promptSnippet: "Delegate tasks to specialized subagents",
347
+ promptGuidelines: guidelines,
226
348
 
227
349
  parameters: Type.Object({
228
- role: Type.Union(
229
- [
230
- Type.Literal("explorer"),
231
- Type.Literal("reviewer"),
232
- Type.Literal("worker"),
233
- Type.Literal("researcher"),
234
- ],
235
- { description: "Subagent role to use" },
236
- ),
350
+ role: Type.String({ description: "Subagent role to use" }),
237
351
  task: Type.String({ description: "Specific task for the subagent" }),
238
352
  cwd: Type.Optional(Type.String({ description: "Working directory (defaults to current)" })),
239
353
  }),
240
354
 
241
355
  async execute(_toolCallId, params, signal, onUpdate, ctx) {
242
- const roleDef = BUILTIN_ROLES[params.role];
356
+ const roleDef = availableRoles[params.role];
243
357
  if (!roleDef) {
244
358
  return {
245
359
  content: [
246
360
  {
247
361
  type: "text",
248
- text: `Unknown subagent role: ${params.role}. Available: ${Object.keys(BUILTIN_ROLES).join(", ")}`,
362
+ text: `Unknown subagent role: ${params.role}. Available: ${Object.keys(availableRoles).join(", ")}`,
249
363
  },
250
364
  ],
365
+ details: undefined as any,
251
366
  };
252
367
  }
253
368
 
@@ -258,6 +373,7 @@ export default function subagentExtension(pi: ExtensionAPI) {
258
373
  } catch {
259
374
  return {
260
375
  content: [{ type: "text", text: "pi-model-roles is not initialized. Cannot resolve model for subagent." }],
376
+ details: undefined as any,
261
377
  };
262
378
  }
263
379
 
@@ -265,6 +381,7 @@ export default function subagentExtension(pi: ExtensionAPI) {
265
381
  if (!resolved.model) {
266
382
  return {
267
383
  content: [{ type: "text", text: `Role "${roleDef.role}" could not be resolved. Model not available.` }],
384
+ details: undefined as any,
268
385
  };
269
386
  }
270
387
 
@@ -281,6 +398,7 @@ export default function subagentExtension(pi: ExtensionAPI) {
281
398
  output: "",
282
399
  stderr: "",
283
400
  usage: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, cost: 0, contextTokens: 0, turns: 0 },
401
+ activityLog: [],
284
402
  };
285
403
  onUpdate({
286
404
  content: [{ type: "text", text: `${params.role}: running...` }],
@@ -289,10 +407,11 @@ export default function subagentExtension(pi: ExtensionAPI) {
289
407
  }
290
408
 
291
409
  try {
292
- const result = await spawnSubagent(modelRef, params.task, {
410
+ let result = await spawnSubagent(modelRef, params.task, {
293
411
  cwd: params.cwd ?? ctx.cwd,
294
412
  tools: roleDef.tools,
295
413
  systemPrompt: roleDef.systemPrompt,
414
+ subagentRoles: roleDef.subagentRoles,
296
415
  timeoutMs: config.timeoutMs,
297
416
  signal,
298
417
  onProgress: (partial) => {
@@ -316,6 +435,7 @@ export default function subagentExtension(pi: ExtensionAPI) {
316
435
  },
317
436
  model: partial.model,
318
437
  stopReason: partial.stopReason,
438
+ activityLog: partial.activityLog ?? [],
319
439
  };
320
440
  const statusText = `${params.role} ${elapsed}s ${liveResult.usage.turns} turn${liveResult.usage.turns !== 1 ? "s" : ""}`;
321
441
  onUpdate({
@@ -325,6 +445,27 @@ export default function subagentExtension(pi: ExtensionAPI) {
325
445
  },
326
446
  });
327
447
 
448
+ // Retry with fallback role on provider errors (quota, auth, timeout, etc.)
449
+ if ((result.exitCode !== 0 || result.errorMessage) && roleDef.fallbackRole) {
450
+ const isProviderError = /429|quota|rate.?limit|auth|timeout|exhausted|unavailable/i.test(
451
+ (result.stderr || "") + (result.errorMessage || ""),
452
+ );
453
+ if (isProviderError) {
454
+ const fallback = await rolesApi.resolveRoleAsync(roleDef.fallbackRole);
455
+ if (fallback.model) {
456
+ const fbRef = `${fallback.model.provider}/${fallback.model.id}`;
457
+ result = await spawnSubagent(fbRef, params.task, {
458
+ cwd: params.cwd ?? ctx.cwd,
459
+ tools: roleDef.tools,
460
+ systemPrompt: roleDef.systemPrompt,
461
+ subagentRoles: roleDef.subagentRoles,
462
+ timeoutMs: config.timeoutMs,
463
+ signal,
464
+ });
465
+ }
466
+ }
467
+ }
468
+
328
469
  // Generate summary for TUI display
329
470
  if (config.summary.enabled && result.output.trim()) {
330
471
  result.summary = await generateSummary(rolesApi, result.output, config.summary);
@@ -399,7 +540,7 @@ export default function subagentExtension(pi: ExtensionAPI) {
399
540
  } else {
400
541
  icon = theme.fg("success", "\u2713");
401
542
  }
402
- const displayItems = getDisplayItems(r.messages);
543
+ const displayItems = buildDisplayItems(r.activityLog);
403
544
  const finalOutput = getFinalOutput(r.messages);
404
545
  const mdTheme = getMarkdownTheme();
405
546
 
@@ -420,20 +561,21 @@ export default function subagentExtension(pi: ExtensionAPI) {
420
561
  }
421
562
 
422
563
  container.addChild(new Spacer(1));
423
- const toolCalls = displayItems.filter((item) => item.type === "toolCall");
424
- if (toolCalls.length === 0) {
564
+ const activity = displayItems.filter((item) => item.type === "toolCall" || item.type === "thinking");
565
+ if (activity.length === 0) {
425
566
  const runningLabel = isRunning ? "(waiting for first event...)" : "(none)";
426
567
  container.addChild(new Text(theme.fg("muted", runningLabel), 0, 0));
427
568
  } else {
428
- for (const item of toolCalls) {
429
- container.addChild(
430
- new Text(
431
- theme.fg("muted", "\u2192 ") +
432
- formatToolCall(item.name, item.args, theme.fg.bind(theme)),
433
- 0,
434
- 0,
435
- ),
436
- );
569
+ const fg = theme.fg.bind(theme) as (color: string, text: string) => string;
570
+ for (const item of activity) {
571
+ if (item.type === "thinking") {
572
+ container.addChild(new Text(formatThinking(item.status, fg), 0, 0));
573
+ } else {
574
+ const { prefix, color } = statusStyle(item.status, fg);
575
+ container.addChild(
576
+ new Text(prefix + formatToolCall(item.name, item.args, color), 0, 0),
577
+ );
578
+ }
437
579
  }
438
580
  }
439
581
 
@@ -457,11 +599,11 @@ export default function subagentExtension(pi: ExtensionAPI) {
457
599
 
458
600
  if (isRunning) {
459
601
  // Running: show recent tool calls only
460
- const toolCalls = displayItems.filter((item) => item.type === "toolCall");
461
- if (toolCalls.length === 0) {
602
+ const activity = displayItems.filter((item) => item.type === "toolCall" || item.type === "thinking");
603
+ if (activity.length === 0) {
462
604
  text += `\n${theme.fg("muted", "(running...)")}`;
463
605
  } else {
464
- const rendered = renderDisplayItems(toolCalls, 5, theme.fg.bind(theme));
606
+ const rendered = renderDisplayItems(activity, 5, theme.fg.bind(theme) as (color: string, text: string) => string);
465
607
  if (rendered) text += `\n${rendered}`;
466
608
  }
467
609
  } else {
@@ -469,8 +611,9 @@ export default function subagentExtension(pi: ExtensionAPI) {
469
611
  if (r.summary) {
470
612
  text += ` ${theme.fg("dim", "\u00b7")} ${theme.fg("text", r.summary)}`;
471
613
  }
472
- if (isError && r.errorMessage) {
473
- text += `\n${theme.fg("error", `Error: ${r.errorMessage}`)}`;
614
+ if (isError) {
615
+ const errMsg = r.errorMessage || (r.stderr ? r.stderr.trim().split("\n")[0].slice(0, 80) : r.stopReason);
616
+ if (errMsg) text += `\n${theme.fg("error", `Error: ${errMsg}`)}`;
474
617
  }
475
618
  const usageStr = formatUsageStats(r.usage, r.model);
476
619
  if (usageStr) text += `\n${theme.fg("dim", usageStr)}`;
@@ -478,4 +621,59 @@ export default function subagentExtension(pi: ExtensionAPI) {
478
621
  return new Text(text, 0, 0);
479
622
  },
480
623
  });
624
+
625
+ pi.registerCommand("subagent:doctor", {
626
+ description: "Diagnose pi-subagent configuration and dependencies",
627
+ handler: async (_args, ctx) => {
628
+ const lines: string[] = [];
629
+ let allOk = true;
630
+
631
+ // 1. pi executable
632
+ const inv = getPiInvocation(["--version"]);
633
+ lines.push(`[\u2713] pi invocation: ${inv.command} ${inv.args.slice(0, 1).join(" ")}`);
634
+
635
+ // 2. pi-model-roles
636
+ try {
637
+ const api = getModelRolesAPI();
638
+ lines.push("[\u2713] pi-model-roles: loaded");
639
+
640
+ // 3. config
641
+ try {
642
+ const cfg = loadSubagentConfig(ctx.cwd);
643
+ lines.push(`[\u2713] config: timeout=${cfg.timeoutMs}ms summary=${cfg.summary.enabled ? cfg.summary.role : "off"}`);
644
+ } catch {
645
+ lines.push("[\u2717] config: failed to load");
646
+ allOk = false;
647
+ }
648
+
649
+ // 4. roles
650
+ for (const [name, role] of Object.entries(availableRoles)) {
651
+ try {
652
+ const resolved = await api.resolveRoleAsync(role.role);
653
+ if (resolved.model) {
654
+ lines.push(`[\u2713] role ${name}: \u2192 ${resolved.model.provider}/${resolved.model.id}`);
655
+ } else {
656
+ lines.push(`[\u2717] role ${name}: model not resolved (role config: ${role.role})`);
657
+ allOk = false;
658
+ }
659
+ } catch {
660
+ lines.push(`[\u2717] role ${name}: resolution failed`);
661
+ allOk = false;
662
+ }
663
+ }
664
+ } catch {
665
+ lines.push("[\u2717] pi-model-roles: not initialized");
666
+ allOk = false;
667
+ }
668
+
669
+ // 5. ALLOWLIST
670
+ const allowed = process.env.PI_SUBAGENT_ALLOWED;
671
+ if (allowed) {
672
+ lines.push(`[i] PI_SUBAGENT_ALLOWED: ${allowed}`);
673
+ }
674
+
675
+ const summary = allOk ? "All checks passed" : "Some checks failed";
676
+ ctx.ui.notify(`${summary}\n\n${lines.join("\n")}`, "info");
677
+ },
678
+ });
481
679
  }
package/src/roles.ts CHANGED
@@ -11,47 +11,99 @@ import type { SubagentRole } from "./types.ts";
11
11
  export const BUILTIN_ROLES: Record<string, SubagentRole> = {
12
12
  explorer: {
13
13
  role: "fast",
14
- tools: ["read", "bash", "find", "grep", "glob"],
14
+ fallbackRole: "default",
15
+ description: "READ-ONLY codebase exploration — locate files, grep symbols, trace imports, explain structures. Tools: read, find, grep, glob. NO bash, NO edits, NO web access.",
16
+ examples: [
17
+ "Find where auth middleware is implemented",
18
+ "Map the routing structure",
19
+ ],
20
+ decisionTrigger: "Task finds or maps code without touch?",
21
+ tools: ["read", "find", "grep", "glob"],
15
22
  systemPrompt: [
16
- "You are a fast code explorer. Investigate the codebase and answer the task query.",
17
- "You must NOT edit any files.",
23
+ "Fast code explorer. You have READ-ONLY tools only no commands, no edits.",
24
+ "Grep/find to locate read key sections only → identify types, interfaces, functions.",
25
+ "Never read entire files. Target specific line ranges.",
18
26
  "",
19
- "Output accurately and concisely. State findings directly with file paths and line numbers.",
20
- "Keep the final output as short as possible while preserving all actionable information.",
27
+ "Output format (keep each section brief):",
28
+ "## Files: file paths with line ranges and one-line descriptions",
29
+ "## Findings: key types/functions with minimal code snippets",
30
+ "## Summary: direct answer to the task question",
21
31
  ].join("\n"),
22
32
  },
23
33
  reviewer: {
24
34
  role: "heavy",
35
+ fallbackRole: "default",
36
+ description: "READ-ONLY code review & analysis — audit code, assess architecture, review diffs. Tools: read, bash, grep, glob. Has bash (git diff/log, test runs). NO edits, NO web access.",
37
+ examples: [
38
+ "Review the error handling in src/api/ for security issues",
39
+ "Audit this PR diff for performance regressions",
40
+ ],
41
+ decisionTrigger: "Task audits or reviews code quality?",
25
42
  tools: ["read", "bash", "grep", "glob"],
26
43
  systemPrompt: [
27
- "You are a senior code reviewer. Inspect code for correctness, maintainability, and security issues.",
28
- "You must NOT edit any files.",
44
+ "Senior code reviewer. READ-ONLY you must NOT modify any file.",
45
+ "bash is for read-only commands only (git diff/log/show, test runs). Never use sed, tee, echo >, or any write command.",
46
+ "Provide evidence-backed findings with file:line references.",
29
47
  "",
30
- "Provide evidence-backed findings with file/line references.",
31
- 'Use bash only for read-only commands: git diff, git log, git show.',
32
- "",
33
- "Output accurately and concisely. Prioritize critical issues first.",
48
+ "Output format (prioritize critical issues first):",
49
+ "## Issues: severity + file:line + description + suggested fix",
50
+ "## Observations: notable patterns or design concerns",
51
+ "## Summary: overall assessment in 1-2 sentences",
34
52
  ].join("\n"),
35
53
  },
36
54
  worker: {
37
55
  role: "default",
38
- tools: ["read", "bash", "edit", "write", "grep", "glob"],
56
+ description: "the ONLY role that can MODIFY files — edit, write, refactor, fix, implement. Tools: read, bash, edit, write, grep, glob, delegate. Can delegate to explorer/researcher.",
57
+ examples: [
58
+ "Rename all snake_case fields to camelCase",
59
+ "Add input validation to POST /login",
60
+ ],
61
+ decisionTrigger: "Task modifies files?",
62
+ tools: ["read", "bash", "edit", "write", "grep", "glob", "delegate"],
63
+ subagentRoles: ["explorer", "researcher"],
39
64
  systemPrompt: [
40
- "You are an implementation worker. Follow the given plan precisely.",
41
- "Make minimal, focused changes. Validate your work after each change.",
65
+ "Implementation worker. Work autonomously all context is in the task description.",
66
+ "Always read a file before editing it. Make minimal, focused changes.",
67
+ "After each change, validate: run tests, check syntax, verify behavior.",
68
+ "",
69
+ "## Protecting your context",
70
+ "You have a `delegate` tool. Use it to offload exploration and research:",
71
+ "- delegate(role=explorer) when you need to map unfamiliar code before editing",
72
+ "- delegate(role=researcher) when you need external docs or library references",
73
+ "Don't delegate tasks you can do with a single read or grep.",
42
74
  "",
43
- "When finished, report what you changed and what validation you ran.",
44
- "Output accurately and concisely summarize changes, don't repeat full diffs.",
75
+ "Output format (be brief summarize, don't paste full diffs):",
76
+ "## Changes: list each file touched and what changed",
77
+ "## Verification: what you ran to confirm correctness",
45
78
  ].join("\n"),
46
79
  },
47
80
  researcher: {
48
81
  role: "fast",
49
- tools: ["web_search", "fetch_content", "read"],
82
+ fallbackRole: "default",
83
+ description: "the ONLY role with WEB ACCESS — search docs, fetch pages, analyze GitHub repos. Tools: web_search, fetch_content, read, bash, delegate. Can clone repos & delegate to explorer.",
84
+ examples: [
85
+ "Find the React 19 migration guide",
86
+ "Check GitHub issue #1234 for context",
87
+ ],
88
+ decisionTrigger: "Task searches web or GitHub?",
89
+ tools: ["web_search", "fetch_content", "read", "bash", "delegate"],
90
+ subagentRoles: ["explorer"],
50
91
  systemPrompt: [
51
- "You are a web researcher. Find relevant documentation, examples, and best practices.",
92
+ "Web researcher. Search with varied angles, prefer official docs over blogs.",
93
+ "If first results are insufficient, refine queries and search again.",
94
+ "",
95
+ "## GitHub repo analysis",
96
+ "When the task requires analyzing a GitHub repo:",
97
+ "1. git clone the repo into PI_SUBAGENT_TMPDIR (must exist)",
98
+ "2. Use `delegate` with role=explorer to investigate the cloned codebase — pass the repo path and the research question",
99
+ "3. Combine explorer findings with any web search results",
100
+ "",
101
+ "bash is for git clone and read-only commands only. Never modify files.",
52
102
  "",
53
- "Return concise summaries with source links.",
54
- "Output accurately and concisely state key findings first, then supporting details if needed.",
103
+ "Output format:",
104
+ "## Answer: direct answer to the question (2-3 sentences)",
105
+ "## Sources: list of URLs used",
106
+ "## Gaps: what could not be answered",
55
107
  ].join("\n"),
56
108
  },
57
109
  };
package/src/spawn.ts CHANGED
@@ -10,28 +10,106 @@ import { spawn } from "node:child_process";
10
10
  import * as fs from "node:fs";
11
11
  import * as os from "node:os";
12
12
  import * as path from "node:path";
13
+ import { fileURLToPath } from "node:url";
13
14
  import type { SubagentMessage, SubagentResult } from "./types.ts";
14
15
 
15
- /** Determine how to invoke pi.
16
+ /** Maximum task length before writing to a temp file (avoids CLI arg limits). */
17
+ const TASK_CHAR_LIMIT = 8000;
18
+
19
+ /** Maximum output characters returned to the main model. Larger outputs are truncated. */
20
+ const MAX_OUTPUT_CHARS = 50_000;
21
+
22
+ const PI_CODING_AGENT_PACKAGE = "@earendil-works/pi-coding-agent";
23
+
24
+ function isRunnableScript(filePath: string): boolean {
25
+ try {
26
+ if (!fs.existsSync(filePath)) return false;
27
+ return /\.(?:mjs|cjs|js)$/i.test(filePath);
28
+ } catch {
29
+ return false;
30
+ }
31
+ }
32
+
33
+ function findPiPackageRootFromEntry(entryPoint: string): string | undefined {
34
+ let dir = path.dirname(entryPoint);
35
+ while (dir !== path.dirname(dir)) {
36
+ const pkgPath = path.join(dir, "package.json");
37
+ if (fs.existsSync(pkgPath)) {
38
+ try {
39
+ const pkg = JSON.parse(fs.readFileSync(pkgPath, "utf-8")) as { name?: unknown };
40
+ if (pkg.name === PI_CODING_AGENT_PACKAGE) return dir;
41
+ } catch {
42
+ /* ignore */
43
+ }
44
+ }
45
+ dir = path.dirname(dir);
46
+ }
47
+ return undefined;
48
+ }
49
+
50
+ function resolveWindowsPiCliScript(args: string[]): { command: string; args: string[] } | undefined {
51
+ // Strategy 1: Use process.argv[1] if it's a runnable script
52
+ // (works when pi is run via `bun pi` or `bunx pi` — argv[1] is the real CLI path)
53
+ const argv1 = process.argv[1];
54
+ if (argv1) {
55
+ const argvPath = path.isAbsolute(argv1) ? argv1 : path.resolve(argv1);
56
+ if (isRunnableScript(argvPath)) {
57
+ return { command: process.execPath, args: [argvPath, ...args] };
58
+ }
59
+ }
60
+
61
+ // Strategy 2: Resolve pi-coding-agent package via import.meta.resolve,
62
+ // then read the bin field from its package.json
63
+ try {
64
+ const resolved = fileURLToPath(import.meta.resolve(PI_CODING_AGENT_PACKAGE));
65
+ const root = findPiPackageRootFromEntry(resolved);
66
+ if (root) {
67
+ const pkgPath = path.join(root, "package.json");
68
+ const pkg = JSON.parse(fs.readFileSync(pkgPath, "utf-8")) as {
69
+ bin?: string | Record<string, string>;
70
+ };
71
+ const binField = pkg.bin;
72
+ const binPath =
73
+ typeof binField === "string"
74
+ ? binField
75
+ : binField?.pi ?? Object.values(binField ?? {})[0];
76
+ if (binPath) {
77
+ const candidate = path.resolve(root, binPath);
78
+ if (isRunnableScript(candidate)) {
79
+ return { command: process.execPath, args: [candidate, ...args] };
80
+ }
81
+ }
82
+ }
83
+ } catch {
84
+ /* fall through */
85
+ }
86
+
87
+ return undefined;
88
+ }
89
+
90
+ /**
91
+ * Determine how to invoke pi.
16
92
  *
17
- * Always uses the `pi` CLI command. On Windows with Bun-compiled pi,
18
- * process.execPath returns a virtual path (B:/~BUN/root/pi.exe) that
19
- * leaks into the child model's context and causes it to run stray
20
- * diagnostic commands. Using the `pi` command from PATH avoids this.
93
+ * On Windows, attempts to find the pi CLI script via:
94
+ * 1. process.argv[1] (when run via `bun pi` or `bunx pi`)
95
+ * 2. import.meta.resolve of @earendil-works/pi-coding-agent bin field
96
+ * If found, spawns process.execPath (bun) with the script path.
97
+ * Falls back to `pi` from PATH if neither works.
98
+ *
99
+ * On non-Windows, always uses the `pi` CLI command from PATH.
100
+ *
101
+ * This avoids the standalone compiled pi.exe's process.execPath
102
+ * (virtual Bun path like B:/~BUN/root/pi.exe) ever being passed
103
+ * to the child process, while still working when `pi` is not in PATH.
21
104
  */
22
- function getPiInvocation(args: string[]): { command: string; args: string[] } {
105
+ export function getPiInvocation(args: string[]): { command: string; args: string[] } {
106
+ if (process.platform === "win32") {
107
+ const winResult = resolveWindowsPiCliScript(args);
108
+ if (winResult) return winResult;
109
+ }
23
110
  return { command: "pi", args };
24
111
  }
25
112
 
26
- /** Write a system prompt to a temp file for --append-system-prompt. */
27
- async function writeTempPromptFile(prefix: string, content: string): Promise<{ dir: string; filePath: string }> {
28
- const tmpDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), "pi-subagent-"));
29
- const safeName = prefix.replace(/[^\w.-]+/g, "_");
30
- const filePath = path.join(tmpDir, `prompt-${safeName}.md`);
31
- await fs.promises.writeFile(filePath, content, { encoding: "utf-8", mode: 0o600 });
32
- return { dir: tmpDir, filePath };
33
- }
34
-
35
113
  /**
36
114
  * Spawn a pi child process with the given model and configuration.
37
115
  * Fires onProgress on each JSON event for streaming TUI updates.
@@ -48,6 +126,7 @@ export async function spawnSubagent(
48
126
  cwd?: string;
49
127
  tools?: string[];
50
128
  systemPrompt?: string;
129
+ subagentRoles?: string[];
51
130
  timeoutMs?: number;
52
131
  signal?: AbortSignal;
53
132
  onProgress?: (update: Partial<SubagentResult>) => void;
@@ -61,6 +140,7 @@ export async function spawnSubagent(
61
140
  output: "",
62
141
  stderr: "",
63
142
  usage: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, cost: 0, contextTokens: 0, turns: 0 },
143
+ activityLog: [],
64
144
  };
65
145
 
66
146
  let tmpDir: string | null = null;
@@ -74,14 +154,23 @@ export async function spawnSubagent(
74
154
  args.push("--tools", options.tools.join(","));
75
155
  }
76
156
 
77
- if (options.systemPrompt?.trim()) {
78
- const tmp = await writeTempPromptFile("delegate", options.systemPrompt);
79
- tmpDir = tmp.dir;
80
- tmpFile = tmp.filePath;
81
- args.push("--append-system-prompt", tmpFile);
82
- }
157
+ // Always create temp dir — used for prompt file, long task file, and as PI_SUBAGENT_TMPDIR for subagent work (e.g. git clone)
158
+ tmpDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), "pi-subagent-"));
83
159
 
84
- args.push(`Task: ${task}`);
160
+ const promptContent = options.systemPrompt?.trim()
161
+ ? options.systemPrompt + `\n\nPI_SUBAGENT_TMPDIR=${tmpDir}`
162
+ : `PI_SUBAGENT_TMPDIR=${tmpDir}`;
163
+ tmpFile = path.join(tmpDir, "prompt.md");
164
+ await fs.promises.writeFile(tmpFile, promptContent, { encoding: "utf-8", mode: 0o600 });
165
+ args.push("--append-system-prompt", tmpFile);
166
+
167
+ if (task.length > TASK_CHAR_LIMIT) {
168
+ const taskPath = path.join(tmpDir, "task.md");
169
+ await fs.promises.writeFile(taskPath, task, { encoding: "utf-8", mode: 0o600 });
170
+ args.push(`@${taskPath}`);
171
+ } else {
172
+ args.push(`Task: ${task}`);
173
+ }
85
174
 
86
175
  // Spawn process
87
176
  const invocation = getPiInvocation(args);
@@ -95,9 +184,12 @@ export async function spawnSubagent(
95
184
  usage: { ...result.usage },
96
185
  model: result.model,
97
186
  stopReason: result.stopReason,
187
+ activityLog: result.activityLog.map((a) => ({ ...a })),
98
188
  });
99
189
  };
100
190
 
191
+ let thinkingCounter = 0;
192
+
101
193
  const processLine = (line: string) => {
102
194
  if (!line.trim()) return;
103
195
  let event: any;
@@ -137,15 +229,63 @@ export async function spawnSubagent(
137
229
  emitProgress();
138
230
  }
139
231
 
140
- if (event.type === "tool_result_end" && event.message) {
141
- result.messages.push(event.message as SubagentMessage);
232
+ // Activity log: track thinking blocks and tool calls in arrival order.
233
+ // Both update in place so the TUI reflects real-time state.
234
+ if (event.type === "tool_execution_start" && event.toolCallId) {
235
+ result.activityLog.push({
236
+ kind: "toolCall",
237
+ id: event.toolCallId,
238
+ status: "running",
239
+ toolName: event.toolName,
240
+ args: event.args ?? {},
241
+ });
142
242
  emitProgress();
243
+ } else if (event.type === "tool_execution_end" && event.toolCallId) {
244
+ const entry = result.activityLog.find((a) => a.id === event.toolCallId);
245
+ if (entry) entry.status = event.isError ? "failed" : "done";
246
+ emitProgress();
247
+ }
248
+
249
+ // Thinking-block lifecycle: pi wraps thinking_start/end inside
250
+ // message_update.assistantMessageEvent. These arrive BEFORE message_end,
251
+ // so we can't rely on messages[] to show real-time thinking state —
252
+ // register them in the activity log directly.
253
+ const aev = event.assistantMessageEvent;
254
+ if (event.type === "message_update" && aev) {
255
+ if (aev.type === "thinking_start") {
256
+ result.activityLog.push({
257
+ kind: "thinking",
258
+ id: `thinking-${thinkingCounter++}`,
259
+ status: "running",
260
+ });
261
+ emitProgress();
262
+ } else if (aev.type === "thinking_end") {
263
+ // Mark the most recent still-running thinking block as done.
264
+ for (let i = result.activityLog.length - 1; i >= 0; i--) {
265
+ if (result.activityLog[i].kind === "thinking" && result.activityLog[i].status === "running") {
266
+ result.activityLog[i].status = "done";
267
+ break;
268
+ }
269
+ }
270
+ emitProgress();
271
+ }
143
272
  }
144
273
  };
145
274
 
275
+ // Build env with optional subagent allowlist and tmpdir for researcher role
276
+ const childEnv: NodeJS.ProcessEnv = { ...process.env };
277
+ if (options.subagentRoles && options.subagentRoles.length > 0) {
278
+ childEnv.PI_SUBAGENT_ALLOWED = options.subagentRoles.join(",");
279
+ }
280
+ // Expose tmpdir as env var so subagent bash commands (e.g. git clone) can use it
281
+ childEnv.PI_SUBAGENT_TMPDIR = tmpDir;
282
+
283
+ let timeoutHandle: ReturnType<typeof setTimeout> | undefined;
284
+
146
285
  const exitCode = await new Promise<number>((resolve) => {
147
286
  const proc = spawn(invocation.command, invocation.args, {
148
287
  cwd: options.cwd,
288
+ env: childEnv,
149
289
  shell: false,
150
290
  stdio: ["ignore", "pipe", "pipe"],
151
291
  });
@@ -162,6 +302,7 @@ export async function spawnSubagent(
162
302
  });
163
303
 
164
304
  proc.on("close", (code) => {
305
+ if (timeoutHandle) clearTimeout(timeoutHandle);
165
306
  if (buffer.trim()) processLine(buffer);
166
307
  resolve(code ?? 0);
167
308
  });
@@ -185,7 +326,7 @@ export async function spawnSubagent(
185
326
 
186
327
  // Handle timeout
187
328
  if (options.timeoutMs && options.timeoutMs > 0) {
188
- setTimeout(() => {
329
+ timeoutHandle = setTimeout(() => {
189
330
  if (!proc.killed) {
190
331
  proc.kill("SIGTERM");
191
332
  setTimeout(() => {
@@ -198,10 +339,16 @@ export async function spawnSubagent(
198
339
 
199
340
  result.exitCode = exitCode;
200
341
  if (wasAborted) throw new Error("Subagent was aborted");
342
+
343
+ // Truncate large outputs: keep head (findings) + tail (summary), drop middle
344
+ if (result.output.length > MAX_OUTPUT_CHARS) {
345
+ const head = result.output.slice(0, 30_000);
346
+ const tail = result.output.slice(-(MAX_OUTPUT_CHARS - 30_050));
347
+ result.output = `[Output truncated — ${result.output.length} chars total]\n\n${head}\n\n... [truncated] ...\n\n${tail}`;
348
+ }
201
349
  } finally {
202
- // Cleanup temp files
203
- if (tmpFile) try { fs.unlinkSync(tmpFile); } catch { /* ignore */ }
204
- if (tmpDir) try { fs.rmdirSync(tmpDir); } catch { /* ignore */ }
350
+ // Cleanup temp directory and all contents
351
+ if (tmpDir) try { fs.rmSync(tmpDir, { recursive: true, force: true }); } catch { /* ignore */ }
205
352
  }
206
353
 
207
354
  return result;
package/src/types.ts CHANGED
@@ -6,6 +6,12 @@
6
6
  export interface SubagentConfig {
7
7
  timeoutMs: number;
8
8
  summary: SubagentSummaryConfig;
9
+ /**
10
+ * Per-role overrides from settings.json. Keyed by role name.
11
+ * - Override built-in roles: provide fields to merge.
12
+ * - Disable built-in roles: set `disabled: true`.
13
+ */
14
+ agentOverrides: Record<string, Partial<SubagentRole> & { disabled?: boolean }>;
9
15
  }
10
16
 
11
17
  export interface SubagentSummaryConfig {
@@ -16,16 +22,41 @@ export interface SubagentSummaryConfig {
16
22
  export const DEFAULT_CONFIG: SubagentConfig = {
17
23
  timeoutMs: 300_000,
18
24
  summary: { role: "utility", enabled: true },
25
+ agentOverrides: {},
19
26
  };
20
27
 
21
28
  /** A built-in subagent role definition. */
22
29
  export interface SubagentRole {
23
30
  /** pi-model-roles role name to use for this subagent */
24
31
  role: string;
32
+ /** One-line description for the LLM prompt — what this role does and what tools it has */
33
+ description: string;
34
+ /** Example tasks to show in CONCRETE EXAMPLES section */
35
+ examples: string[];
36
+ /** Decision flow trigger phrase, e.g. "Task modifies files?" */
37
+ decisionTrigger: string;
25
38
  /** System prompt for the subagent */
26
39
  systemPrompt: string;
27
40
  /** Tools available to this subagent */
28
41
  tools: string[];
42
+ /** If this role has `delegate`, restrict which roles it may spawn. undefined = no restriction. */
43
+ subagentRoles?: string[];
44
+ /** Fallback pi-model-roles role name when this role's model is unavailable (provider error). Defaults to "default". */
45
+ fallbackRole?: string;
46
+ }
47
+
48
+ /** Status of an individual tool call within a subagent run. */
49
+ export type ToolStatus = "running" | "done" | "failed";
50
+
51
+ /** A single entry in the real-time activity log (thinking block or tool call). */
52
+ export interface ActivityEntry {
53
+ kind: "thinking" | "toolCall";
54
+ /** Synthetic id (thinking-N) or the toolCallId from the event stream. */
55
+ id: string;
56
+ status: ToolStatus;
57
+ /** Tool name + args (toolCall only). */
58
+ toolName?: string;
59
+ args?: Record<string, any>;
29
60
  }
30
61
 
31
62
  /** Usage statistics from a subagent execution. */
@@ -87,6 +118,8 @@ export interface SubagentResult {
87
118
  stopReason?: string;
88
119
  /** Error message if failed */
89
120
  errorMessage?: string;
121
+ /** Real-time activity log: thinking blocks and tool calls in arrival order. */
122
+ activityLog: ActivityEntry[];
90
123
  }
91
124
 
92
125
  /** TUI details structure passed via tool result details. */