@tintinweb/pi-subagents 0.4.9 → 0.4.11

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (44) hide show
  1. package/.github/workflows/ci.yml +21 -0
  2. package/CHANGELOG.md +18 -0
  3. package/README.md +11 -11
  4. package/biome.json +26 -0
  5. package/dist/agent-manager.d.ts +18 -4
  6. package/dist/agent-manager.js +111 -9
  7. package/dist/agent-runner.d.ts +10 -6
  8. package/dist/agent-runner.js +80 -26
  9. package/dist/agent-types.d.ts +10 -0
  10. package/dist/agent-types.js +23 -1
  11. package/dist/cross-extension-rpc.d.ts +30 -0
  12. package/dist/cross-extension-rpc.js +33 -0
  13. package/dist/custom-agents.js +36 -8
  14. package/dist/index.js +335 -66
  15. package/dist/memory.d.ts +49 -0
  16. package/dist/memory.js +151 -0
  17. package/dist/output-file.d.ts +17 -0
  18. package/dist/output-file.js +66 -0
  19. package/dist/prompts.d.ts +12 -1
  20. package/dist/prompts.js +15 -3
  21. package/dist/skill-loader.d.ts +19 -0
  22. package/dist/skill-loader.js +67 -0
  23. package/dist/types.d.ts +45 -1
  24. package/dist/ui/agent-widget.d.ts +21 -0
  25. package/dist/ui/agent-widget.js +205 -127
  26. package/dist/ui/conversation-viewer.d.ts +2 -2
  27. package/dist/ui/conversation-viewer.js +2 -2
  28. package/dist/ui/conversation-viewer.test.d.ts +1 -0
  29. package/dist/ui/conversation-viewer.test.js +254 -0
  30. package/dist/worktree.d.ts +36 -0
  31. package/dist/worktree.js +139 -0
  32. package/package.json +7 -2
  33. package/src/agent-manager.ts +7 -5
  34. package/src/agent-runner.ts +24 -19
  35. package/src/agent-types.ts +5 -5
  36. package/src/custom-agents.ts +4 -4
  37. package/src/index.ts +54 -33
  38. package/src/memory.ts +2 -2
  39. package/src/output-file.ts +1 -1
  40. package/src/skill-loader.ts +1 -1
  41. package/src/types.ts +3 -1
  42. package/src/ui/agent-widget.ts +18 -2
  43. package/src/ui/conversation-viewer.ts +4 -4
  44. package/src/worktree.ts +2 -2
package/dist/index.js CHANGED
@@ -9,18 +9,20 @@
9
9
  * Commands:
10
10
  * /agents — Interactive agent management menu
11
11
  */
12
- import { existsSync, mkdirSync, unlinkSync, readFileSync } from "node:fs";
13
- import { join } from "node:path";
12
+ import { existsSync, mkdirSync, readFileSync, unlinkSync } from "node:fs";
14
13
  import { homedir } from "node:os";
14
+ import { join } from "node:path";
15
15
  import { Text } from "@mariozechner/pi-tui";
16
16
  import { Type } from "@sinclair/typebox";
17
17
  import { AgentManager } from "./agent-manager.js";
18
- import { steerAgent, getAgentConversation, getDefaultMaxTurns, setDefaultMaxTurns, getGraceTurns, setGraceTurns } from "./agent-runner.js";
19
- import { GroupJoinManager } from "./group-join.js";
20
- import { getAvailableTypes, getAllTypes, getDefaultAgentNames, getUserAgentNames, getAgentConfig, resolveType, registerAgents, BUILTIN_TOOL_NAMES } from "./agent-types.js";
18
+ import { getAgentConversation, getDefaultMaxTurns, getGraceTurns, setDefaultMaxTurns, setGraceTurns, steerAgent } from "./agent-runner.js";
19
+ import { BUILTIN_TOOL_NAMES, getAgentConfig, getAllTypes, getAvailableTypes, getDefaultAgentNames, getUserAgentNames, registerAgents, resolveType } from "./agent-types.js";
20
+ import { registerRpcHandlers } from "./cross-extension-rpc.js";
21
21
  import { loadCustomAgents } from "./custom-agents.js";
22
+ import { GroupJoinManager } from "./group-join.js";
22
23
  import { resolveModel } from "./model-resolver.js";
23
- import { AgentWidget, SPINNER, formatTokens, formatMs, formatDuration, getDisplayName, getPromptModeLabel, describeActivity, } from "./ui/agent-widget.js";
24
+ import { createOutputFilePath, streamToOutputFile, writeInitialEntry } from "./output-file.js";
25
+ import { AgentWidget, describeActivity, formatDuration, formatMs, formatTokens, formatTurns, getDisplayName, getPromptModeLabel, SPINNER, } from "./ui/agent-widget.js";
24
26
  // ---- Shared helpers ----
25
27
  /** Tool execute return value for a text response. */
26
28
  function textResult(msg, details) {
@@ -41,8 +43,8 @@ function safeFormatTokens(session) {
41
43
  * Create an AgentActivity state and spawn callbacks for tracking tool usage.
42
44
  * Used by both foreground and background paths to avoid duplication.
43
45
  */
44
- function createActivityTracker(onStreamUpdate) {
45
- const state = { activeTools: new Map(), toolUses: 0, tokens: "", responseText: "", session: undefined };
46
+ function createActivityTracker(maxTurns, onStreamUpdate) {
47
+ const state = { activeTools: new Map(), toolUses: 0, turnCount: 1, maxTurns, tokens: "", responseText: "", session: undefined };
46
48
  const callbacks = {
47
49
  onToolActivity: (activity) => {
48
50
  if (activity.type === "start") {
@@ -64,6 +66,10 @@ function createActivityTracker(onStreamUpdate) {
64
66
  state.responseText = fullText;
65
67
  onStreamUpdate?.();
66
68
  },
69
+ onTurnEnd: (turnCount) => {
70
+ state.turnCount = turnCount;
71
+ onStreamUpdate?.();
72
+ },
67
73
  onSessionCreated: (session) => {
68
74
  state.session = session;
69
75
  },
@@ -89,12 +95,47 @@ function getStatusNote(status) {
89
95
  default: return "";
90
96
  }
91
97
  }
98
+ /** Escape XML special characters to prevent injection in structured notifications. */
99
+ function escapeXml(s) {
100
+ return s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
101
+ }
102
+ /** Format a structured task notification matching Claude Code's <task-notification> XML. */
103
+ function formatTaskNotification(record, resultMaxLen) {
104
+ const status = getStatusLabel(record.status, record.error);
105
+ const durationMs = record.completedAt ? record.completedAt - record.startedAt : 0;
106
+ let totalTokens = 0;
107
+ try {
108
+ if (record.session) {
109
+ const stats = record.session.getSessionStats();
110
+ totalTokens = stats.tokens?.total ?? 0;
111
+ }
112
+ }
113
+ catch { /* session stats unavailable */ }
114
+ const resultPreview = record.result
115
+ ? record.result.length > resultMaxLen
116
+ ? record.result.slice(0, resultMaxLen) + "\n...(truncated, use get_subagent_result for full output)"
117
+ : record.result
118
+ : "No output.";
119
+ return [
120
+ `<task-notification>`,
121
+ `<task-id>${record.id}</task-id>`,
122
+ record.toolCallId ? `<tool-use-id>${escapeXml(record.toolCallId)}</tool-use-id>` : null,
123
+ record.outputFile ? `<output-file>${escapeXml(record.outputFile)}</output-file>` : null,
124
+ `<status>${escapeXml(status)}</status>`,
125
+ `<summary>Agent "${escapeXml(record.description)}" ${record.status}</summary>`,
126
+ `<result>${escapeXml(resultPreview)}</result>`,
127
+ `<usage><total_tokens>${totalTokens}</total_tokens><tool_uses>${record.toolUses}</tool_uses><duration_ms>${durationMs}</duration_ms></usage>`,
128
+ `</task-notification>`,
129
+ ].filter(Boolean).join('\n');
130
+ }
92
131
  /** Build AgentDetails from a base + record-specific fields. */
93
- function buildDetails(base, record, overrides) {
132
+ function buildDetails(base, record, activity, overrides) {
94
133
  return {
95
134
  ...base,
96
135
  toolUses: record.toolUses,
97
136
  tokens: safeFormatTokens(record.session),
137
+ turnCount: activity?.turnCount,
138
+ maxTurns: activity?.maxTurns,
98
139
  durationMs: (record.completedAt ?? Date.now()) - record.startedAt,
99
140
  status: record.status,
100
141
  agentId: record.id,
@@ -102,7 +143,78 @@ function buildDetails(base, record, overrides) {
102
143
  ...overrides,
103
144
  };
104
145
  }
146
+ /** Build notification details for the custom message renderer. */
147
+ function buildNotificationDetails(record, resultMaxLen, activity) {
148
+ let totalTokens = 0;
149
+ try {
150
+ if (record.session)
151
+ totalTokens = record.session.getSessionStats().tokens?.total ?? 0;
152
+ }
153
+ catch { }
154
+ return {
155
+ id: record.id,
156
+ description: record.description,
157
+ status: record.status,
158
+ toolUses: record.toolUses,
159
+ turnCount: activity?.turnCount ?? 0,
160
+ maxTurns: activity?.maxTurns,
161
+ totalTokens,
162
+ durationMs: record.completedAt ? record.completedAt - record.startedAt : 0,
163
+ outputFile: record.outputFile,
164
+ error: record.error,
165
+ resultPreview: record.result
166
+ ? record.result.length > resultMaxLen
167
+ ? record.result.slice(0, resultMaxLen) + "…"
168
+ : record.result
169
+ : "No output.",
170
+ };
171
+ }
105
172
  export default function (pi) {
173
+ // ---- Register custom notification renderer ----
174
+ pi.registerMessageRenderer("subagent-notification", (message, { expanded }, theme) => {
175
+ const d = message.details;
176
+ if (!d)
177
+ return undefined;
178
+ function renderOne(d) {
179
+ const isError = d.status === "error" || d.status === "stopped" || d.status === "aborted";
180
+ const icon = isError ? theme.fg("error", "✗") : theme.fg("success", "✓");
181
+ const statusText = isError ? d.status
182
+ : d.status === "steered" ? "completed (steered)"
183
+ : "completed";
184
+ // Line 1: icon + agent description + status
185
+ let line = `${icon} ${theme.bold(d.description)} ${theme.fg("dim", statusText)}`;
186
+ // Line 2: stats
187
+ const parts = [];
188
+ if (d.turnCount > 0)
189
+ parts.push(formatTurns(d.turnCount, d.maxTurns));
190
+ if (d.toolUses > 0)
191
+ parts.push(`${d.toolUses} tool use${d.toolUses === 1 ? "" : "s"}`);
192
+ if (d.totalTokens > 0)
193
+ parts.push(formatTokens(d.totalTokens));
194
+ if (d.durationMs > 0)
195
+ parts.push(formatMs(d.durationMs));
196
+ if (parts.length) {
197
+ line += "\n " + parts.map(p => theme.fg("dim", p)).join(" " + theme.fg("dim", "·") + " ");
198
+ }
199
+ // Line 3: result preview (collapsed) or full (expanded)
200
+ if (expanded) {
201
+ const lines = d.resultPreview.split("\n").slice(0, 30);
202
+ for (const l of lines)
203
+ line += "\n" + theme.fg("dim", ` ${l}`);
204
+ }
205
+ else {
206
+ const preview = d.resultPreview.split("\n")[0]?.slice(0, 80) ?? "";
207
+ line += "\n " + theme.fg("dim", `⎿ ${preview}`);
208
+ }
209
+ // Line 4: output file link (if present)
210
+ if (d.outputFile) {
211
+ line += "\n " + theme.fg("muted", `transcript: ${d.outputFile}`);
212
+ }
213
+ return line;
214
+ }
215
+ const all = [d, ...(d.others ?? [])];
216
+ return new Text(all.map(renderOne).join("\n"), 0, 0);
217
+ });
106
218
  /** Reload agents from .pi/agents/*.md and merge with defaults (called on init and each Agent invocation). */
107
219
  const reloadCustomAgents = () => {
108
220
  const userAgents = loadCustomAgents(process.cwd());
@@ -112,60 +224,120 @@ export default function (pi) {
112
224
  reloadCustomAgents();
113
225
  // ---- Agent activity tracking + widget ----
114
226
  const agentActivity = new Map();
227
+ // ---- Cancellable pending notifications ----
228
+ // Holds notifications briefly so get_subagent_result can cancel them
229
+ // before they reach pi.sendMessage (fire-and-forget).
230
+ const pendingNudges = new Map();
231
+ const NUDGE_HOLD_MS = 200;
232
+ function scheduleNudge(key, send, delay = NUDGE_HOLD_MS) {
233
+ cancelNudge(key);
234
+ pendingNudges.set(key, setTimeout(() => {
235
+ pendingNudges.delete(key);
236
+ send();
237
+ }, delay));
238
+ }
239
+ function cancelNudge(key) {
240
+ const timer = pendingNudges.get(key);
241
+ if (timer != null) {
242
+ clearTimeout(timer);
243
+ pendingNudges.delete(key);
244
+ }
245
+ }
115
246
  // ---- Individual nudge helper (async join mode) ----
247
+ function emitIndividualNudge(record) {
248
+ if (record.resultConsumed)
249
+ return; // re-check at send time
250
+ const notification = formatTaskNotification(record, 500);
251
+ const footer = record.outputFile ? `\nFull transcript available at: ${record.outputFile}` : '';
252
+ pi.sendMessage({
253
+ customType: "subagent-notification",
254
+ content: notification + footer,
255
+ display: true,
256
+ details: buildNotificationDetails(record, 500, agentActivity.get(record.id)),
257
+ }, { deliverAs: "followUp" });
258
+ }
116
259
  function sendIndividualNudge(record) {
117
- const displayName = getDisplayName(record.type);
118
- const duration = formatDuration(record.startedAt, record.completedAt);
119
- const status = getStatusLabel(record.status, record.error);
120
- const resultPreview = record.result
121
- ? record.result.length > 500
122
- ? record.result.slice(0, 500) + "\n...(truncated, use get_subagent_result for full output)"
123
- : record.result
124
- : "No output.";
125
260
  agentActivity.delete(record.id);
126
261
  widget.markFinished(record.id);
127
- const tokens = safeFormatTokens(record.session);
128
- const toolStats = tokens ? `Tool uses: ${record.toolUses} | ${tokens}` : `Tool uses: ${record.toolUses}`;
129
- pi.sendUserMessage(`Background agent completed: ${displayName} (${record.description})\n` +
130
- `Agent ID: ${record.id} | Status: ${status} | ${toolStats} | Duration: ${duration}\n\n` +
131
- resultPreview, { deliverAs: "followUp" });
262
+ scheduleNudge(record.id, () => emitIndividualNudge(record));
132
263
  widget.update();
133
264
  }
134
- /** Format a single agent's summary for grouped notification. */
135
- function formatAgentSummary(record) {
136
- const displayName = getDisplayName(record.type);
137
- const duration = formatDuration(record.startedAt, record.completedAt);
138
- const status = getStatusLabel(record.status, record.error);
139
- const resultPreview = record.result
140
- ? record.result.length > 300
141
- ? record.result.slice(0, 300) + "\n...(truncated)"
142
- : record.result
143
- : "No output.";
144
- const tokens = safeFormatTokens(record.session);
145
- const toolStats = tokens ? `Tools: ${record.toolUses} | ${tokens}` : `Tools: ${record.toolUses}`;
146
- return `- ${displayName} (${record.description})\n ID: ${record.id} | Status: ${status} | ${toolStats} | Duration: ${duration}\n ${resultPreview}`;
147
- }
148
265
  // ---- Group join manager ----
149
266
  const groupJoin = new GroupJoinManager((records, partial) => {
150
- // Filter out agents whose results were already consumed via get_subagent_result
151
- const unconsumed = records.filter(r => !r.resultConsumed);
152
267
  for (const r of records) {
153
268
  agentActivity.delete(r.id);
154
269
  widget.markFinished(r.id);
155
270
  }
156
- // If all results were already consumed, skip the notification entirely
157
- if (unconsumed.length === 0) {
158
- widget.update();
159
- return;
160
- }
161
- const total = unconsumed.length;
162
- const label = partial ? `${total} agent(s) finished (partial — others still running)` : `${total} agent(s) finished`;
163
- const summary = unconsumed.map(r => formatAgentSummary(r)).join("\n\n");
164
- pi.sendUserMessage(`Background agent group completed: ${label}\n\n${summary}\n\nUse get_subagent_result for full output.`, { deliverAs: "followUp" });
271
+ const groupKey = `group:${records.map(r => r.id).join(",")}`;
272
+ scheduleNudge(groupKey, () => {
273
+ // Re-check at send time
274
+ const unconsumed = records.filter(r => !r.resultConsumed);
275
+ if (unconsumed.length === 0) {
276
+ widget.update();
277
+ return;
278
+ }
279
+ const notifications = unconsumed.map(r => formatTaskNotification(r, 300)).join('\n\n');
280
+ const label = partial
281
+ ? `${unconsumed.length} agent(s) finished (partial — others still running)`
282
+ : `${unconsumed.length} agent(s) finished`;
283
+ const [first, ...rest] = unconsumed;
284
+ const details = buildNotificationDetails(first, 300, agentActivity.get(first.id));
285
+ if (rest.length > 0) {
286
+ details.others = rest.map(r => buildNotificationDetails(r, 300, agentActivity.get(r.id)));
287
+ }
288
+ pi.sendMessage({
289
+ customType: "subagent-notification",
290
+ content: `Background agent group completed: ${label}\n\n${notifications}\n\nUse get_subagent_result for full output.`,
291
+ display: true,
292
+ details,
293
+ }, { deliverAs: "followUp" });
294
+ });
165
295
  widget.update();
166
296
  }, 30_000);
297
+ /** Helper: build event data for lifecycle events from an AgentRecord. */
298
+ function buildEventData(record) {
299
+ const durationMs = record.completedAt ? record.completedAt - record.startedAt : Date.now() - record.startedAt;
300
+ let tokens;
301
+ try {
302
+ if (record.session) {
303
+ const stats = record.session.getSessionStats();
304
+ tokens = {
305
+ input: stats.tokens?.input ?? 0,
306
+ output: stats.tokens?.output ?? 0,
307
+ total: stats.tokens?.total ?? 0,
308
+ };
309
+ }
310
+ }
311
+ catch { /* session stats unavailable */ }
312
+ return {
313
+ id: record.id,
314
+ type: record.type,
315
+ description: record.description,
316
+ result: record.result,
317
+ error: record.error,
318
+ status: record.status,
319
+ toolUses: record.toolUses,
320
+ durationMs,
321
+ tokens,
322
+ };
323
+ }
167
324
  // Background completion: route through group join or send individual nudge
168
325
  const manager = new AgentManager((record) => {
326
+ // Emit lifecycle event based on terminal status
327
+ const isError = record.status === "error" || record.status === "stopped" || record.status === "aborted";
328
+ const eventData = buildEventData(record);
329
+ if (isError) {
330
+ pi.events.emit("subagents:failed", eventData);
331
+ }
332
+ else {
333
+ pi.events.emit("subagents:completed", eventData);
334
+ }
335
+ // Persist final record for cross-extension history reconstruction
336
+ pi.appendEntry("subagents:record", {
337
+ id: record.id, type: record.type, description: record.description,
338
+ status: record.status, result: record.result, error: record.error,
339
+ startedAt: record.startedAt, completedAt: record.completedAt,
340
+ });
169
341
  // Skip notification if result was already consumed via get_subagent_result
170
342
  if (record.resultConsumed) {
171
343
  agentActivity.delete(record.id);
@@ -186,6 +358,13 @@ export default function (pi) {
186
358
  // 'held' → do nothing, group will fire later
187
359
  // 'delivered' → group callback already fired
188
360
  widget.update();
361
+ }, undefined, (record) => {
362
+ // Emit started event when agent transitions to running (including from queue)
363
+ pi.events.emit("subagents:started", {
364
+ id: record.id,
365
+ type: record.type,
366
+ description: record.description,
367
+ });
189
368
  });
190
369
  // Expose manager via Symbol.for() global registry for cross-package access.
191
370
  // Standard Node.js pattern for cross-package singletons (used by OpenTelemetry, etc.).
@@ -193,11 +372,36 @@ export default function (pi) {
193
372
  globalThis[MANAGER_KEY] = {
194
373
  waitForAll: () => manager.waitForAll(),
195
374
  hasRunning: () => manager.hasRunning(),
375
+ spawn: (piRef, ctx, type, prompt, options) => manager.spawn(piRef, ctx, type, prompt, options),
376
+ getRecord: (id) => manager.getRecord(id),
196
377
  };
197
- // Wait for all subagents on shutdown, then dispose the manager
378
+ // --- Cross-extension RPC via pi.events ---
379
+ let currentCtx;
380
+ // Capture ctx from session_start for RPC spawn handler
381
+ pi.on("session_start", async (_event, ctx) => {
382
+ currentCtx = ctx;
383
+ manager.clearCompleted(); // preserve existing behavior
384
+ });
385
+ pi.on("session_switch", () => { manager.clearCompleted(); });
386
+ const { unsubPing: unsubPingRpc, unsubSpawn: unsubSpawnRpc } = registerRpcHandlers({
387
+ events: pi.events,
388
+ pi,
389
+ getCtx: () => currentCtx,
390
+ manager,
391
+ });
392
+ // Broadcast readiness so extensions loaded after us can discover us
393
+ pi.events.emit("subagents:ready", {});
394
+ // On shutdown, abort all agents immediately and clean up.
395
+ // If the session is going down, there's nothing left to consume agent results.
198
396
  pi.on("session_shutdown", async () => {
397
+ unsubSpawnRpc();
398
+ unsubPingRpc();
399
+ currentCtx = undefined;
199
400
  delete globalThis[MANAGER_KEY];
200
- await manager.waitForAll();
401
+ manager.abortAll();
402
+ for (const timer of pendingNudges.values())
403
+ clearTimeout(timer);
404
+ pendingNudges.clear();
201
405
  manager.dispose();
202
406
  });
203
407
  // Live widget: show running agents above editor
@@ -306,6 +510,7 @@ Guidelines:
306
510
  - Use model to specify a different model (as "provider/modelId", or fuzzy e.g. "haiku", "sonnet").
307
511
  - Use thinking to control extended thinking level.
308
512
  - Use inherit_context if the agent needs the parent conversation history.
513
+ - Use isolation: "worktree" to run the agent in an isolated git worktree (safe parallel file modifications).
309
514
  - Use join_mode to control how background completion notifications are delivered. By default (smart), 2+ background agents spawned in the same turn are grouped into a single notification. Use "async" for individual notifications or "group" to force grouping.`,
310
515
  parameters: Type.Object({
311
516
  prompt: Type.String({
@@ -324,7 +529,7 @@ Guidelines:
324
529
  description: "Thinking level: off, minimal, low, medium, high, xhigh. Overrides agent default.",
325
530
  })),
326
531
  max_turns: Type.Optional(Type.Number({
327
- description: "Maximum number of agentic turns before stopping.",
532
+ description: "Maximum number of agentic turns before stopping. Omit for unlimited (default).",
328
533
  minimum: 1,
329
534
  })),
330
535
  run_in_background: Type.Optional(Type.Boolean({
@@ -339,6 +544,9 @@ Guidelines:
339
544
  inherit_context: Type.Optional(Type.Boolean({
340
545
  description: "If true, fork parent conversation into the agent. Default: false (fresh context).",
341
546
  })),
547
+ isolation: Type.Optional(Type.Literal("worktree", {
548
+ description: 'Set to "worktree" to run the agent in a temporary git worktree (isolated copy of the repo). Changes are saved to a branch on completion.',
549
+ })),
342
550
  join_mode: Type.Optional(Type.Union([
343
551
  Type.Literal("async"),
344
552
  Type.Literal("group"),
@@ -356,13 +564,16 @@ Guidelines:
356
564
  const text = result.content[0]?.type === "text" ? result.content[0].text : "";
357
565
  return new Text(text, 0, 0);
358
566
  }
359
- // Helper: build "haiku · thinking: high · 3 tool uses · 33.8k tokens" stats string
567
+ // Helper: build "haiku · thinking: high · ⟳5≤30 · 3 tool uses · 33.8k tokens" stats string
360
568
  const stats = (d) => {
361
569
  const parts = [];
362
570
  if (d.modelName)
363
571
  parts.push(d.modelName);
364
572
  if (d.tags)
365
573
  parts.push(...d.tags);
574
+ if (d.turnCount != null && d.turnCount > 0) {
575
+ parts.push(formatTurns(d.turnCount, d.maxTurns));
576
+ }
366
577
  if (d.toolUses > 0)
367
578
  parts.push(`${d.toolUses} tool use${d.toolUses === 1 ? "" : "s"}`);
368
579
  if (d.tokens)
@@ -426,7 +637,7 @@ Guidelines:
426
637
  return new Text(line, 0, 0);
427
638
  },
428
639
  // ---- Execute ----
429
- execute: async (_toolCallId, params, signal, onUpdate, ctx) => {
640
+ execute: async (toolCallId, params, signal, onUpdate, ctx) => {
430
641
  // Ensure we have UI context for widget rendering
431
642
  widget.setUICtx(ctx.ui);
432
643
  // Reload custom agents so new .pi/agents/*.md files are picked up without restart
@@ -458,6 +669,7 @@ Guidelines:
458
669
  const inheritContext = params.inherit_context ?? customConfig?.inheritContext ?? false;
459
670
  const runInBackground = params.run_in_background ?? customConfig?.runInBackground ?? false;
460
671
  const isolated = params.isolated ?? customConfig?.isolated ?? false;
672
+ const isolation = params.isolation ?? customConfig?.isolation;
461
673
  // Build display tags for non-default config
462
674
  const parentModelId = ctx.model?.id;
463
675
  const effectiveModelId = model?.id;
@@ -472,6 +684,9 @@ Guidelines:
472
684
  agentTags.push(`thinking: ${thinking}`);
473
685
  if (isolated)
474
686
  agentTags.push("isolated");
687
+ if (isolation === "worktree")
688
+ agentTags.push("worktree");
689
+ const effectiveMaxTurns = params.max_turns ?? customConfig?.maxTurns ?? getDefaultMaxTurns();
475
690
  // Shared base fields for all AgentDetails in this call
476
691
  const detailBase = {
477
692
  displayName,
@@ -497,8 +712,20 @@ Guidelines:
497
712
  }
498
713
  // Background execution
499
714
  if (runInBackground) {
500
- const { state: bgState, callbacks: bgCallbacks } = createActivityTracker();
501
- const id = manager.spawn(pi, ctx, subagentType, params.prompt, {
715
+ const { state: bgState, callbacks: bgCallbacks } = createActivityTracker(effectiveMaxTurns);
716
+ // Wrap onSessionCreated to wire output file streaming.
717
+ // The callback lazily reads record.outputFile (set right after spawn)
718
+ // rather than closing over a value that doesn't exist yet.
719
+ let id;
720
+ const origBgOnSession = bgCallbacks.onSessionCreated;
721
+ bgCallbacks.onSessionCreated = (session) => {
722
+ origBgOnSession(session);
723
+ const rec = manager.getRecord(id);
724
+ if (rec?.outputFile) {
725
+ rec.outputCleanup = streamToOutputFile(session, rec.outputFile, id, ctx.cwd);
726
+ }
727
+ };
728
+ id = manager.spawn(pi, ctx, subagentType, params.prompt, {
502
729
  description: params.description,
503
730
  model,
504
731
  maxTurns: params.max_turns,
@@ -506,13 +733,19 @@ Guidelines:
506
733
  inheritContext,
507
734
  thinkingLevel: thinking,
508
735
  isBackground: true,
736
+ isolation,
509
737
  ...bgCallbacks,
510
738
  });
511
- // Determine join mode and track for batching
739
+ // Set output file + join mode synchronously after spawn, before the
740
+ // event loop yields — onSessionCreated is async so this is safe.
512
741
  const joinMode = params.join_mode ?? defaultJoinMode;
513
742
  const record = manager.getRecord(id);
514
- if (record)
743
+ if (record) {
515
744
  record.joinMode = joinMode;
745
+ record.toolCallId = toolCallId;
746
+ record.outputFile = createOutputFilePath(ctx.cwd, id, ctx.sessionManager.getSessionId());
747
+ writeInitialEntry(record.outputFile, id, params.prompt, ctx.cwd);
748
+ }
516
749
  if (joinMode === 'async') {
517
750
  // Explicit async — not part of any batch
518
751
  }
@@ -528,11 +761,19 @@ Guidelines:
528
761
  agentActivity.set(id, bgState);
529
762
  widget.ensureTimer();
530
763
  widget.update();
764
+ // Emit created event
765
+ pi.events.emit("subagents:created", {
766
+ id,
767
+ type: subagentType,
768
+ description: params.description,
769
+ isBackground: true,
770
+ });
531
771
  const isQueued = record?.status === "queued";
532
772
  return textResult(`Agent ${isQueued ? "queued" : "started"} in background.\n` +
533
773
  `Agent ID: ${id}\n` +
534
774
  `Type: ${displayName}\n` +
535
775
  `Description: ${params.description}\n` +
776
+ (record?.outputFile ? `Output file: ${record.outputFile}\n` : "") +
536
777
  (isQueued ? `Position: queued (max ${manager.getMaxConcurrent()} concurrent)\n` : "") +
537
778
  `\nYou will be notified when this agent completes.\n` +
538
779
  `Use get_subagent_result to retrieve full results, or steer_subagent to send it messages.\n` +
@@ -547,6 +788,8 @@ Guidelines:
547
788
  ...detailBase,
548
789
  toolUses: fgState.toolUses,
549
790
  tokens: fgState.tokens,
791
+ turnCount: fgState.turnCount,
792
+ maxTurns: fgState.maxTurns,
550
793
  durationMs: Date.now() - startedAt,
551
794
  status: "running",
552
795
  activity: describeActivity(fgState.activeTools, fgState.responseText),
@@ -557,7 +800,7 @@ Guidelines:
557
800
  details: details,
558
801
  });
559
802
  };
560
- const { state: fgState, callbacks: fgCallbacks } = createActivityTracker(streamUpdate);
803
+ const { state: fgState, callbacks: fgCallbacks } = createActivityTracker(effectiveMaxTurns, streamUpdate);
561
804
  // Wire session creation to register in widget
562
805
  const origOnSession = fgCallbacks.onSessionCreated;
563
806
  fgCallbacks.onSessionCreated = (session) => {
@@ -584,6 +827,7 @@ Guidelines:
584
827
  isolated,
585
828
  inheritContext,
586
829
  thinkingLevel: thinking,
830
+ isolation,
587
831
  ...fgCallbacks,
588
832
  });
589
833
  clearInterval(spinnerInterval);
@@ -594,7 +838,7 @@ Guidelines:
594
838
  }
595
839
  // Get final token count
596
840
  const tokenText = safeFormatTokens(fgState.session);
597
- const details = buildDetails(detailBase, record, { tokens: tokenText });
841
+ const details = buildDetails(detailBase, record, fgState, { tokens: tokenText });
598
842
  const fallbackNote = fellBack
599
843
  ? `Note: Unknown agent type "${rawType}" — using general-purpose.\n\n`
600
844
  : "";
@@ -630,8 +874,13 @@ Guidelines:
630
874
  if (!record) {
631
875
  return textResult(`Agent not found: "${params.agent_id}". It may have been cleaned up.`);
632
876
  }
633
- // Wait for completion if requested
877
+ // Wait for completion if requested.
878
+ // Pre-mark resultConsumed BEFORE awaiting: onComplete fires inside .then()
879
+ // (attached earlier at spawn time) and always runs before this await resumes.
880
+ // Setting the flag here prevents a redundant follow-up notification.
634
881
  if (params.wait && record.status === "running" && record.promise) {
882
+ record.resultConsumed = true;
883
+ cancelNudge(params.agent_id);
635
884
  await record.promise;
636
885
  }
637
886
  const displayName = getDisplayName(record.type);
@@ -653,6 +902,7 @@ Guidelines:
653
902
  // Mark result as consumed — suppresses the completion notification
654
903
  if (record.status !== "running" && record.status !== "queued") {
655
904
  record.resultConsumed = true;
905
+ cancelNudge(params.agent_id);
656
906
  }
657
907
  // Verbose: include full conversation
658
908
  if (params.verbose && record.session) {
@@ -687,10 +937,16 @@ Guidelines:
687
937
  return textResult(`Agent "${params.agent_id}" is not running (status: ${record.status}). Cannot steer a non-running agent.`);
688
938
  }
689
939
  if (!record.session) {
690
- return textResult(`Agent "${params.agent_id}" has no active session yet. It may still be initializing.`);
940
+ // Session not ready yet queue the steer for delivery once initialized
941
+ if (!record.pendingSteers)
942
+ record.pendingSteers = [];
943
+ record.pendingSteers.push(params.message);
944
+ pi.events.emit("subagents:steered", { id: record.id, message: params.message });
945
+ return textResult(`Steering message queued for agent ${record.id}. It will be delivered once the session initializes.`);
691
946
  }
692
947
  try {
693
948
  await steerAgent(record.session, params.message);
949
+ pi.events.emit("subagents:steered", { id: record.id, message: params.message });
694
950
  return textResult(`Steering message sent to agent ${record.id}. The agent will process it after its current tool execution.`);
695
951
  }
696
952
  catch (err) {
@@ -961,12 +1217,18 @@ Guidelines:
961
1217
  fmFields.push("skills: false");
962
1218
  else if (Array.isArray(cfg.skills))
963
1219
  fmFields.push(`skills: ${cfg.skills.join(", ")}`);
1220
+ if (cfg.disallowedTools?.length)
1221
+ fmFields.push(`disallowed_tools: ${cfg.disallowedTools.join(", ")}`);
964
1222
  if (cfg.inheritContext)
965
1223
  fmFields.push("inherit_context: true");
966
1224
  if (cfg.runInBackground)
967
1225
  fmFields.push("run_in_background: true");
968
1226
  if (cfg.isolated)
969
1227
  fmFields.push("isolated: true");
1228
+ if (cfg.memory)
1229
+ fmFields.push(`memory: ${cfg.memory}`);
1230
+ if (cfg.isolation)
1231
+ fmFields.push(`isolation: ${cfg.isolation}`);
970
1232
  const content = `---\n${fmFields.join("\n")}\n---\n\n${cfg.systemPrompt}\n`;
971
1233
  const { writeFileSync } = await import("node:fs");
972
1234
  writeFileSync(targetPath, content, "utf-8");
@@ -1073,13 +1335,16 @@ description: <one-line description shown in UI>
1073
1335
  tools: <comma-separated built-in tools: read, bash, edit, write, grep, find, ls. Use "none" for no tools. Omit for all tools>
1074
1336
  model: <optional model as "provider/modelId", e.g. "anthropic/claude-haiku-4-5-20251001". Omit to inherit parent model>
1075
1337
  thinking: <optional thinking level: off, minimal, low, medium, high, xhigh. Omit to inherit>
1076
- max_turns: <optional max agentic turns, default 50. Omit for default>
1338
+ max_turns: <optional max agentic turns. 0 or omit for unlimited (default)>
1077
1339
  prompt_mode: <"replace" (body IS the full system prompt) or "append" (body is appended to default prompt). Default: replace>
1078
1340
  extensions: <true (inherit all MCP/extension tools), false (none), or comma-separated names. Default: true>
1079
- skills: <true (inherit all), false (none). Default: true>
1341
+ skills: <true (inherit all), false (none), or comma-separated skill names to preload into prompt. Default: true>
1342
+ disallowed_tools: <comma-separated tool names to block, even if otherwise available. Omit for none>
1080
1343
  inherit_context: <true to fork parent conversation into agent so it sees chat history. Default: false>
1081
1344
  run_in_background: <true to run in background by default. Default: false>
1082
1345
  isolated: <true for no extension/MCP tools, only built-in tools. Default: false>
1346
+ memory: <"user" (global), "project" (per-project), or "local" (gitignored per-project) for persistent memory. Omit for none>
1347
+ isolation: <"worktree" to run in isolated git worktree. Omit for normal>
1083
1348
  ---
1084
1349
 
1085
1350
  <system prompt body — instructions for the agent>
@@ -1205,7 +1470,7 @@ ${systemPrompt}
1205
1470
  async function showSettings(ctx) {
1206
1471
  const choice = await ctx.ui.select("Settings", [
1207
1472
  `Max concurrency (current: ${manager.getMaxConcurrent()})`,
1208
- `Default max turns (current: ${getDefaultMaxTurns()})`,
1473
+ `Default max turns (current: ${getDefaultMaxTurns() ?? "unlimited"})`,
1209
1474
  `Grace turns (current: ${getGraceTurns()})`,
1210
1475
  `Join mode (current: ${getDefaultJoinMode()})`,
1211
1476
  ]);
@@ -1225,15 +1490,19 @@ ${systemPrompt}
1225
1490
  }
1226
1491
  }
1227
1492
  else if (choice.startsWith("Default max turns")) {
1228
- const val = await ctx.ui.input("Default max turns before wrap-up", String(getDefaultMaxTurns()));
1493
+ const val = await ctx.ui.input("Default max turns before wrap-up (0 = unlimited)", String(getDefaultMaxTurns() ?? 0));
1229
1494
  if (val) {
1230
1495
  const n = parseInt(val, 10);
1231
- if (n >= 1) {
1496
+ if (n === 0) {
1497
+ setDefaultMaxTurns(undefined);
1498
+ ctx.ui.notify("Default max turns set to unlimited", "info");
1499
+ }
1500
+ else if (n >= 1) {
1232
1501
  setDefaultMaxTurns(n);
1233
1502
  ctx.ui.notify(`Default max turns set to ${n}`, "info");
1234
1503
  }
1235
1504
  else {
1236
- ctx.ui.notify("Must be a positive integer.", "warning");
1505
+ ctx.ui.notify("Must be 0 (unlimited) or a positive integer.", "warning");
1237
1506
  }
1238
1507
  }
1239
1508
  }