@tintinweb/pi-subagents 0.4.10 → 0.5.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/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", triggerTurn: true });
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", triggerTurn: true });
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,37 @@ 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, unsubStop: unsubStopRpc } = 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
+ unsubStopRpc();
399
+ unsubPingRpc();
400
+ currentCtx = undefined;
199
401
  delete globalThis[MANAGER_KEY];
200
- await manager.waitForAll();
402
+ manager.abortAll();
403
+ for (const timer of pendingNudges.values())
404
+ clearTimeout(timer);
405
+ pendingNudges.clear();
201
406
  manager.dispose();
202
407
  });
203
408
  // Live widget: show running agents above editor
@@ -306,6 +511,7 @@ Guidelines:
306
511
  - Use model to specify a different model (as "provider/modelId", or fuzzy e.g. "haiku", "sonnet").
307
512
  - Use thinking to control extended thinking level.
308
513
  - Use inherit_context if the agent needs the parent conversation history.
514
+ - Use isolation: "worktree" to run the agent in an isolated git worktree (safe parallel file modifications).
309
515
  - 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
516
  parameters: Type.Object({
311
517
  prompt: Type.String({
@@ -324,7 +530,7 @@ Guidelines:
324
530
  description: "Thinking level: off, minimal, low, medium, high, xhigh. Overrides agent default.",
325
531
  })),
326
532
  max_turns: Type.Optional(Type.Number({
327
- description: "Maximum number of agentic turns before stopping.",
533
+ description: "Maximum number of agentic turns before stopping. Omit for unlimited (default).",
328
534
  minimum: 1,
329
535
  })),
330
536
  run_in_background: Type.Optional(Type.Boolean({
@@ -339,6 +545,9 @@ Guidelines:
339
545
  inherit_context: Type.Optional(Type.Boolean({
340
546
  description: "If true, fork parent conversation into the agent. Default: false (fresh context).",
341
547
  })),
548
+ isolation: Type.Optional(Type.Literal("worktree", {
549
+ 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.',
550
+ })),
342
551
  join_mode: Type.Optional(Type.Union([
343
552
  Type.Literal("async"),
344
553
  Type.Literal("group"),
@@ -356,13 +565,16 @@ Guidelines:
356
565
  const text = result.content[0]?.type === "text" ? result.content[0].text : "";
357
566
  return new Text(text, 0, 0);
358
567
  }
359
- // Helper: build "haiku · thinking: high · 3 tool uses · 33.8k tokens" stats string
568
+ // Helper: build "haiku · thinking: high · ⟳5≤30 · 3 tool uses · 33.8k tokens" stats string
360
569
  const stats = (d) => {
361
570
  const parts = [];
362
571
  if (d.modelName)
363
572
  parts.push(d.modelName);
364
573
  if (d.tags)
365
574
  parts.push(...d.tags);
575
+ if (d.turnCount != null && d.turnCount > 0) {
576
+ parts.push(formatTurns(d.turnCount, d.maxTurns));
577
+ }
366
578
  if (d.toolUses > 0)
367
579
  parts.push(`${d.toolUses} tool use${d.toolUses === 1 ? "" : "s"}`);
368
580
  if (d.tokens)
@@ -426,7 +638,7 @@ Guidelines:
426
638
  return new Text(line, 0, 0);
427
639
  },
428
640
  // ---- Execute ----
429
- execute: async (_toolCallId, params, signal, onUpdate, ctx) => {
641
+ execute: async (toolCallId, params, signal, onUpdate, ctx) => {
430
642
  // Ensure we have UI context for widget rendering
431
643
  widget.setUICtx(ctx.ui);
432
644
  // Reload custom agents so new .pi/agents/*.md files are picked up without restart
@@ -458,6 +670,7 @@ Guidelines:
458
670
  const inheritContext = params.inherit_context ?? customConfig?.inheritContext ?? false;
459
671
  const runInBackground = params.run_in_background ?? customConfig?.runInBackground ?? false;
460
672
  const isolated = params.isolated ?? customConfig?.isolated ?? false;
673
+ const isolation = params.isolation ?? customConfig?.isolation;
461
674
  // Build display tags for non-default config
462
675
  const parentModelId = ctx.model?.id;
463
676
  const effectiveModelId = model?.id;
@@ -472,6 +685,9 @@ Guidelines:
472
685
  agentTags.push(`thinking: ${thinking}`);
473
686
  if (isolated)
474
687
  agentTags.push("isolated");
688
+ if (isolation === "worktree")
689
+ agentTags.push("worktree");
690
+ const effectiveMaxTurns = params.max_turns ?? customConfig?.maxTurns ?? getDefaultMaxTurns();
475
691
  // Shared base fields for all AgentDetails in this call
476
692
  const detailBase = {
477
693
  displayName,
@@ -497,8 +713,20 @@ Guidelines:
497
713
  }
498
714
  // Background execution
499
715
  if (runInBackground) {
500
- const { state: bgState, callbacks: bgCallbacks } = createActivityTracker();
501
- const id = manager.spawn(pi, ctx, subagentType, params.prompt, {
716
+ const { state: bgState, callbacks: bgCallbacks } = createActivityTracker(effectiveMaxTurns);
717
+ // Wrap onSessionCreated to wire output file streaming.
718
+ // The callback lazily reads record.outputFile (set right after spawn)
719
+ // rather than closing over a value that doesn't exist yet.
720
+ let id;
721
+ const origBgOnSession = bgCallbacks.onSessionCreated;
722
+ bgCallbacks.onSessionCreated = (session) => {
723
+ origBgOnSession(session);
724
+ const rec = manager.getRecord(id);
725
+ if (rec?.outputFile) {
726
+ rec.outputCleanup = streamToOutputFile(session, rec.outputFile, id, ctx.cwd);
727
+ }
728
+ };
729
+ id = manager.spawn(pi, ctx, subagentType, params.prompt, {
502
730
  description: params.description,
503
731
  model,
504
732
  maxTurns: params.max_turns,
@@ -506,13 +734,19 @@ Guidelines:
506
734
  inheritContext,
507
735
  thinkingLevel: thinking,
508
736
  isBackground: true,
737
+ isolation,
509
738
  ...bgCallbacks,
510
739
  });
511
- // Determine join mode and track for batching
740
+ // Set output file + join mode synchronously after spawn, before the
741
+ // event loop yields — onSessionCreated is async so this is safe.
512
742
  const joinMode = params.join_mode ?? defaultJoinMode;
513
743
  const record = manager.getRecord(id);
514
- if (record)
744
+ if (record) {
515
745
  record.joinMode = joinMode;
746
+ record.toolCallId = toolCallId;
747
+ record.outputFile = createOutputFilePath(ctx.cwd, id, ctx.sessionManager.getSessionId());
748
+ writeInitialEntry(record.outputFile, id, params.prompt, ctx.cwd);
749
+ }
516
750
  if (joinMode === 'async') {
517
751
  // Explicit async — not part of any batch
518
752
  }
@@ -528,11 +762,19 @@ Guidelines:
528
762
  agentActivity.set(id, bgState);
529
763
  widget.ensureTimer();
530
764
  widget.update();
765
+ // Emit created event
766
+ pi.events.emit("subagents:created", {
767
+ id,
768
+ type: subagentType,
769
+ description: params.description,
770
+ isBackground: true,
771
+ });
531
772
  const isQueued = record?.status === "queued";
532
773
  return textResult(`Agent ${isQueued ? "queued" : "started"} in background.\n` +
533
774
  `Agent ID: ${id}\n` +
534
775
  `Type: ${displayName}\n` +
535
776
  `Description: ${params.description}\n` +
777
+ (record?.outputFile ? `Output file: ${record.outputFile}\n` : "") +
536
778
  (isQueued ? `Position: queued (max ${manager.getMaxConcurrent()} concurrent)\n` : "") +
537
779
  `\nYou will be notified when this agent completes.\n` +
538
780
  `Use get_subagent_result to retrieve full results, or steer_subagent to send it messages.\n` +
@@ -547,6 +789,8 @@ Guidelines:
547
789
  ...detailBase,
548
790
  toolUses: fgState.toolUses,
549
791
  tokens: fgState.tokens,
792
+ turnCount: fgState.turnCount,
793
+ maxTurns: fgState.maxTurns,
550
794
  durationMs: Date.now() - startedAt,
551
795
  status: "running",
552
796
  activity: describeActivity(fgState.activeTools, fgState.responseText),
@@ -557,7 +801,7 @@ Guidelines:
557
801
  details: details,
558
802
  });
559
803
  };
560
- const { state: fgState, callbacks: fgCallbacks } = createActivityTracker(streamUpdate);
804
+ const { state: fgState, callbacks: fgCallbacks } = createActivityTracker(effectiveMaxTurns, streamUpdate);
561
805
  // Wire session creation to register in widget
562
806
  const origOnSession = fgCallbacks.onSessionCreated;
563
807
  fgCallbacks.onSessionCreated = (session) => {
@@ -584,6 +828,7 @@ Guidelines:
584
828
  isolated,
585
829
  inheritContext,
586
830
  thinkingLevel: thinking,
831
+ isolation,
587
832
  ...fgCallbacks,
588
833
  });
589
834
  clearInterval(spinnerInterval);
@@ -594,7 +839,7 @@ Guidelines:
594
839
  }
595
840
  // Get final token count
596
841
  const tokenText = safeFormatTokens(fgState.session);
597
- const details = buildDetails(detailBase, record, { tokens: tokenText });
842
+ const details = buildDetails(detailBase, record, fgState, { tokens: tokenText });
598
843
  const fallbackNote = fellBack
599
844
  ? `Note: Unknown agent type "${rawType}" — using general-purpose.\n\n`
600
845
  : "";
@@ -630,8 +875,13 @@ Guidelines:
630
875
  if (!record) {
631
876
  return textResult(`Agent not found: "${params.agent_id}". It may have been cleaned up.`);
632
877
  }
633
- // Wait for completion if requested
878
+ // Wait for completion if requested.
879
+ // Pre-mark resultConsumed BEFORE awaiting: onComplete fires inside .then()
880
+ // (attached earlier at spawn time) and always runs before this await resumes.
881
+ // Setting the flag here prevents a redundant follow-up notification.
634
882
  if (params.wait && record.status === "running" && record.promise) {
883
+ record.resultConsumed = true;
884
+ cancelNudge(params.agent_id);
635
885
  await record.promise;
636
886
  }
637
887
  const displayName = getDisplayName(record.type);
@@ -653,6 +903,7 @@ Guidelines:
653
903
  // Mark result as consumed — suppresses the completion notification
654
904
  if (record.status !== "running" && record.status !== "queued") {
655
905
  record.resultConsumed = true;
906
+ cancelNudge(params.agent_id);
656
907
  }
657
908
  // Verbose: include full conversation
658
909
  if (params.verbose && record.session) {
@@ -687,10 +938,16 @@ Guidelines:
687
938
  return textResult(`Agent "${params.agent_id}" is not running (status: ${record.status}). Cannot steer a non-running agent.`);
688
939
  }
689
940
  if (!record.session) {
690
- return textResult(`Agent "${params.agent_id}" has no active session yet. It may still be initializing.`);
941
+ // Session not ready yet queue the steer for delivery once initialized
942
+ if (!record.pendingSteers)
943
+ record.pendingSteers = [];
944
+ record.pendingSteers.push(params.message);
945
+ pi.events.emit("subagents:steered", { id: record.id, message: params.message });
946
+ return textResult(`Steering message queued for agent ${record.id}. It will be delivered once the session initializes.`);
691
947
  }
692
948
  try {
693
949
  await steerAgent(record.session, params.message);
950
+ pi.events.emit("subagents:steered", { id: record.id, message: params.message });
694
951
  return textResult(`Steering message sent to agent ${record.id}. The agent will process it after its current tool execution.`);
695
952
  }
696
953
  catch (err) {
@@ -961,12 +1218,18 @@ Guidelines:
961
1218
  fmFields.push("skills: false");
962
1219
  else if (Array.isArray(cfg.skills))
963
1220
  fmFields.push(`skills: ${cfg.skills.join(", ")}`);
1221
+ if (cfg.disallowedTools?.length)
1222
+ fmFields.push(`disallowed_tools: ${cfg.disallowedTools.join(", ")}`);
964
1223
  if (cfg.inheritContext)
965
1224
  fmFields.push("inherit_context: true");
966
1225
  if (cfg.runInBackground)
967
1226
  fmFields.push("run_in_background: true");
968
1227
  if (cfg.isolated)
969
1228
  fmFields.push("isolated: true");
1229
+ if (cfg.memory)
1230
+ fmFields.push(`memory: ${cfg.memory}`);
1231
+ if (cfg.isolation)
1232
+ fmFields.push(`isolation: ${cfg.isolation}`);
970
1233
  const content = `---\n${fmFields.join("\n")}\n---\n\n${cfg.systemPrompt}\n`;
971
1234
  const { writeFileSync } = await import("node:fs");
972
1235
  writeFileSync(targetPath, content, "utf-8");
@@ -1073,13 +1336,16 @@ description: <one-line description shown in UI>
1073
1336
  tools: <comma-separated built-in tools: read, bash, edit, write, grep, find, ls. Use "none" for no tools. Omit for all tools>
1074
1337
  model: <optional model as "provider/modelId", e.g. "anthropic/claude-haiku-4-5-20251001". Omit to inherit parent model>
1075
1338
  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>
1339
+ max_turns: <optional max agentic turns. 0 or omit for unlimited (default)>
1077
1340
  prompt_mode: <"replace" (body IS the full system prompt) or "append" (body is appended to default prompt). Default: replace>
1078
1341
  extensions: <true (inherit all MCP/extension tools), false (none), or comma-separated names. Default: true>
1079
- skills: <true (inherit all), false (none). Default: true>
1342
+ skills: <true (inherit all), false (none), or comma-separated skill names to preload into prompt. Default: true>
1343
+ disallowed_tools: <comma-separated tool names to block, even if otherwise available. Omit for none>
1080
1344
  inherit_context: <true to fork parent conversation into agent so it sees chat history. Default: false>
1081
1345
  run_in_background: <true to run in background by default. Default: false>
1082
1346
  isolated: <true for no extension/MCP tools, only built-in tools. Default: false>
1347
+ memory: <"user" (global), "project" (per-project), or "local" (gitignored per-project) for persistent memory. Omit for none>
1348
+ isolation: <"worktree" to run in isolated git worktree. Omit for normal>
1083
1349
  ---
1084
1350
 
1085
1351
  <system prompt body — instructions for the agent>
@@ -1205,7 +1471,7 @@ ${systemPrompt}
1205
1471
  async function showSettings(ctx) {
1206
1472
  const choice = await ctx.ui.select("Settings", [
1207
1473
  `Max concurrency (current: ${manager.getMaxConcurrent()})`,
1208
- `Default max turns (current: ${getDefaultMaxTurns()})`,
1474
+ `Default max turns (current: ${getDefaultMaxTurns() ?? "unlimited"})`,
1209
1475
  `Grace turns (current: ${getGraceTurns()})`,
1210
1476
  `Join mode (current: ${getDefaultJoinMode()})`,
1211
1477
  ]);
@@ -1225,15 +1491,19 @@ ${systemPrompt}
1225
1491
  }
1226
1492
  }
1227
1493
  else if (choice.startsWith("Default max turns")) {
1228
- const val = await ctx.ui.input("Default max turns before wrap-up", String(getDefaultMaxTurns()));
1494
+ const val = await ctx.ui.input("Default max turns before wrap-up (0 = unlimited)", String(getDefaultMaxTurns() ?? 0));
1229
1495
  if (val) {
1230
1496
  const n = parseInt(val, 10);
1231
- if (n >= 1) {
1497
+ if (n === 0) {
1498
+ setDefaultMaxTurns(undefined);
1499
+ ctx.ui.notify("Default max turns set to unlimited", "info");
1500
+ }
1501
+ else if (n >= 1) {
1232
1502
  setDefaultMaxTurns(n);
1233
1503
  ctx.ui.notify(`Default max turns set to ${n}`, "info");
1234
1504
  }
1235
1505
  else {
1236
- ctx.ui.notify("Must be a positive integer.", "warning");
1506
+ ctx.ui.notify("Must be 0 (unlimited) or a positive integer.", "warning");
1237
1507
  }
1238
1508
  }
1239
1509
  }