@nredd/pi-subagents 0.0.0-stage → 0.22.0-nredd.5

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 (70) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +1244 -2
  3. package/package.json +75 -4
  4. package/src/abortable.ts +43 -0
  5. package/src/agent-color.ts +161 -0
  6. package/src/agent-file-toggle.ts +297 -0
  7. package/src/agent-manager.ts +1874 -0
  8. package/src/agent-runner.ts +1552 -0
  9. package/src/agent-source-badge.ts +75 -0
  10. package/src/agent-types.ts +346 -0
  11. package/src/child-context.ts +15 -0
  12. package/src/clock.ts +25 -0
  13. package/src/completion-outbox.ts +125 -0
  14. package/src/context.ts +58 -0
  15. package/src/cross-extension-rpc.ts +221 -0
  16. package/src/custom-agents.ts +436 -0
  17. package/src/default-agents.ts +126 -0
  18. package/src/detached-results.ts +134 -0
  19. package/src/enabled-models.ts +180 -0
  20. package/src/enterprise-local.ts +66 -0
  21. package/src/env.ts +33 -0
  22. package/src/group-join.ts +148 -0
  23. package/src/index.ts +4423 -0
  24. package/src/invocation-config.ts +189 -0
  25. package/src/memory.ts +179 -0
  26. package/src/mention-clone.ts +195 -0
  27. package/src/mention.ts +141 -0
  28. package/src/model-call.ts +83 -0
  29. package/src/model-resolver.ts +266 -0
  30. package/src/model-scope.ts +70 -0
  31. package/src/nested-tools.ts +448 -0
  32. package/src/output-file.ts +164 -0
  33. package/src/package-resources.ts +624 -0
  34. package/src/prompts.ts +156 -0
  35. package/src/quota-admission.ts +99 -0
  36. package/src/schedule-store.ts +153 -0
  37. package/src/schedule.ts +446 -0
  38. package/src/settings.ts +728 -0
  39. package/src/skill-loader.ts +137 -0
  40. package/src/startup-error.ts +29 -0
  41. package/src/status-note.ts +90 -0
  42. package/src/structured-output.ts +130 -0
  43. package/src/types.ts +404 -0
  44. package/src/ui/agent-mention.ts +216 -0
  45. package/src/ui/agent-row.ts +235 -0
  46. package/src/ui/agent-widget.ts +692 -0
  47. package/src/ui/conversation-viewer.ts +832 -0
  48. package/src/ui/fleet-list.ts +675 -0
  49. package/src/ui/notification-view.ts +120 -0
  50. package/src/ui/schedule-menu.ts +105 -0
  51. package/src/ui/select-item.ts +45 -0
  52. package/src/ui/viewer-keys.ts +39 -0
  53. package/src/ui/workflow-card.ts +462 -0
  54. package/src/ui/workflow-dialog.ts +1115 -0
  55. package/src/ui/workflow-menu.ts +193 -0
  56. package/src/usage.ts +167 -0
  57. package/src/workflow/collisions.ts +123 -0
  58. package/src/workflow/entry.ts +47 -0
  59. package/src/workflow/host.ts +401 -0
  60. package/src/workflow/journal.ts +164 -0
  61. package/src/workflow/json-schema.ts +129 -0
  62. package/src/workflow/meta.ts +326 -0
  63. package/src/workflow/progress.ts +549 -0
  64. package/src/workflow/runtime.ts +1216 -0
  65. package/src/workflow/saved.ts +259 -0
  66. package/src/workflow/task.ts +302 -0
  67. package/src/workflow/tool-description.ts +200 -0
  68. package/src/workflow/worker-source.ts +781 -0
  69. package/src/worktree.ts +230 -0
  70. package/src/xml.ts +13 -0
package/src/index.ts ADDED
@@ -0,0 +1,4423 @@
1
+ /**
2
+ * pi-agents — A pi extension providing Claude Code-style autonomous sub-agents.
3
+ *
4
+ * Tools:
5
+ * Agent — LLM-callable: spawn a sub-agent
6
+ * get_subagent_result — LLM-callable: check background agent status/result
7
+ * steer_subagent — LLM-callable: send a steering message to a running agent
8
+ *
9
+ * Commands:
10
+ * /agents — Interactive agent management menu
11
+ */
12
+
13
+ import { existsSync, mkdirSync, readFileSync, unlinkSync, writeFileSync } from "node:fs";
14
+ import { isAbsolute, join } from "node:path";
15
+ import { defineTool, type ExtensionAPI, type ExtensionCommandContext, type ExtensionContext, type ExtensionFactory, getAgentDir, getSettingsListTheme } from "@earendil-works/pi-coding-agent";
16
+ import { Container, isKeyRelease, Key, matchesKey, type ScrollViewScrollbar, type SettingItem, SettingsList, Spacer, Text } from "@earendil-works/pi-tui";
17
+ import { nanoid } from "nanoid";
18
+ import { Type } from "typebox";
19
+ import { abortable } from "./abortable.js";
20
+ import { hasAgentBadge, renderAgentName } from "./agent-color.js";
21
+ import { buildNewAgentFile, disableInContent, enableInContent, isEmptyStub, locateAgentFile, personalAgentsDir, projectAgentsDir, serializeAgentFile } from "./agent-file-toggle.js";
22
+ import { AgentManager, describeEvicted, isTopLevelAgent, markConsumed } from "./agent-manager.js";
23
+ import { getAgentConversation, getDefaultMaxTurns, getGraceTurns, getRememberAgents, normalizeMaxTurns, resolveEffectiveMaxTurns, SUBAGENT_TOOL_NAMES, setDefaultMaxTurns, setGraceTurns, setRememberAgents, steerAgent } from "./agent-runner.js";
24
+ import { rowDescription, sourceIndicator, sourceLegend } from "./agent-source-badge.js";
25
+ import { BUILTIN_TOOL_NAMES, getAgentConfig, getAllTypes, getAvailableTypes, getConfig, getFallbackSubagent, isDefaultsDisabled, NO_FALLBACK, registerAgents, resolveSpawnType, resolveType, setDefaultsDisabled, setFallbackSubagent } from "./agent-types.js";
26
+ import { inChildSessionContext } from "./child-context.js";
27
+ import { type Clock, realClock } from "./clock.js";
28
+ import { CompletionOutbox, NOTIFICATION_HOLD_MS } from "./completion-outbox.js";
29
+ import { type RpcHandle, registerRpcHandlers } from "./cross-extension-rpc.js";
30
+ import { loadCustomAgents } from "./custom-agents.js";
31
+ import {
32
+ type DetachedResult,
33
+ drainDetached,
34
+ listRunningDetached,
35
+ registerRunningDetached,
36
+ spoolDetached,
37
+ unregisterRunningDetached,
38
+ watchDetached,
39
+ } from "./detached-results.js";
40
+ import { readEnabledModels, resolveEnabledModels } from "./enabled-models.js";
41
+ import { GroupJoinManager } from "./group-join.js";
42
+ import { cwdParam, isolationParam, resolveAgentInvocationConfig, resolveCwdParam, resolveJoinMode } from "./invocation-config.js";
43
+ import { describeMention, handleBase, isReservedHandle, parseMention, resolveHandleToType, stripAgentPrefix } from "./mention.js";
44
+ import { runMentionClone } from "./mention-clone.js";
45
+ import { applyModelCallEvent, type ModelCallEvent } from "./model-call.js";
46
+ import { describeModel, describeVirtualModel, isVirtualModel, type ModelRegistry, resolveModel, setAgentModels, setModelAliases, setModelScopeProvider } from "./model-resolver.js";
47
+ import { checkModelScope, isScopeModelsEnabled, setScopeModelsEnabled } from "./model-scope.js";
48
+ import { getMaxSubagentDepth, setMaxSubagentDepth } from "./nested-tools.js";
49
+ import { createOutputFilePath, ensureOutputFile, getOutputTranscriptDefault, sessionTaskDir, setOutputTranscriptDefault, streamToOutputFile, writeInitialEntry } from "./output-file.js";
50
+ import { getPackageAgentsGate, getPackageWorkflowsGate, invalidatePackageCache, isProjectTrusted, packageNameForPath, setPackageAgentsGate, setPackageWorkflowsGate, setProjectTrusted } from "./package-resources.js";
51
+ import { checkAdmission, describeBlock, mayPark, modelRef, QUOTA_RESTART_GRACE_MS } from "./quota-admission.js";
52
+ import { SubagentScheduler } from "./schedule.js";
53
+ import { resolveStorePath, ScheduleStore } from "./schedule-store.js";
54
+ import { applyAndEmitLoaded, loadSettings, type SubagentsSettings, saveAndEmitChanged, type ToolDescriptionMode } from "./settings.js";
55
+ import { hostStartupErrorClass } from "./startup-error.js";
56
+ import { getForegroundOutcomeNote, getStatusNote, partialOutputSuffix } from "./status-note.js";
57
+ import { type AgentConfig, type AgentInvocation, type AgentMentionMode, type AgentRecord, type JoinMode, type NotificationDetails, type SubagentType, type ViewerMarkdownMode, type WidgetMode } from "./types.js";
58
+ import { createMentionProvider, mentionRoster, type TypeInfo } from "./ui/agent-mention.js";
59
+ import { AgentCallView, AgentResultView, type AgentRowLive, keepRowTicking, keepTicking } from "./ui/agent-row.js";
60
+ import {
61
+ type AgentActivity,
62
+ type AgentDetails,
63
+ AgentWidget,
64
+ buildInvocationTags,
65
+ describeActivity,
66
+ formatCost,
67
+ formatDuration,
68
+ formatMs,
69
+ formatTokens,
70
+ getDisplayName,
71
+ getPromptModeLabel,
72
+ SPINNER,
73
+ type UICtx,
74
+ } from "./ui/agent-widget.js";
75
+ import { ConversationViewer, getConversationOverlayOptions, type ViewerBackgroundAction } from "./ui/conversation-viewer.js";
76
+ import { FleetList, type FleetUICtx, type FleetWorkflow } from "./ui/fleet-list.js";
77
+ import { renderNotification } from "./ui/notification-view.js";
78
+ import { showSchedulesMenu } from "./ui/schedule-menu.js";
79
+ import { selectItem } from "./ui/select-item.js";
80
+ import { renderWorkflowCard, renderWorkflowEntryCard } from "./ui/workflow-card.js";
81
+ import { openWorkflowFromFleet, showWorkflowsMenu, type WorkflowMenuDeps } from "./ui/workflow-menu.js";
82
+ import { getLifetimeCost, getLifetimeTotal, getSessionContextPercent, type LifetimeUsage, PendingUsagePool, toReportedUsage } from "./usage.js";
83
+ import { decideWorkflowCollision, FOREIGN_WORKFLOW_TOOL_NAMES } from "./workflow/collisions.js";
84
+ import { WORKFLOW_ENTRY_TYPE, type WorkflowEntryData, workflowEntryData } from "./workflow/entry.js";
85
+ import { createWorkflowHost } from "./workflow/host.js";
86
+ import { appendJournal, readJournal, type WorkflowJournalEntry } from "./workflow/journal.js";
87
+ import { extractMeta, type WorkflowMeta, workflowCallName } from "./workflow/meta.js";
88
+ import { elapsedMs } from "./workflow/progress.js";
89
+ import { runWorkflow } from "./workflow/runtime.js";
90
+ import { resolveWorkflowScript } from "./workflow/saved.js";
91
+ import { completeWorkflowTask, createWorkflowTask, failWorkflowTask, formatWorkflowNotification, resolveResumeTarget, updateWorkflowProgressBatch, type WorkflowTask, workflowResultText, workflowRunId } from "./workflow/task.js";
92
+ import { fullWorkflowToolDescription } from "./workflow/tool-description.js";
93
+ import { isWorktreeIsolationEnabled, setWorktreeIsolationEnabled } from "./worktree.js";
94
+ import { escapeXml } from "./xml.js";
95
+
96
+ // ---- Shared helpers ----
97
+
98
+ /** Tool execute return value for a text response. */
99
+ function textResult(msg: string, details?: AgentDetails) {
100
+ return { content: [{ type: "text" as const, text: msg }], details: details as any };
101
+ }
102
+
103
+ /** Format an agent's lifetime token total, or "" when zero. */
104
+ function formatLifetimeTokens(o: { lifetimeUsage: LifetimeUsage }): string {
105
+ const t = getLifetimeTotal(o.lifetimeUsage);
106
+ return t > 0 ? formatTokens(t) : "";
107
+ }
108
+
109
+ /**
110
+ * Create an AgentActivity state and spawn callbacks for tracking tool usage.
111
+ * Used by both foreground and background paths to avoid duplication.
112
+ */
113
+ function createActivityTracker(maxTurns?: number, onStreamUpdate?: () => void, clock: Clock = realClock) {
114
+ const state: AgentActivity = {
115
+ activeTools: new Map(),
116
+ toolUses: 0,
117
+ turnCount: 1,
118
+ maxTurns,
119
+ responseText: "",
120
+ session: undefined,
121
+ };
122
+
123
+ const callbacks = {
124
+ onToolActivity: (activity: { type: "start" | "end"; toolName: string }) => {
125
+ if (activity.type === "start") {
126
+ state.activeTools.set(activity.toolName + "_" + Date.now(), activity.toolName);
127
+ } else {
128
+ for (const [key, name] of state.activeTools) {
129
+ if (name === activity.toolName) { state.activeTools.delete(key); break; }
130
+ }
131
+ state.toolUses++;
132
+ }
133
+ onStreamUpdate?.();
134
+ },
135
+ onTextDelta: (_delta: string, fullText: string) => {
136
+ state.responseText = fullText;
137
+ onStreamUpdate?.();
138
+ },
139
+ onTurnEnd: (turnCount: number) => {
140
+ state.turnCount = turnCount;
141
+ onStreamUpdate?.();
142
+ },
143
+ onModelCall: (event: ModelCallEvent) => {
144
+ state.modelCall = applyModelCallEvent(state.modelCall, event, clock.now());
145
+ onStreamUpdate?.();
146
+ },
147
+ onSessionCreated: (session: any) => {
148
+ state.session = session;
149
+ },
150
+ // Spend is accumulated on the AgentRecord (agent-manager), which is what
151
+ // every surface reads; this callback exists here only to repaint on it.
152
+ onAssistantUsage: (_usage: LifetimeUsage) => {
153
+ onStreamUpdate?.();
154
+ },
155
+ };
156
+
157
+ return { state, callbacks };
158
+ }
159
+
160
+ /**
161
+ * Advertised thinking levels, ordered to mirror pi-ai's EXTENDED_THINKING_LEVELS
162
+ * (`off` + every `ThinkingLevel`). Single source for the Agent tool description,
163
+ * the generated-agent template, and the `/agents` wizard so these lists can't
164
+ * drift behind pi again (#147). Availability of any level still depends on the
165
+ * host pi version and the selected model — pi clamps unsupported levels down.
166
+ */
167
+ const THINKING_LEVELS = ["off", "minimal", "low", "medium", "high", "xhigh", "max"] as const;
168
+
169
+ /** Human-readable status label for agent completion. */
170
+ function getStatusLabel(status: string, error?: string): string {
171
+ switch (status) {
172
+ case "error": return `Error: ${error ?? "unknown"}`;
173
+ case "aborted": return "Aborted (max turns exceeded)";
174
+ case "steered": return "Wrapped up (turn limit)";
175
+ case "stopped": return "Stopped";
176
+ default: return "Done";
177
+ }
178
+ }
179
+
180
+ /** Format a structured task notification matching Claude Code's <task-notification> XML. */
181
+ function formatTaskNotification(record: AgentRecord, resultMaxLen: number, showCost = false): string {
182
+ const status = getStatusLabel(record.status, record.error);
183
+ const durationMs = record.completedAt ? record.completedAt - record.startedAt : 0;
184
+ const totalTokens = getLifetimeTotal(record.lifetimeUsage);
185
+ const contextPercent = getSessionContextPercent(record.session);
186
+ const ctxXml = contextPercent !== null ? `<context_percent>${Math.round(contextPercent)}</context_percent>` : "";
187
+ const compactXml = record.compactionCount ? `<compactions>${record.compactionCount}</compactions>` : "";
188
+ // Only under `showCost`: this is LLM context, and a figure the orchestrator
189
+ // did not ask for is a figure it may start reporting unprompted.
190
+ const cost = showCost ? getLifetimeCost(record.lifetimeUsage) : 0;
191
+ const costXml = cost > 0 ? `<estimated_cost_usd>${cost.toFixed(4)}</estimated_cost_usd>` : "";
192
+
193
+ const resultPreview = record.result
194
+ ? record.result.length > resultMaxLen
195
+ ? record.result.slice(0, resultMaxLen) + "\n...(truncated, use get_subagent_result for full output)"
196
+ : record.result
197
+ : "No output.";
198
+
199
+ return [
200
+ `<task-notification>`,
201
+ `<task-id>${record.id}</task-id>`,
202
+ record.toolCallId ? `<tool-use-id>${escapeXml(record.toolCallId)}</tool-use-id>` : null,
203
+ record.outputFile ? `<output-file>${escapeXml(record.outputFile)}</output-file>` : null,
204
+ `<status>${escapeXml(status)}</status>`,
205
+ `<summary>Agent "${escapeXml(record.description)}" ${record.status}${getStatusNote(record.status)}</summary>`,
206
+ `<result>${escapeXml(resultPreview)}</result>`,
207
+ `<usage><total_tokens>${totalTokens}</total_tokens><tool_uses>${record.toolUses}</tool_uses>${ctxXml}${compactXml}${costXml}<duration_ms>${durationMs}</duration_ms></usage>`,
208
+ `</task-notification>`,
209
+ ].filter(Boolean).join('\n');
210
+ }
211
+
212
+ /** Build AgentDetails from a base + record-specific fields. */
213
+ function buildDetails(
214
+ base: Pick<AgentDetails, "displayName" | "description" | "subagentType" | "modelName" | "tags">,
215
+ record: { toolUses: number; startedAt: number; completedAt?: number; status: string; error?: string; id?: string; session?: any; outputFile?: string; lifetimeUsage: LifetimeUsage },
216
+ activity?: AgentActivity,
217
+ overrides?: Partial<AgentDetails>,
218
+ ): AgentDetails {
219
+ return {
220
+ ...base,
221
+ toolUses: record.toolUses,
222
+ tokens: formatLifetimeTokens(record),
223
+ // Raw, and unconditional: `tokens` is preformatted because it is one stat,
224
+ // but a cost is joined by "·" in one surface, "," in another and "|" in a
225
+ // third — so it travels as a number and each renderer punctuates its own.
226
+ cost: getLifetimeCost(record.lifetimeUsage),
227
+ turnCount: activity?.turnCount,
228
+ maxTurns: activity?.maxTurns,
229
+ durationMs: (record.completedAt ?? Date.now()) - record.startedAt,
230
+ status: record.status as AgentDetails["status"],
231
+ agentId: record.id,
232
+ error: record.error,
233
+ outputFile: record.outputFile,
234
+ ...overrides,
235
+ };
236
+ }
237
+
238
+ /** Build notification details for the custom message renderer. */
239
+ function buildNotificationDetails(record: AgentRecord, resultMaxLen: number, activity?: AgentActivity): NotificationDetails {
240
+ const totalTokens = getLifetimeTotal(record.lifetimeUsage);
241
+
242
+ return {
243
+ id: record.id,
244
+ description: record.description,
245
+ status: record.status,
246
+ toolUses: record.toolUses,
247
+ turnCount: activity?.turnCount ?? 0,
248
+ maxTurns: activity?.maxTurns,
249
+ totalTokens,
250
+ // Carried unconditionally; the renderer gates on the setting. Details are
251
+ // data, and a notification rendered before a mid-session toggle should not
252
+ // be stuck with the old answer.
253
+ totalCost: getLifetimeCost(record.lifetimeUsage),
254
+ durationMs: record.completedAt ? record.completedAt - record.startedAt : 0,
255
+ outputFile: record.outputFile,
256
+ error: record.error,
257
+ resultPreview: record.result
258
+ ? record.result.length > resultMaxLen
259
+ ? record.result.slice(0, resultMaxLen) + "…"
260
+ : record.result
261
+ : "No output.",
262
+ };
263
+ }
264
+
265
+ /**
266
+ * Format an agent's tool scope for the Agent tool description.
267
+ *
268
+ * This suffix names the BUILT-IN scope and flags, without listing them, that
269
+ * extension tools load too — they are resolved when the agent runs (extensions
270
+ * can register asynchronously), so they cannot be enumerated while the
271
+ * description is being built. An agent with `tools: "*, ext:mcp/search"`
272
+ * therefore renders "* + extension tools".
273
+ *
274
+ * Two distinctions matter, both of them capability claims the orchestrator acts on:
275
+ *
276
+ * - absent vs empty. `builtinToolNames: undefined` means the agent never narrowed
277
+ * its tools (the shipped defaults); `[]` is what `tools: none` and an `ext:`-only
278
+ * `tools:` parse to, and the runtime really does hand those agents no built-ins.
279
+ * Rendering both "*" tells the orchestrator a tool-less agent can run `bash`.
280
+ * - empty-with-extensions vs empty-without. Zero built-ins does NOT imply zero
281
+ * tools: `tools: none` alongside `extensions:` still surfaces every extension
282
+ * tool (see test/fixtures/.pi/agents/tools-none.md, which expects three). Calling
283
+ * that "none" understates the agent instead of overstating it — better, but still
284
+ * wrong, and it would route work away from the only agent able to do it. "none"
285
+ * is therefore reserved for agents that genuinely can call nothing: `isolated`
286
+ * agents and those with `extensions: false`.
287
+ */
288
+ export function formatToolsSuffix(cfg: AgentConfig | undefined): string {
289
+ const tools = cfg?.builtinToolNames;
290
+ // `isolated` overrides extensions to false in the runner, so both mean the
291
+ // agent has no extension tools.
292
+ const noExtensionTools = cfg?.isolated === true || cfg?.extensions === false;
293
+ if (tools && tools.length === 0) {
294
+ return noExtensionTools ? "none" : "no built-ins, extension tools only";
295
+ }
296
+ const isFullSet = !tools
297
+ || (tools.length === BUILTIN_TOOL_NAMES.length && BUILTIN_TOOL_NAMES.every((t) => tools.includes(t)));
298
+ const builtins = isFullSet ? "*" : tools.join(", ");
299
+ // Extension tools resolve at run time, so they cannot be enumerated here, but
300
+ // omitting the fact reads as "built-ins only": an agent that can search the
301
+ // web through an extension then looks web-less, and the work gets routed away.
302
+ return noExtensionTools ? builtins : `${builtins} + extension tools`;
303
+ }
304
+
305
+ /** CLI flag that runs a workflow script at session start. */
306
+ export const WORKFLOW_FILE_FLAG = "subagents-workflow-file";
307
+
308
+ /**
309
+ * Re-exported from where they now live, because this is where they were
310
+ * defined and a consumer (or a test) that matched a session entry on
311
+ * {@link WORKFLOW_ENTRY_TYPE} imports it from here.
312
+ */
313
+ export { FOREIGN_WORKFLOW_TOOL_NAMES, WORKFLOW_ENTRY_TYPE, type WorkflowEntryData, workflowEntryData };
314
+
315
+ /** Statuses the Agent row has a rendering for. */
316
+ const AGENT_ROW_STATUSES = new Set(["running", "background", "completed", "steered", "aborted", "stopped", "error"]);
317
+
318
+ export type { Clock, TimerHandle } from "./clock.js";
319
+
320
+ /** Optional host dependencies, shared by the scheduler and completion delivery. */
321
+ export interface SubagentsExtensionOptions {
322
+ clock?: Clock;
323
+ }
324
+
325
+ /** Create the extension with an optional host-owned clock; the default export uses native time. */
326
+ export function createSubagentsExtension(options: SubagentsExtensionOptions = {}): ExtensionFactory {
327
+ return pi => activateSubagents(pi, options);
328
+ }
329
+
330
+ export default createSubagentsExtension();
331
+
332
+ function activateSubagents(pi: ExtensionAPI, options: SubagentsExtensionOptions) {
333
+ const clock = options.clock ?? realClock;
334
+ // Child AgentSessions load normal extensions. Re-entering this extension there
335
+ // would create another manager and leak handlers. Nested orchestration is
336
+ // injected as scoped custom tools by the existing manager instead.
337
+ if (inChildSessionContext()) return;
338
+
339
+ // ---- Register custom notification renderer ----
340
+ // Core collapses a custom message to this renderer's first line and draws the
341
+ // ▸/▾ gutter itself; see ui/notification-view.ts.
342
+ pi.registerMessageRenderer<NotificationDetails>(
343
+ "subagent-notification",
344
+ (message, { expanded }, theme) => {
345
+ const d = message.details;
346
+ if (!d) return undefined;
347
+ return renderNotification(d, expanded, theme, showCost);
348
+ }
349
+ );
350
+
351
+ // ---- Workflow run rendered as a session entry ----
352
+ // A workflow launched from the CLI flag has no tool call to hang its result
353
+ // card on, so it renders here instead — through the SAME layout the tool
354
+ // result uses, not a second one. Custom entries with no registered renderer
355
+ // are silently dropped by the host, which is why this is registered at
356
+ // activation rather than lazily.
357
+ pi.registerEntryRenderer<WorkflowEntryData>(WORKFLOW_ENTRY_TYPE, (entry, _options, theme) =>
358
+ renderWorkflowEntryCard(entry.data, theme));
359
+
360
+ // Registered at activation; READ from session_start. The host applies CLI
361
+ // values after every extension factory has run, so `getFlag` here would only
362
+ // ever hand back the registered default (see the read site below).
363
+ pi.registerFlag(WORKFLOW_FILE_FLAG, {
364
+ type: "string",
365
+ description:
366
+ `Run a workflow script at startup: --${WORKFLOW_FILE_FLAG}=<path>. ` +
367
+ "Use the `=` form — the space form consumes the next argument, which would swallow a following prompt.",
368
+ });
369
+
370
+ // Fuzzy model names ("opus") prefer what the user enabled in pi's settings.
371
+ setModelScopeProvider(registry => resolveEnabledModels(readEnabledModels(process.cwd()), registry, process.cwd()));
372
+
373
+ // Read directly rather than waiting for applyAndEmitLoaded below: these decide
374
+ // the initial load, which happens hundreds of lines before settings are applied.
375
+ const bootSettings = loadSettings(process.cwd());
376
+ let strictAgentFiles = bootSettings.strictAgentFiles === true;
377
+ // Strict agent files fail a bind by throwing the host's `ExtensionStartupError`
378
+ // from `session_start`. A host without that class would silently degrade to
379
+ // warnings, so refuse here, before any project agent definition is read.
380
+ const StartupError = hostStartupErrorClass();
381
+ if (strictAgentFiles && !StartupError) {
382
+ throw new Error(
383
+ "[pi-subagents] strictAgentFiles requires a pi host that exports ExtensionStartupError, which this host lacks. " +
384
+ "Upgrade pi, or set strictAgentFiles to false.",
385
+ );
386
+ }
387
+
388
+ // Seed the package gates from the same merged settings, before the first load
389
+ // below reads them. `applyAndEmitLoaded` sets them again through the appliers;
390
+ // doing it here as well is what makes the *initial* registry honour them.
391
+ setPackageAgentsGate(bootSettings.packageAgents);
392
+ setPackageWorkflowsGate(bootSettings.packageWorkflows);
393
+ // Project trust is unknown at activation: pi's canonical answer exists only on
394
+ // `ctx` at `session_start`. Start untrusted, so this load reads global and
395
+ // globally configured package agents only; `session_start` reloads the
396
+ // project's and re-registers the `Agent` tool with the result.
397
+ setProjectTrusted(false);
398
+
399
+ /** Reload agents from package/global/project agent dirs and merge with defaults (called on init and each Agent invocation). */
400
+ const reloadCustomAgents = (strict = false) => {
401
+ const userAgents = loadCustomAgents(process.cwd(), strict);
402
+ registerAgents(userAgents);
403
+ };
404
+
405
+ // Activation load: global and package agents only (project trust is not yet
406
+ // known). The first canonical load on `session_start` is the strict one; a bad
407
+ // edit mid-session must not kill the session on the next unrelated spawn, so
408
+ // every later reload keeps warning.
409
+ reloadCustomAgents(strictAgentFiles);
410
+ let canonicalAgentsLoaded = false;
411
+
412
+ // ---- Agent activity tracking + widget ----
413
+ const agentActivity = new Map<string, AgentActivity>();
414
+
415
+ // ---- Usage reporting (both off by default; see SubagentsSettings) ----
416
+ /** Attach subagent spend to tool results, so the parent session counts it. */
417
+ let reportUsage = false;
418
+ function isReportUsageEnabled(): boolean { return reportUsage; }
419
+ function setReportUsage(b: boolean): void {
420
+ reportUsage = b;
421
+ // Whatever accumulated while it was on is stale the moment it goes off:
422
+ // draining it later would bill the parent for a window the user opted out
423
+ // of, in one lump, on some unrelated later tool call.
424
+ if (!b) pendingUsage.drain();
425
+ }
426
+ /** Show `~$X` next to token counts in the subagent surfaces. */
427
+ let showCost = false;
428
+ function isShowCostEnabled(): boolean { return showCost; }
429
+ function setShowCost(b: boolean): void { showCost = b; widget.update(); fleet.update(); }
430
+ /** Name the model and thinking level on the widget's running rows. */
431
+ let showModel = false;
432
+ function isShowModelEnabled(): boolean { return showModel; }
433
+ function setShowModel(b: boolean): void { showModel = b; widget.update(); fleet.update(); }
434
+ /**
435
+ * How much of the conversation viewer renders as Markdown. Read through a
436
+ * getter by the viewer rather than captured like `showCost`, because the
437
+ * viewer's `m` key writes back here while the overlay is on screen.
438
+ */
439
+ let viewerMarkdown: ViewerMarkdownMode = "assistant";
440
+ function getViewerMarkdown(): ViewerMarkdownMode { return viewerMarkdown; }
441
+ function setViewerMarkdown(mode: ViewerMarkdownMode): void { viewerMarkdown = mode; }
442
+ /**
443
+ * The viewer's `m` key, from either entry point: set the mode and persist it,
444
+ * so the key and `/agents → Settings` stay one setting rather than one per
445
+ * entry point. `ctx` carries only the warning a failed write notifies with,
446
+ * and the fleet list may be acting without one.
447
+ */
448
+ function chooseViewerMarkdown(mode: ViewerMarkdownMode, ctx?: ExtensionCommandContext): void {
449
+ setViewerMarkdown(mode);
450
+ persistSettings(ctx, `Viewer markdown set to ${mode}`);
451
+ }
452
+ const pendingUsage = new PendingUsagePool();
453
+
454
+ // ---- Completion delivery ----
455
+ // Every completion notification goes through the outbox, which sends what is
456
+ // pending as one message at the parent's next boundary (see
457
+ // completion-outbox.ts). Each is built when the agent finishes, and reading
458
+ // the result (get_subagent_result, rpc consume) cancels it before it is sent.
459
+ const outbox = new CompletionOutbox(
460
+ (message) => pi.sendMessage<NotificationDetails>({
461
+ customType: "subagent-notification",
462
+ content: message.content,
463
+ display: true,
464
+ details: message.details,
465
+ }, { deliverAs: "steer", triggerTurn: true }),
466
+ () => currentCtx?.signal !== undefined,
467
+ options.clock,
468
+ );
469
+ // A queued result wait must observe completion before its held notification
470
+ // can fire, so successful waits can still suppress that redundant nudge.
471
+ const QUEUE_WAIT_POLL_MS = Math.floor(NOTIFICATION_HOLD_MS / 4);
472
+
473
+ function sendIndividualNudge(record: AgentRecord) {
474
+ agentActivity.delete(record.id);
475
+ widget.markFinished(record.id);
476
+ fleet.onAgentFinished(record.id);
477
+ // Built now, so a resume before the send cannot rewrite it. A record that
478
+ // cannot be formatted is skipped, as a failed send always was.
479
+ try {
480
+ const footer = record.outputFile ? `\nFull transcript available at: ${record.outputFile}` : '';
481
+ const message = {
482
+ content: formatTaskNotification(record, 500, showCost) + footer,
483
+ details: buildNotificationDetails(record, 500, agentActivity.get(record.id)),
484
+ };
485
+ outbox.add({ ids: [record.id], build: () => message });
486
+ } catch { /* ignore stale completion side-effect errors */ }
487
+ widget.update();
488
+ }
489
+
490
+ // ---- Group join manager (`group` join mode only) ----
491
+ const groupJoin = new GroupJoinManager(
492
+ (records, partial) => {
493
+ for (const r of records) { agentActivity.delete(r.id); widget.markFinished(r.id); fleet.onAgentFinished(r.id); }
494
+
495
+ const members = records.filter(r => !r.resultConsumed).flatMap(r => {
496
+ try {
497
+ return [{ id: r.id, text: formatTaskNotification(r, 300, showCost), details: buildNotificationDetails(r, 300, agentActivity.get(r.id)) }];
498
+ } catch { return []; /* as in sendIndividualNudge */ }
499
+ });
500
+ if (members.length > 0) {
501
+ outbox.add({
502
+ ids: members.map(m => m.id),
503
+ build: (ids) => {
504
+ const unread = members.filter(m => ids.includes(m.id));
505
+ const notifications = unread.map(m => m.text).join('\n\n');
506
+ const label = partial
507
+ ? `${unread.length} agent(s) finished (partial — others still running)`
508
+ : `${unread.length} agent(s) finished`;
509
+
510
+ const [first, ...rest] = unread;
511
+ const details = first.details;
512
+ if (rest.length > 0) details.others = rest.map(m => m.details);
513
+ return {
514
+ content: `Background agent group completed: ${label}\n\n${notifications}\n\nUse get_subagent_result for full output.`,
515
+ details,
516
+ };
517
+ },
518
+ });
519
+ }
520
+ widget.update();
521
+ },
522
+ 30_000,
523
+ );
524
+
525
+ /** Helper: build event data for lifecycle events from an AgentRecord. */
526
+ function buildEventData(record: AgentRecord) {
527
+ const durationMs = record.completedAt ? record.completedAt - record.startedAt : Date.now() - record.startedAt;
528
+ // All three fields are lifetime-accumulated (Σ over every assistant message_end),
529
+ // so they survive compaction together — input + output ≤ total always.
530
+ // tokens is omitted when nothing was ever produced (e.g. agent errored before
531
+ // any message_end fired), preserving prior payload shape.
532
+ const u = record.lifetimeUsage;
533
+ const total = getLifetimeTotal(u);
534
+ const tokens = total > 0
535
+ ? { input: u.input, output: u.output, total }
536
+ : undefined;
537
+ // The whole run's spend as a pi `Usage` — pi's convention for handing spend
538
+ // to a consumer, so `usage.cost.total` and `usage.cacheRead` are where a
539
+ // listener already expects them and anything pi adds to `Usage` arrives
540
+ // without a change here. Omitted when nothing was spent, so "spent nothing"
541
+ // and "never ran" stay distinguishable. Ungated by `showCost`: that setting
542
+ // governs what a human is shown, not what the event carries.
543
+ //
544
+ // `tokens` above is the other convention, kept as it shipped: a flat view
545
+ // model like pi's own `SessionStats`, carrying the DISPLAY total, which
546
+ // excludes cacheRead (#38). The two answer different questions and neither
547
+ // derives from the other.
548
+ const usage = toReportedUsage(u);
549
+ return {
550
+ id: record.id,
551
+ type: record.type,
552
+ description: record.description,
553
+ result: record.result,
554
+ error: record.error,
555
+ status: record.status,
556
+ toolUses: record.toolUses,
557
+ durationMs,
558
+ tokens,
559
+ usage,
560
+ };
561
+ }
562
+
563
+ /**
564
+ * Set when a session switch left background agents running in this (now
565
+ * orphaned) instance; their results are spooled for that session to pick up.
566
+ */
567
+ let detachedSessionFile: string | undefined;
568
+
569
+ function spoolDetachedResult(sessionFile: string, record: AgentRecord) {
570
+ if (record.resultConsumed) return;
571
+ const footer = record.outputFile ? `\nFull transcript available at: ${record.outputFile}` : "";
572
+ try {
573
+ spoolDetached(sessionFile, {
574
+ id: record.id,
575
+ content: formatTaskNotification(record, 500, showCost) + footer,
576
+ details: buildNotificationDetails(record, 500),
577
+ record: {
578
+ id: record.id, type: record.type, description: record.description,
579
+ status: record.status, result: record.result, error: record.error,
580
+ startedAt: record.startedAt, completedAt: record.completedAt,
581
+ },
582
+ });
583
+ } catch { /* spool unwritable: the transcript file still has the result */ }
584
+ }
585
+
586
+ /** Re-announce agents that finished while this session was switched away. */
587
+ function deliverDetached(sessionFile: string) {
588
+ for (const result of drainDetached(sessionFile)) deliverDetachedResult(result);
589
+ }
590
+
591
+ function deliverDetachedResult(result: DetachedResult) {
592
+ try {
593
+ if (result.record) pi.appendEntry("subagents:record", result.record);
594
+ const details = result.details as NotificationDetails;
595
+ outbox.add({
596
+ ids: [details.id, ...(details.others?.map(other => other.id) ?? [])],
597
+ build: () => ({ content: result.content, details }),
598
+ });
599
+ } catch { /* session already gone again; the transcript file still has the result */ }
600
+ }
601
+
602
+ let unwatchDetached: (() => void) | undefined;
603
+
604
+ // Background completion: route through group join or send individual nudge
605
+ const manager = new AgentManager((record) => {
606
+ // Owned children — nested, or a workflow's — report only through their
607
+ // owner: the parent's scoped tools, or the workflow's card, notification
608
+ // and dialog. Keep them out of top-level lifecycle, transcript,
609
+ // notification, and UI channels.
610
+ if (!isTopLevelAgent(record)) return;
611
+
612
+ // Orphaned by a session switch: this closure's `pi` is stale, so hand the
613
+ // result to the session it belongs to instead of announcing it here.
614
+ if (detachedSessionFile !== undefined) {
615
+ unregisterRunningDetached(detachedSessionFile, record.id);
616
+ spoolDetachedResult(detachedSessionFile, record);
617
+ return;
618
+ }
619
+
620
+ // Emit lifecycle event based on terminal status
621
+ const isError = record.status === "error" || record.status === "stopped" || record.status === "aborted";
622
+ const eventData = buildEventData(record);
623
+ if (isError) {
624
+ pi.events.emit("subagents:failed", eventData);
625
+ } else {
626
+ pi.events.emit("subagents:completed", eventData);
627
+ }
628
+
629
+ // Persist final record for cross-extension history reconstruction
630
+ pi.appendEntry("subagents:record", {
631
+ id: record.id, type: record.type, description: record.description,
632
+ status: record.status, result: record.result, error: record.error,
633
+ startedAt: record.startedAt, completedAt: record.completedAt,
634
+ });
635
+
636
+ // Skip notification if result was already consumed via get_subagent_result
637
+ if (record.resultConsumed) {
638
+ agentActivity.delete(record.id);
639
+ widget.markFinished(record.id);
640
+ fleet.onAgentFinished(record.id);
641
+ widget.update();
642
+ return;
643
+ }
644
+
645
+ // A `group` spawn whose turn has not ended yet: its group is registered at
646
+ // that turn's end, which picks this completion up retroactively.
647
+ if (turnGroupSpawns.includes(record.id)) {
648
+ widget.update();
649
+ return;
650
+ }
651
+
652
+ const result = groupJoin.onAgentComplete(record);
653
+ if (result === 'pass') {
654
+ sendIndividualNudge(record);
655
+ }
656
+ // 'held' → do nothing, group will fire later
657
+ // 'delivered' → group callback already fired
658
+ widget.update();
659
+ }, undefined, (record) => {
660
+ if (!isTopLevelAgent(record)) return;
661
+ // Agent-tool spawns refresh these surfaces in their tool handler, but RPC
662
+ // and scheduler spawns enter through the manager directly.
663
+ if (currentCtx?.hasUI) {
664
+ widget.ensureTimer();
665
+ widget.update();
666
+ fleet.ensureTimer();
667
+ fleet.update();
668
+ }
669
+ // Emit started event when agent transitions to running (including from queue)
670
+ pi.events.emit("subagents:started", {
671
+ id: record.id,
672
+ type: record.type,
673
+ description: record.description,
674
+ });
675
+ }, (record, info) => {
676
+ if (!isTopLevelAgent(record)) return;
677
+ // Emit compacted event when agent's session compacts (preserves count on record).
678
+ pi.events.emit("subagents:compacted", {
679
+ id: record.id,
680
+ type: record.type,
681
+ description: record.description,
682
+ reason: info.reason,
683
+ tokensBefore: info.tokensBefore,
684
+ compactionCount: record.compactionCount,
685
+ });
686
+ }, (record, usage) => {
687
+ // Every assistant message from every agent — nested included, exactly once.
688
+ // Parked here until a tool result can carry it back to the parent session;
689
+ // see `PendingUsagePool`. Skipped entirely when the feature is off, so no
690
+ // pool grows in a session that will never drain it.
691
+ if (reportUsage) pendingUsage.add(usage);
692
+ // The same message, live, for anyone keeping their own ledger — a footer
693
+ // that breaks spend down by agent type and thinking level, say. This is the
694
+ // one place every agent's spend passes through, which is why it is emitted
695
+ // here and not from the lifecycle events: those fire for top-level agents
696
+ // only, once, at the end, so a workflow's children and nested agents would
697
+ // never be counted, and a running agent would read as free until it
698
+ // stopped. Deliberately ungated by `reportUsage` — that decides whether the
699
+ // parent session's own totals include this spend, not whether it happened.
700
+ //
701
+ // `model` and `thinking` are read off the record per message, not captured
702
+ // at spawn: the record is authoritative only once the session exists and pi
703
+ // has clamped the level to the model (see `onSessionCreated`).
704
+ const reported = toReportedUsage(usage);
705
+ if (!reported) return;
706
+ // A key whose value is unknown is omitted, never `undefined`: `model` and
707
+ // `thinking` until the session has reported them, `parentAgentId` and
708
+ // `workflowId` on a top-level agent.
709
+ const invocation = record.invocation;
710
+ pi.events.emit("subagents:usage", {
711
+ id: record.id,
712
+ type: record.type,
713
+ description: record.description,
714
+ ...(invocation?.modelId !== undefined ? { model: invocation.modelId } : {}),
715
+ ...(invocation?.thinking !== undefined ? { thinking: invocation.thinking } : {}),
716
+ // Set only when the caller did not get the level it asked for (pi
717
+ // clamped it to the model, or an agent file outranked it), so a ledger
718
+ // can show `low (asked minimal)` instead of silently relabelling.
719
+ ...(invocation?.requestedThinking !== undefined ? { requestedThinking: invocation.requestedThinking } : {}),
720
+ depth: record.depth ?? 1,
721
+ ...(record.parentAgentId !== undefined ? { parentAgentId: record.parentAgentId } : {}),
722
+ ...(record.workflowId !== undefined ? { workflowId: record.workflowId } : {}),
723
+ usage: reported,
724
+ });
725
+ }, (record, message) => {
726
+ if (!isTopLevelAgent(record)) return;
727
+ pi.events.emit("subagents:steered", { id: record.id, message });
728
+ }, clock);
729
+
730
+ // Expose manager via Symbol.for() global registry for cross-package access.
731
+ // Standard Node.js pattern for cross-package singletons (used by OpenTelemetry, etc.).
732
+ // Documented for callers in docs/rpc.md ("The manager registry").
733
+ //
734
+ // Claim the slot only if it's free: subagent sessions re-activate this
735
+ // extension in the same process (session.bindExtensions in agent-runner.ts),
736
+ // and unconditionally overwriting would point the registry at a short-lived
737
+ // child manager — and the child's shutdown would then delete the root
738
+ // session's entry. The first activation (the root session) wins; child
739
+ // activations leave it alone.
740
+ const MANAGER_KEY = Symbol.for("pi-subagents:manager");
741
+ // Process-external callers may supply arbitrary options. Nested ownership and
742
+ // config-root metadata are internal capabilities issued only by scoped tools.
743
+ /**
744
+ * Resolve the agent type and spawn. Trusts its options — every caller must
745
+ * either be in-process or have gone through `spawnTopLevel` first.
746
+ */
747
+ const spawnResolved = (piRef: any, ctxRef: any, type: string, prompt: string, options: any) => {
748
+ // Cross-extension callers get the same dispatch contract as the LLM (#183).
749
+ // The RPC layer already throws for an unresolvable model rather than falling
750
+ // back silently; a bad agent type should not be quieter. Throws become error
751
+ // envelopes at the RPC boundary. Reload first so an agent file added mid
752
+ // session is spawnable here too, not only through the Agent tool.
753
+ reloadCustomAgents();
754
+ const dispatch = resolveSpawnType(type);
755
+ if (!dispatch.ok) throw new Error(dispatch.message);
756
+ // Every programmatic spawn lands here — cross-extension RPC, both `@handle`
757
+ // mention paths, and the `Symbol.for("pi-subagents:manager")` registry — and
758
+ // none came through the Agent tool, which is where the UI activity tracker is
759
+ // otherwise created. Without one the widget and FleetView have no tool name
760
+ // and no turn count, so the row reads `thinking…` for the agent's whole life
761
+ // while the header's tool-use count climbs beside it (#181). Double-tracking
762
+ // is not possible: the Agent tool calls `manager.spawn` directly. The tracker
763
+ // callbacks are the funnel's own — a caller's are not honoured, since a
764
+ // half-wired tracker renders worse than none.
765
+ //
766
+ // The turn limit is resolved rather than read off `options`, which a mention
767
+ // spawn deliberately omits so the agent's own config can decide: a tracker
768
+ // built with `undefined` renders `↻3` where the Agent tool renders `↻3≤20`.
769
+ // Like the tool's own, it is a prediction — editing the agent file mid-run
770
+ // leaves the displayed ceiling stale.
771
+ const { state, callbacks } = createActivityTracker(resolveEffectiveMaxTurns(dispatch.type, options?.maxTurns), undefined, clock);
772
+ // Repaints are left to the manager's `onStart` callback, which already starts
773
+ // the widget/fleet timers for agents that enter this way.
774
+ const id = manager.spawn(piRef, ctxRef, dispatch.type, prompt, { ...options, ...callbacks });
775
+ agentActivity.set(id, state);
776
+ return id;
777
+ };
778
+
779
+ const spawnTopLevel = (piRef: any, ctxRef: any, type: string, prompt: string, options: any) => {
780
+ const safeOptions = { ...(options ?? {}) };
781
+ delete safeOptions.parentAgentId;
782
+ // Internal too: a forged value would hide an RPC-spawned agent inside
783
+ // someone else's workflow, and take it out of the concurrency pool with it.
784
+ delete safeOptions.workflowId;
785
+ delete safeOptions.depth;
786
+ delete safeOptions.maxSubagentDepth;
787
+ delete safeOptions.configCwd;
788
+ delete safeOptions.configProjectTrusted;
789
+ // Also internal: it names a transcript directory, so a forged value would
790
+ // be a path-traversal primitive.
791
+ delete safeOptions.rootSessionId;
792
+ // Worse than rootSessionId: this one names a file to OPEN and replay as a
793
+ // conversation. Only the mention dispatcher may set it, and only from a
794
+ // path this extension itself recorded — never from anything a caller sent.
795
+ delete safeOptions.resumeSessionFile;
796
+ // Bypasses handle allocation, so a forged value would duplicate a live
797
+ // agent's name and make `@handle` ambiguous. Same rule: dispatcher only.
798
+ delete safeOptions.reclaim;
799
+ // Every spawn through here is DETACHED — the caller gets an id back and
800
+ // awaits nothing. A forged `blocking` would charge it to the foreground
801
+ // pool and could defer it behind a queue whose gate nobody is holding.
802
+ delete safeOptions.blocking;
803
+ return spawnResolved(piRef, ctxRef, type, prompt, safeOptions);
804
+ };
805
+
806
+ /**
807
+ * Resolve a tool's `agent_id` as an id OR a handle, so the model addresses
808
+ * agents by the same names the user types. Ids are tried first, keeping the
809
+ * existing behaviour exact — a handle is only consulted when the string is
810
+ * not an id at all. Only live records: a tombstone has nothing to steer and
811
+ * no result to read. Callers still enforce the nested-ownership rejection.
812
+ */
813
+ const resolveAgentRef = (ref: string): AgentRecord | undefined => {
814
+ const byId = manager.getRecord(ref);
815
+ if (byId) return byId;
816
+ const resolved = manager.resolveMention(ref);
817
+ return resolved?.kind === "live" ? resolved.record : undefined;
818
+ };
819
+
820
+ const registryEntry = {
821
+ waitForAll: () => manager.waitForAll(),
822
+ hasRunning: () => manager.hasRunning(),
823
+ spawn: spawnTopLevel,
824
+ getRecord: (id: string) => {
825
+ const record = manager.getRecord(id);
826
+ return record !== undefined && isTopLevelAgent(record) ? record : undefined;
827
+ },
828
+ };
829
+ const ownsManagerRegistry = (globalThis as any)[MANAGER_KEY] === undefined;
830
+ if (ownsManagerRegistry) {
831
+ (globalThis as any)[MANAGER_KEY] = registryEntry;
832
+ }
833
+
834
+ // --- Cross-extension RPC via pi.events ---
835
+ let currentCtx: ExtensionContext | undefined;
836
+ // RPC handlers + the `subagents:ready` broadcast are wired on `session_start`
837
+ // (a bound lifecycle event), not at factory time. pi runs every extension
838
+ // factory before the `extensions:` filter and only fires lifecycle events for
839
+ // survivors, so a child session that filtered pi-subagents out never reaches
840
+ // session_start — and must not advertise or answer RPC it can't service
841
+ // (currentCtx would stay undefined → spawn always "No active session"). Gating
842
+ // here makes a filtered session behave like an absent one (#142).
843
+ let rpcHandle: RpcHandle | undefined;
844
+ /** Whether the `@handle` autocomplete wrapper has been stacked on pi's provider. */
845
+ let mentionProviderRegistered = false;
846
+
847
+ // ---- Subagent scheduler ----
848
+ // Session-scoped: store is constructed inside session_start once sessionId
849
+ // is available. Mirrors pi-chonky-tasks's session-scoped task store —
850
+ // schedules reset on /new, restore on /resume.
851
+ const scheduler = new SubagentScheduler({ clock: options.clock });
852
+
853
+ function startScheduler(ctx: ExtensionContext) {
854
+ try {
855
+ const sessionId = ctx.sessionManager?.getSessionId?.();
856
+ if (!sessionId) return; // sessionId not yet available — try again on next event
857
+ const path = resolveStorePath(ctx.cwd, sessionId);
858
+ const store = new ScheduleStore(path);
859
+ scheduler.start(pi, ctx, manager, store);
860
+ pi.events.emit("subagents:scheduler_ready", { sessionId, jobCount: store.list().length });
861
+ } catch (err) {
862
+ // Scheduling is non-essential — log and move on so the rest of the
863
+ // extension keeps working if e.g. .pi/ is unwritable.
864
+ console.warn("[pi-subagents] Failed to start scheduler:", err);
865
+ }
866
+ }
867
+
868
+ // Capture ctx from session_start for RPC spawn handler + start the scheduler.
869
+ // This also wires the RPC handlers and broadcasts readiness — on the first
870
+ // bound session_start, so a filtered-out activation never advertises (#142).
871
+ pi.on("session_start", async (_event, ctx) => {
872
+ currentCtx = ctx;
873
+ // Pi resolves project trust before the first session starts, so this is the
874
+ // earliest honest answer. The activation-time load defaulted to untrusted,
875
+ // so a trusted project's `packages[]` only becomes visible now.
876
+ setProjectTrusted(ctx.isProjectTrusted?.() === true);
877
+ // Unconditional, and not folded into the call above: `/reload` and a new or
878
+ // resumed session all land here, and any of them may follow a `pi install`
879
+ // that added a package the cached root list predates.
880
+ invalidatePackageCache();
881
+ // The first load with the canonical trust answer is strict. Latched until it
882
+ // succeeds, so a retried bind is strict again.
883
+ const strictLoad = strictAgentFiles && !canonicalAgentsLoaded;
884
+ try {
885
+ reloadCustomAgents(strictLoad);
886
+ } catch (err) {
887
+ if (strictLoad && StartupError) {
888
+ throw new StartupError(err instanceof Error ? err.message : String(err), { cause: err });
889
+ }
890
+ throw err;
891
+ }
892
+ canonicalAgentsLoaded = true;
893
+ // The type list in the `Agent` description and parameters was built from the
894
+ // activation load; rebuild it from the canonical one before any provider turn.
895
+ refreshAgentTool();
896
+ if (ctx.hasUI) {
897
+ widget.setUICtx(ctx.ui);
898
+ fleet.setUICtx(ctx.ui as any);
899
+ watchHandoffKey(ctx.ui);
900
+ }
901
+ manager.clearCompleted(true);
902
+ const sessionFile = ctx.sessionManager?.getSessionFile?.();
903
+ if (sessionFile) {
904
+ unwatchDetached?.();
905
+ unwatchDetached = watchDetached(sessionFile, () => deliverDetached(sessionFile));
906
+ deliverDetached(sessionFile);
907
+ }
908
+ // Orphans from the previous instance are already registered; show them now.
909
+ fleet.update();
910
+ // Guard mirrors the `!scheduler.isActive()` pattern below: session_start
911
+ // fires once per activation, but a double-bind must not leak listeners.
912
+ if (!rpcHandle) {
913
+ rpcHandle = registerRpcHandlers({
914
+ events: pi.events,
915
+ pi,
916
+ getCtx: () => currentCtx,
917
+ manager: {
918
+ spawn: spawnTopLevel,
919
+ awaitStartup: (id) => manager.awaitStartup(id),
920
+ getRecord: (id) => manager.getRecord(id),
921
+ // Unguarded on purpose: the stop handler now runs the top-level check
922
+ // itself off `getRecord`, and reports the refusal instead of the
923
+ // "Agent not found" a false from here used to be read as.
924
+ abort: (id) => manager.abort(id),
925
+ consumeResult: (id) => {
926
+ const record = resolveAgentRef(id);
927
+ // Same guard as get_subagent_result: a running agent has no result
928
+ // to consume, and its notification is still the caller's only
929
+ // signal that it finished.
930
+ if (!record || record.parentAgentId) return false;
931
+ if (record.status === "running" || record.status === "queued") return false;
932
+ markConsumed(record, clock.now());
933
+ outbox.cancel(record.id);
934
+ return true;
935
+ },
936
+ steer: (id, message) => manager.steer(id, message),
937
+ },
938
+ });
939
+ // Broadcast readiness so extensions loaded alongside us can discover us.
940
+ // Emitting after all factories have run (rather than at factory time)
941
+ // also avoids the race where a consumer loaded after us misses the event.
942
+ pi.events.emit("subagents:ready", {});
943
+ }
944
+ if (isSchedulingEnabled() && !scheduler.isActive()) startScheduler(ctx);
945
+ // Stack `@handle` suggestions on pi's built-in autocomplete. Registered at
946
+ // most once per activation: pi appends wrappers to a list it never prunes,
947
+ // so a second call would layer a duplicate provider on the first. TUI only
948
+ // — print mode has no such method, and RPC mode's is a no-op.
949
+ if (ctx.mode === "tui" && !mentionProviderRegistered) {
950
+ mentionProviderRegistered = true;
951
+ ctx.ui.addAutocompleteProvider(current =>
952
+ createMentionProvider(
953
+ current,
954
+ // Plain text, not renderAgentName: the same label FleetView and the
955
+ // widget show, but the autocomplete description cannot carry ANSI.
956
+ () => mentionRoster(manager, mentionTypes(), type => getConfig(type).displayName),
957
+ isAgentMentionsEnabled,
958
+ ),
959
+ );
960
+ }
961
+ // Last, and only here: CLI flag values are applied by the host AFTER every
962
+ // extension factory has run, so this is the earliest point the real value
963
+ // exists. Detached inside — a workflow must not hold up session startup.
964
+ resolveWorkflowCollisions(ctx);
965
+ runWorkflowFlag(ctx);
966
+ });
967
+
968
+ /** Agent types `@` can start, in the shape the roster wants. */
969
+ const mentionTypes = (): TypeInfo[] =>
970
+ getAvailableTypes().map(name => ({ name, description: getAgentConfig(name)?.description ?? name }));
971
+
972
+ /**
973
+ * `@handle message` typed at the prompt addresses that agent instead of the
974
+ * main model — Claude Code's prompt mention, same grammar (see mention.ts).
975
+ *
976
+ * The handle names the *agent*, not one process, so one syntax covers its
977
+ * whole lifecycle: message it while it runs, resume it once it has finished,
978
+ * start it if it never ran. Everything that isn't an agent mention falls
979
+ * through untouched, which is what keeps `@src/foo.ts summarize this`, a bare
980
+ * `@handle`, and ordinary prose working. A delivered mention costs no
981
+ * main-model turn; the answer arrives through the ordinary completion
982
+ * notification either way.
983
+ */
984
+ pi.on("input", async (event, ctx) => {
985
+ // Never hijack text the extension layer itself submitted (pi.sendMessage,
986
+ // scheduled prompts) — only something a person typed can be a mention.
987
+ if (event.source === "extension" || !isAgentMentionsEnabled()) return { action: "continue" };
988
+ // Claiming the turn is TUI only, matching the `@` completion that teaches
989
+ // the syntax. Pi defaults `session.prompt()` to source "interactive", so a
990
+ // headless `pi -p "@explore …"` reaches here too — and claiming it would
991
+ // answer with silence, which the background hold cannot fix: `handled`
992
+ // returns from prompt() before any turn starts, so the loop that patch wraps
993
+ // never runs (it holds subagents spawned by the Agent tool MID-turn, a
994
+ // different path). The agent would detach, `ctx.ui.notify` is a no-op
995
+ // outside the TUI, and print mode would exit having printed nothing.
996
+ //
997
+ // `model` mode has none of that problem: it queues a reminder and lets the
998
+ // turn run, so the answer is the model's own, printed as usual. It is the
999
+ // only branch allowed to act headlessly; everything else falls through to
1000
+ // the main model exactly as it did before mentions existed.
1001
+ const canDispatchDirectly = ctx.mode === "tui";
1002
+ if (!canDispatchDirectly && getAgentMentionMode() !== "model") return { action: "continue" };
1003
+
1004
+ const mention = parseMention(event.text);
1005
+ if (!mention) return { action: "continue" };
1006
+
1007
+ // `@main` addresses the main conversation, never a subagent — the one name
1008
+ // `assignHandle` refuses to allocate. An explicit escape hatch for text
1009
+ // that would otherwise read as a mention, so the prefix is dropped and the
1010
+ // rest goes to the model with its attachments intact.
1011
+ if (isReservedHandle(mention.handle)) {
1012
+ return { action: "transform", text: mention.message, ...(event.images && { images: event.images }) };
1013
+ }
1014
+
1015
+ // As typed first, so an agent actually called `agent-foo` wins over Claude
1016
+ // Code's `@agent-` + `foo` spelling rather than being shadowed by it.
1017
+ const alias = stripAgentPrefix(mention.handle);
1018
+ const resolved = manager.resolveMention(mention.handle)
1019
+ ?? (alias ? manager.resolveMention(alias) : undefined);
1020
+
1021
+ // Steering and resuming are direct in every mode, so headless they are not
1022
+ // available at all. Falling through here rather than dropping to the start
1023
+ // path below matters: the handle names an agent that already exists, and
1024
+ // asking the model to start another one is not what was typed.
1025
+ if (resolved && !canDispatchDirectly) return { action: "continue" };
1026
+
1027
+ if (resolved?.kind === "live") {
1028
+ const record = resolved.record;
1029
+ const target = `@${record.alias ?? record.handle ?? mention.handle}`;
1030
+
1031
+ if (record.status === "running" || record.status === "queued") {
1032
+ // Steering interrupts after the current tool call, exactly like the
1033
+ // steer_subagent tool. Un-consume the result so the agent's reply to
1034
+ // this message is still relayed even if the LLM read its last answer.
1035
+ record.resultConsumed = false;
1036
+ record.consumedAt = undefined;
1037
+ manager.steer(record.id, mention.message);
1038
+ ctx.ui.notify(`Sent to ${target}`, "info");
1039
+ return { action: "handled" };
1040
+ }
1041
+
1042
+ if (record.session) {
1043
+ // Both derived from the record's OWN type: a mention names an existing
1044
+ // agent, so its frontmatter is what governs — `output_transcript: false`
1045
+ // must keep holding, since record.outputFile is the sole gate every
1046
+ // downstream consumer keys off and a resume must not re-open it.
1047
+ const resumedRecord = await startBackgroundResume(ctx, record, mention.message, {
1048
+ outputTranscript: getAgentConfig(record.type)?.outputTranscript ?? getOutputTranscriptDefault(),
1049
+ maxTurns: resumeTurnLimit(record),
1050
+ });
1051
+ ctx.ui.notify(
1052
+ resumedRecord ? `Resuming ${target}` : `Could not resume ${target} — it is still running.`,
1053
+ resumedRecord ? "info" : "warning",
1054
+ );
1055
+ return { action: "handled" };
1056
+ }
1057
+ // A live record with no session never got far enough to continue, so it
1058
+ // falls through to the start-fresh path below, like Claude's
1059
+ // `no_transcript`.
1060
+ }
1061
+
1062
+ // Evicted, but its conversation is still on disk: reopen it. This is an
1063
+ // ordinary spawn carrying a session file, so the new record picks up the
1064
+ // widget, fleet row, transcript and completion notification unchanged —
1065
+ // and `reclaim` hands it back the names the tombstone was holding.
1066
+ if (resolved?.kind === "tombstone") {
1067
+ const entry = resolved.entry;
1068
+ const target = `@${entry.alias ?? entry.handle}`;
1069
+
1070
+ // Checked here rather than left to SessionManager.open: that runs inside
1071
+ // runAgent, whose rejection lands on the record as an agent error, not in
1072
+ // the catch below. A `/new` in another pi window or a manual delete makes
1073
+ // the conversation unrecoverable (Claude Code's `not_reachable`), so drop
1074
+ // the entry — a row that can only ever fail is worse than none — and say
1075
+ // so rather than quietly sending this message to an unrelated agent.
1076
+ if (!existsSync(entry.sessionFile)) {
1077
+ manager.dropTombstone(entry.handle);
1078
+ ctx.ui.notify(`Could not resume ${target} — its session is gone.`, "warning");
1079
+ return { action: "handled" };
1080
+ }
1081
+
1082
+ // The Agent tool deliberately falls back to general-purpose for a type it
1083
+ // cannot resolve (#183), which covers a deleted file AND a merely
1084
+ // disabled one. A resume must not inherit that: reopening this
1085
+ // conversation under a different agent's prompt and tools is not
1086
+ // continuing it, and the new record would re-tombstone under the
1087
+ // substitute, so the handle would never find its way back.
1088
+ reloadCustomAgents();
1089
+ const dispatch = resolveSpawnType(entry.type);
1090
+ if (!dispatch.ok || dispatch.fellBackFrom !== undefined) {
1091
+ // The tombstone stays: re-enabling the agent makes the handle work
1092
+ // again, which a drop would foreclose.
1093
+ ctx.ui.notify(`Could not resume ${target} — the ${entry.type} agent is no longer available.`, "warning");
1094
+ return { action: "handled" };
1095
+ }
1096
+
1097
+ try {
1098
+ // spawnResolved, not spawnTopLevel: the latter strips
1099
+ // `resumeSessionFile` and `reclaim` as untrusted. This path is the
1100
+ // exception — both come from a tombstone this extension wrote.
1101
+ const id = spawnResolved(pi, ctx, dispatch.type, mention.message, {
1102
+ description: entry.description,
1103
+ reclaim: { handle: entry.handle, alias: entry.alias },
1104
+ resumeSessionFile: entry.sessionFile,
1105
+ isBackground: true,
1106
+ });
1107
+ // The agent may still be starting — wait, so a startup failure lands in
1108
+ // the catch below instead of being announced as a resume.
1109
+ await manager.awaitStartup(id);
1110
+ // The tombstone deliberately stays. `resolveMention` prefers the live
1111
+ // record holding these same names, so it cannot shadow the resume — and
1112
+ // if this run dies before establishing its own session, the original
1113
+ // transcript is still the right thing for the next mention to reopen.
1114
+ // Once the resumed record is evicted it overwrites this entry in place,
1115
+ // keyed by the same handle, so nothing accumulates.
1116
+ ctx.ui.notify(`Resuming ${target}`, "info");
1117
+ } catch (err) {
1118
+ // The type is already settled above, so what is left is a spawn-time
1119
+ // failure: a strict worktree-isolation error, an unusable cwd.
1120
+ ctx.ui.notify(
1121
+ `Could not resume ${target}: ${err instanceof Error ? err.message : String(err)}`,
1122
+ "warning",
1123
+ );
1124
+ }
1125
+ return { action: "handled" };
1126
+ }
1127
+
1128
+ // No agent under that handle — but the name may still be an agent type, in
1129
+ // which case the mention starts one.
1130
+ const typeHandle = mention.handle;
1131
+ const type = resolveHandleToType(typeHandle, getAvailableTypes())
1132
+ ?? (alias ? resolveHandleToType(alias, getAvailableTypes()) : undefined);
1133
+ if (!type) return { action: "continue" };
1134
+
1135
+ // Claude Code never starts the agent itself: `@agent-<type>` becomes an
1136
+ // attachment asking the main model to do it, and the model writes the
1137
+ // agent's prompt from the conversation rather than forwarding the typed
1138
+ // text. That buys a real `Agent` tool call — transcript, per-tool widget
1139
+ // detail, tool-use-id correlation, join grouping — and a prompt with the
1140
+ // context a cold spawn lacks.
1141
+ //
1142
+ // It also costs a visible turn, spent narrating a decision the user already
1143
+ // made by typing the handle. So the turn is taken by a clone of this
1144
+ // conversation instead (mention-clone.ts): same messages, same system
1145
+ // prompt, off-screen, holding only the `Agent` tool. Nothing reaches the
1146
+ // chat, and what it starts is an ordinary top-level agent.
1147
+ if (getAgentMentionMode() === "model") {
1148
+ const label = `@${handleBase(type)}`;
1149
+ // "Prompting", not "Starting": in this mode nothing starts until the
1150
+ // off-screen clone has taken a whole model turn writing the agent's
1151
+ // prompt, and that wait is the one thing the chat cannot show. `direct`
1152
+ // says "Started" because by then it has. The distinction tells the user
1153
+ // which of the two they are waiting on.
1154
+ ctx.ui.notify(`Prompting ${label}…`, "info");
1155
+ // Not awaited: the clone runs a full model turn, and prompt() is blocked
1156
+ // until this hook returns. The user gets their prompt back immediately
1157
+ // and the agent appears in the widget when it starts.
1158
+ void runMentionClone({ ctx, type, message: mention.message, agentTool: registeredAgentTool })
1159
+ .then(async (result) => {
1160
+ if (result.spawned) return;
1161
+ // A clone that could not run must not swallow the mention: start the
1162
+ // agent the direct way rather than leaving the user with a toast and
1163
+ // nothing running.
1164
+ try {
1165
+ const id = spawnTopLevel(pi, ctx, type, mention.message, {
1166
+ description: describeMention(mention.message),
1167
+ isBackground: true,
1168
+ });
1169
+ // Same reason as the direct path below: the agent may still be
1170
+ // starting, and a failure there must reach this catch.
1171
+ await manager.awaitStartup(id);
1172
+ ctx.ui.notify(`Started ${label} directly — ${result.error}`, "warning");
1173
+ } catch (err) {
1174
+ ctx.ui.notify(
1175
+ `Could not start ${label}: ${err instanceof Error ? err.message : String(err)}`,
1176
+ "error",
1177
+ );
1178
+ }
1179
+ });
1180
+ return { action: "handled" };
1181
+ }
1182
+
1183
+ try {
1184
+ // Nothing else to pass: runAgent resolves model, thinking, max turns and
1185
+ // `isolated` from the agent's own config when the spawn omits them, and the
1186
+ // manager's onStart/onComplete callbacks own the widget, the fleet list
1187
+ // and the completion notification — the same contract the scheduler and
1188
+ // cross-extension RPC spawns run under.
1189
+ const id = spawnTopLevel(pi, ctx, type, mention.message, {
1190
+ description: describeMention(mention.message),
1191
+ isBackground: true,
1192
+ });
1193
+ // The agent may still be starting (a worktree copy is an awaited git
1194
+ // call) — report a failure that lands there as a failed start, not as a
1195
+ // "Started" toast for an agent that never ran.
1196
+ await manager.awaitStartup(id);
1197
+ ctx.ui.notify(`Started @${handleBase(type)}`, "info");
1198
+ } catch (err) {
1199
+ ctx.ui.notify(`Could not start @${handleBase(type)}: ${err instanceof Error ? err.message : String(err)}`, "error");
1200
+ }
1201
+ return { action: "handled" };
1202
+ });
1203
+
1204
+ pi.on("session_before_switch", () => {
1205
+ // Keep unread records until shutdown can transfer their held notices.
1206
+ manager.clearCompleted(true);
1207
+ scheduler.stop();
1208
+ });
1209
+
1210
+ // On quit, abort all agents immediately and clean up: the process is exiting.
1211
+ // On /new, /resume, /fork and /reload the process lives on, so running background
1212
+ // agents are detached instead and hand their results back when the user returns
1213
+ // to this session (see detached-results.ts). Only agents that cannot outlive the
1214
+ // session are aborted.
1215
+ pi.on("session_shutdown", async (event: { reason?: string }) => {
1216
+ const sessionFile = currentCtx?.sessionManager?.getSessionFile?.();
1217
+ unwatchDetached?.();
1218
+ unwatchDetached = undefined;
1219
+ rpcHandle?.unsubSpawn();
1220
+ rpcHandle?.unsubStop();
1221
+ rpcHandle?.unsubPing();
1222
+ rpcHandle?.unsubConsume();
1223
+ rpcHandle?.unsubSteer();
1224
+ rpcHandle = undefined;
1225
+ currentCtx = undefined;
1226
+ // Only release the global slot if this activation claimed it — a child
1227
+ // session's shutdown must not delete the root session's registry entry.
1228
+ if (ownsManagerRegistry && (globalThis as any)[MANAGER_KEY] === registryEntry) {
1229
+ delete (globalThis as any)[MANAGER_KEY];
1230
+ }
1231
+ scheduler.stop();
1232
+ // Before abortAll, and not folded into it: a workflow owns a worker thread
1233
+ // as well as its children, and only its own signal terminates that.
1234
+ for (const task of workflowTasks.values()) task.abortController.abort();
1235
+ workflowTasks.clear();
1236
+ const switching = event?.reason !== "quit" && sessionFile !== undefined;
1237
+ if (switching) {
1238
+ // A group may still be held, or not registered until the parent's turn
1239
+ // ends. Release both into the outbox before transferring its snapshot.
1240
+ groupJoin.flushPending();
1241
+ for (const id of turnGroupSpawns) {
1242
+ const record = manager.getRecord(id);
1243
+ if (record && !record.resultConsumed && record.status !== "running" && record.status !== "queued") {
1244
+ sendIndividualNudge(record);
1245
+ }
1246
+ }
1247
+ const pending = outbox.takePending();
1248
+ if (pending) {
1249
+ try { spoolDetached(sessionFile, { id: nanoid(8), ...pending }); }
1250
+ catch { console.error("[pi-subagents] Could not spool held completion notices; results remain in their transcripts."); }
1251
+ }
1252
+ }
1253
+ const detaching = switching && manager.detach() > 0;
1254
+ if (!detaching) manager.abortAll();
1255
+ // Activation-owned UI and group state (#256): `session_shutdown` also runs for
1256
+ // reload and session replacement, where the Node process survives, so a live
1257
+ // widget render interval or a pending group-join timeout from this activation
1258
+ // would outlive it and fire into the next session's UI and group state.
1259
+ // `session_before_switch` deliberately keeps both: a vetoed switch continues
1260
+ // this session, where the live UI and group state must survive.
1261
+ groupJoin.dispose();
1262
+ widget.dispose();
1263
+ outbox.dispose();
1264
+ fleet.dispose();
1265
+ unsubHandoffKey?.();
1266
+ unsubHandoffKey = undefined;
1267
+ handoffKeyUi = undefined;
1268
+
1269
+ if (detaching) {
1270
+ // Disposal waits for the survivors; it also releases their sessions.
1271
+ detachedSessionFile = sessionFile;
1272
+ // The reloaded instance has an empty manager; publish the survivors so
1273
+ // its fleet list shows them while they run.
1274
+ for (const record of manager.listAgents()) {
1275
+ if (record.status !== "running" || !isTopLevelAgent(record)) continue;
1276
+ registerRunningDetached(sessionFile, {
1277
+ record,
1278
+ activity: agentActivity.get(record.id),
1279
+ abort: () => manager.abort(record.id),
1280
+ steer: (message) => manager.steer(record.id, message),
1281
+ });
1282
+ }
1283
+ void manager.waitForAll().then(() => manager.dispose(), () => {});
1284
+ return;
1285
+ }
1286
+ // Awaited: it emits `session_shutdown` into every retained child session so
1287
+ // extensions bound there can release what they armed in `session_start` (#242).
1288
+ // pi awaits this handler, and the process exits right after — unawaited, those
1289
+ // handlers would never run. Internally bounded, so a hung one can't strand quit.
1290
+ await manager.dispose(pi);
1291
+ // This manager's agents and child sessions have shut down. Other detached
1292
+ // activations may still emit usage; this is not a process-global barrier.
1293
+ // A ledger's `session_shutdown` may have run before this handler aborted the agents
1294
+ // (handlers run in load order) and missed their last messages; this is the
1295
+ // point after which a synchronous final flush is complete. Synchronous:
1296
+ // `emit` does not await listeners, and pi exits once this handler returns.
1297
+ pi.events.emit("subagents:disposed", {});
1298
+ });
1299
+
1300
+ // Live widget: show running agents above editor.
1301
+ // widgetMode (default "background") selects what the widget shows: "all" =
1302
+ // every agent; "background" = hide foreground (they already render inline as
1303
+ // the Agent tool result, so showing them here too is a duplicate, #118), keep
1304
+ // everything else; "off" = hide the widget entirely. Read live at render time.
1305
+ let widgetMode: WidgetMode = "background";
1306
+ function getWidgetMode(): WidgetMode { return widgetMode; }
1307
+ const widget = new AgentWidget(manager, agentActivity, getWidgetMode, isShowCostEnabled, isShowModelEnabled, clock);
1308
+ function setWidgetMode(m: WidgetMode): void { widgetMode = m; widget.update(); }
1309
+
1310
+ /**
1311
+ * Foreground `Agent` calls whose run can be handed to the background, by
1312
+ * agent id. Each entry wakes its blocked tool call, which then returns as a
1313
+ * background spawn would. Only the Agent tool registers here, so a spawn with
1314
+ * no tool call to return — the agent generator, a workflow's agents — never
1315
+ * offers the handoff.
1316
+ */
1317
+ const foregroundHandoffs = new Map<string, () => void>();
1318
+
1319
+ /** Hand these runs to the background and wake their tool calls; returns how many took. */
1320
+ function sendForegroundToBackground(ids: Iterable<string>): number {
1321
+ let sent = 0;
1322
+ for (const id of [...ids]) {
1323
+ const wake = foregroundHandoffs.get(id);
1324
+ if (!wake || !manager.sendToBackground(id)) continue;
1325
+ foregroundHandoffs.delete(id);
1326
+ wake();
1327
+ sent++;
1328
+ // It is a background agent from here on, so announce it as one — as a
1329
+ // detached resume re-announces an existing id.
1330
+ const record = manager.getRecord(id);
1331
+ if (record) {
1332
+ pi.events.emit("subagents:created", {
1333
+ id,
1334
+ type: record.type,
1335
+ description: record.description,
1336
+ isBackground: true,
1337
+ });
1338
+ }
1339
+ }
1340
+ if (sent > 0) {
1341
+ widget.ensureTimer();
1342
+ widget.update();
1343
+ fleet.ensureTimer();
1344
+ fleet.update();
1345
+ }
1346
+ return sent;
1347
+ }
1348
+
1349
+ /** The conversation viewer's `b`: hand just this run to the background. */
1350
+ function backgroundActionFor(record: AgentRecord, notify: (message: string) => void): ViewerBackgroundAction {
1351
+ return {
1352
+ // Registered while the run is live, removed the moment its call returns.
1353
+ available: () => foregroundHandoffs.has(record.id),
1354
+ send: () => {
1355
+ if (sendForegroundToBackground([record.id]) > 0) notify(`Sent "${record.description}" to the background.`);
1356
+ },
1357
+ };
1358
+ }
1359
+
1360
+ // Claude Code's ctrl+b (`task:background`): hand every running foreground
1361
+ // `Agent` call to the background. Claimed only while one is pending, so the
1362
+ // rest of the time — and whenever the prompt editor is not focused — the key
1363
+ // is still pi's cursor-left.
1364
+ let handoffKeyUi: ExtensionContext["ui"] | undefined;
1365
+ let unsubHandoffKey: (() => void) | undefined;
1366
+ function watchHandoffKey(ui: ExtensionContext["ui"]): void {
1367
+ if (ui === handoffKeyUi) return;
1368
+ unsubHandoffKey?.();
1369
+ handoffKeyUi = ui;
1370
+ unsubHandoffKey = ui.onTerminalInput(data => {
1371
+ // Kitty reports a release too, and matchesKey matches it — act on the
1372
+ // press only, or a refused handoff would be retried by its own release.
1373
+ if (isKeyRelease(data) || foregroundHandoffs.size === 0 || !matchesKey(data, "ctrl+b")) return undefined;
1374
+ // Listeners run before the focused component, so a dialog or the
1375
+ // viewer's composer would lose its ctrl+b (cursor-left). The viewer has
1376
+ // its own `b`.
1377
+ if (!fleet.editorHasFocus()) return undefined;
1378
+ const sent = sendForegroundToBackground(foregroundHandoffs.keys());
1379
+ if (sent > 0) ui.notify(`Sent ${sent === 1 ? "1 agent" : `${sent} agents`} to the background.`, "info");
1380
+ return { consume: true };
1381
+ });
1382
+ }
1383
+
1384
+ /**
1385
+ * pi's fullscreen scrollbar preference, read from the main session's live,
1386
+ * trust-aware settings and normalized as pi's own `getFullscreenScrollbar()` does.
1387
+ */
1388
+ function getFullscreenScrollbar(): ScrollViewScrollbar {
1389
+ const mode = pi.getSettings().fullscreenScrollbar;
1390
+ return mode === "always" || mode === "hidden" ? mode : "auto";
1391
+ }
1392
+
1393
+ // Claude Code-style FleetView: navigable list of main + subagents below the editor.
1394
+ // The trailing arguments keep a conversation overlay opened here identical to
1395
+ // one opened from `/agents`: same settings on the way in, same persist out.
1396
+ const fleet = new FleetList(manager, agentActivity, isShowCostEnabled, getViewerMarkdown,
1397
+ (mode) => chooseViewerMarkdown(mode, currentCtx as unknown as ExtensionCommandContext | undefined),
1398
+ getFullscreenScrollbar, backgroundActionFor, isShowModelEnabled,
1399
+ // Agents the previous instance of this session left running (/reload, /new, ...).
1400
+ () => listRunningDetached(currentCtx?.sessionManager?.getSessionFile?.()), clock);
1401
+ /** What the Agent tool row reads at render time, so its summary stays live. */
1402
+ const agentRowLive: AgentRowLive = {
1403
+ getRecord: (id) => manager.getRecord(id),
1404
+ getActivity: (id) => agentActivity.get(id),
1405
+ showCost: isShowCostEnabled,
1406
+ };
1407
+ let fleetViewEnabled = true;
1408
+ function isFleetViewEnabled(): boolean { return fleetViewEnabled; }
1409
+ function setFleetViewEnabled(b: boolean): void { fleetViewEnabled = b; fleet.setEnabled(b); }
1410
+
1411
+ // Claude Code-style `@handle message` prompt mentions. Read live by both the
1412
+ // `input` hook and the stacked autocomplete provider, so the toggle applies
1413
+ // immediately — the provider itself can never be unregistered (pi's wrapper
1414
+ // list is append-only), it just delegates everything when this is off.
1415
+ let agentMentionMode: AgentMentionMode = "model";
1416
+ function getAgentMentionMode(): AgentMentionMode { return agentMentionMode; }
1417
+ function setAgentMentionMode(mode: AgentMentionMode): void { agentMentionMode = mode; }
1418
+ // `model` and `direct` differ only in who starts a not-yet-running agent, so
1419
+ // everything that just asks "are mentions live at all" — the suggestion list,
1420
+ // the steer and resume branches — reads this instead of the mode.
1421
+ function isAgentMentionsEnabled(): boolean { return agentMentionMode !== "off"; }
1422
+
1423
+ // Project/global default for writing the subagent .output transcript lives in
1424
+ // output-file.ts (both spawn paths read it). A custom agent's
1425
+ // `output_transcript` frontmatter overrides it per spawn; when the frontmatter
1426
+ // is silent, this default applies. Read live at spawn time.
1427
+
1428
+ // ---- Join mode configuration ----
1429
+ let defaultJoinMode: JoinMode = 'smart';
1430
+ function getDefaultJoinMode(): JoinMode { return defaultJoinMode; }
1431
+ function setDefaultJoinMode(mode: JoinMode) { defaultJoinMode = mode; }
1432
+
1433
+ // What an unqualified top-level spawn means. Defaults to background,
1434
+ // following Claude Code; `backgroundByDefault: false` restores the previous
1435
+ // foreground default. Nested spawns ignore this — see nested-tools.ts.
1436
+ let backgroundByDefault = true;
1437
+ function getBackgroundByDefault(): boolean { return backgroundByDefault; }
1438
+ function setBackgroundByDefault(b: boolean) { backgroundByDefault = b; }
1439
+
1440
+ // Master switch for the schedule subagent feature. Defaults to enabled.
1441
+ // Read once at extension init (before tool registration) so the Agent tool's
1442
+ // param schema reflects the persisted setting. Runtime toggles via /agents
1443
+ // → Settings short-circuit the menu entry + the execute-time addJob path
1444
+ // immediately, but the schema-level removal only takes effect on next
1445
+ // extension load (next pi session). Documented in CHANGELOG/README.
1446
+ let schedulingEnabled = true;
1447
+ function isSchedulingEnabled(): boolean { return schedulingEnabled; }
1448
+ function setSchedulingEnabled(b: boolean) { schedulingEnabled = b; }
1449
+
1450
+ // Master switch for scripted workflows. Defaults to ON. Off means the
1451
+ // `SubagentWorkflow` tool is never registered: the model is not told the
1452
+ // feature exists (zero context cost) and has nothing to call. The
1453
+ // `/agents → Workflows` view and `--subagents-workflow-file` are refused too, so
1454
+ // there is no second door into the same machinery.
1455
+ //
1456
+ // `workflowsPinned` records that the answer came from the user — a boolean in
1457
+ // subagents.json, or the settings toggle — rather than from this default. It
1458
+ // is what `resolveWorkflowCollisions` checks before yielding to another
1459
+ // extension's workflow tool: a default may be overridden by what else is
1460
+ // loaded, an explicit choice may not.
1461
+ let workflowsEnabled = true;
1462
+ let workflowsPinned = false;
1463
+ function isWorkflowsEnabled(): boolean { return workflowsEnabled; }
1464
+ function isWorkflowsPinned(): boolean { return workflowsPinned; }
1465
+ function setWorkflowsEnabled(b: boolean) {
1466
+ workflowsEnabled = b;
1467
+ workflowsPinned = true;
1468
+ }
1469
+
1470
+ // ---- Disable default agents configuration ----
1471
+ // When enabled, the three hardcoded default agents (general-purpose, Explore,
1472
+ // Plan) are not registered. User-defined agents from project/global custom
1473
+ // agent dirs are completely unaffected — only DEFAULT_AGENTS are suppressed.
1474
+ // Defaults to false; opt-in via `/agents → Settings` or subagents.json.
1475
+ // State lives in agent-types.ts (isDefaultsDisabled) because registerAgents
1476
+ // needs it; this wrapper just re-registers after flipping it.
1477
+ function setDisableDefaultAgents(b: boolean): void {
1478
+ setDefaultsDisabled(b);
1479
+ reloadCustomAgents(); // re-register with new setting
1480
+ }
1481
+
1482
+ // ---- Agent tool description mode ----
1483
+ // "full" (default) keeps the rich Claude Code-style description; "compact"
1484
+ // swaps in a ~75% smaller one for small/local models (#91). Read once at
1485
+ // tool registration — flipping it applies on the next pi session.
1486
+ let toolDescriptionMode: ToolDescriptionMode = "full";
1487
+ function getToolDescriptionMode(): ToolDescriptionMode { return toolDescriptionMode; }
1488
+ function setToolDescriptionMode(mode: ToolDescriptionMode): void { toolDescriptionMode = mode; }
1489
+
1490
+ // ---- Turn batches for `group` join mode ----
1491
+ // Background spawns the parent made in its current turn. The turn's end is
1492
+ // the batch boundary: every `Agent` call of the batch has returned by then,
1493
+ // so a spawn whose startup failed has already thrown and was never recorded,
1494
+ // and a slow worktree copy cannot split the batch.
1495
+ let turnGroupSpawns: string[] = [];
1496
+ let turnGroupCounter = 0;
1497
+
1498
+ /** Record a `group`-mode spawn made inside a parent turn. */
1499
+ function joinTurnGroup(id: string, joinMode: JoinMode | undefined) {
1500
+ // Outside a parent turn (an `@handle` mention) no turn end is coming, and a
1501
+ // group of one is delivered exactly like a single completion.
1502
+ if (joinMode === 'group' && currentCtx?.signal !== undefined) turnGroupSpawns.push(id);
1503
+ }
1504
+
1505
+ /** Register the ending turn's `group` spawns, two or more, as one group. */
1506
+ function registerTurnGroup() {
1507
+ const ids = turnGroupSpawns;
1508
+ turnGroupSpawns = [];
1509
+ // One spawn is no group: it reports like any single completion.
1510
+ if (ids.length < 2) {
1511
+ const record = ids.length === 1 ? manager.getRecord(ids[0]) : undefined;
1512
+ if (record?.completedAt != null && !record.resultConsumed) sendIndividualNudge(record);
1513
+ return;
1514
+ }
1515
+ const groupId = `turn-${++turnGroupCounter}`;
1516
+ groupJoin.registerGroup(groupId, ids);
1517
+ // Members that finished before their turn ended had their completion
1518
+ // deferred (see the manager's onComplete); feed them in now.
1519
+ for (const id of ids) {
1520
+ const record = manager.getRecord(id);
1521
+ if (!record) continue;
1522
+ record.groupId = groupId;
1523
+ if (record.completedAt != null && !record.resultConsumed) {
1524
+ groupJoin.onAgentComplete(record);
1525
+ }
1526
+ }
1527
+ }
1528
+
1529
+ /**
1530
+ * The turn budget for resuming `record`, from its OWN type — resume ignores
1531
+ * `subagent_type`. Same precedence as a spawn, frontmatter authoritative:
1532
+ * the agent file's `max_turns`, else the call's, else the limit the agent
1533
+ * was spawned with, else the default (#368).
1534
+ */
1535
+ function resumeTurnLimit(record: AgentRecord, explicit?: number): number | undefined {
1536
+ // Preserve 0 until the runner resolves it: normalizing it here would turn
1537
+ // an explicit unlimited budget into "unspecified" and restore the spawn cap.
1538
+ return getAgentConfig(record.type)?.maxTurns ?? explicit ?? record.invocation?.maxTurns ?? getDefaultMaxTurns();
1539
+ }
1540
+
1541
+ /**
1542
+ * Launch a detached resume of an existing agent and wire everything a
1543
+ * re-running agent needs: transcript anchoring, activity tracking, join-mode
1544
+ * batching, the widget/fleet refresh, and the `subagents:created` event.
1545
+ *
1546
+ * Shared by the Agent tool's `resume` + `run_in_background` branch and the
1547
+ * `@handle message` prompt mention — they differ only in how they report the
1548
+ * outcome. Returns the record, or undefined when the manager refused because
1549
+ * the agent is still running (see AgentManager.resume).
1550
+ *
1551
+ * Callers must have already established that the record has a session.
1552
+ */
1553
+ async function startBackgroundResume(
1554
+ ctx: ExtensionContext,
1555
+ existing: AgentRecord,
1556
+ prompt: string,
1557
+ opts: { outputTranscript: boolean; maxTurns?: number; toolCallId?: string },
1558
+ ): Promise<AgentRecord | undefined> {
1559
+ const id = existing.id;
1560
+ const joinMode = resolveJoinMode(defaultJoinMode, true);
1561
+ // Assigned unconditionally: the completion notification carries this as
1562
+ // `<tool-use-id>`, so a mention-resume (which passes none) has to CLEAR the
1563
+ // id left by the spawn that created the record. Keeping it would point the
1564
+ // orchestrator's new result at a tool call that was answered runs ago.
1565
+ existing.toolCallId = opts.toolCallId;
1566
+ if (joinMode) existing.joinMode = joinMode;
1567
+ // Reuse the agent's transcript rather than starting a fresh one: the
1568
+ // path is deterministic per agent+session, so writing an initial entry
1569
+ // would truncate the previous run's turns (see ensureOutputFile).
1570
+ if (opts.outputTranscript) {
1571
+ existing.outputFile = createOutputFilePath(ctx.cwd, id, ctx.sessionManager.getSessionId());
1572
+ ensureOutputFile(existing.outputFile);
1573
+ }
1574
+ // Anchor streaming past the turns already on disk, captured BEFORE the
1575
+ // run starts. The resumed prompt lands as an ordinary user message at
1576
+ // this index, so it is written exactly once.
1577
+ const transcriptAnchor = existing.session?.messages.length ?? 0;
1578
+
1579
+ const { state: bgState, callbacks: bgCallbacks } = createActivityTracker(normalizeMaxTurns(opts.maxTurns), undefined, clock);
1580
+ // resumeAgent has no onSessionCreated — the session predates this run —
1581
+ // so seed it directly, or the widget shows no context % for the agent.
1582
+ bgState.session = existing.session;
1583
+
1584
+ // No `signal`: a background spawn deliberately omits it, and a detached
1585
+ // resume must behave the same. Passing it would abort this agent when
1586
+ // the parent turn is interrupted (user Esc), while agents started with
1587
+ // run_in_background in that same turn keep going.
1588
+ const record = await manager.resume(id, prompt, undefined, {
1589
+ isBackground: true,
1590
+ maxTurns: opts.maxTurns,
1591
+ onTurnEnd: bgCallbacks.onTurnEnd,
1592
+ onTextDelta: bgCallbacks.onTextDelta,
1593
+ onModelCall: bgCallbacks.onModelCall,
1594
+ onToolActivity: bgCallbacks.onToolActivity,
1595
+ onAssistantUsage: bgCallbacks.onAssistantUsage,
1596
+ // Fires when the run actually starts — immediately, or on queue
1597
+ // drain. Wiring it here (rather than after resume() returns) means a
1598
+ // resume stopped while still queued never started streaming, so
1599
+ // there is no subscription left behind for a later run to trip over.
1600
+ onStarted: () => {
1601
+ const rec = manager.getRecord(id);
1602
+ if (rec?.session && rec.outputFile) {
1603
+ rec.outputCleanup = streamToOutputFile(rec.session, rec.outputFile, id, ctx.cwd, transcriptAnchor);
1604
+ }
1605
+ },
1606
+ });
1607
+ if (!record) return undefined;
1608
+
1609
+ // Only a resume from an Agent call belongs to that call's turn.
1610
+ if (opts.toolCallId !== undefined) joinTurnGroup(id, joinMode);
1611
+
1612
+ agentActivity.set(id, bgState);
1613
+ // This agent already finished once, so the widget holds a finished-age
1614
+ // for it that is past the linger limit — without clearing it, the
1615
+ // resumed run's ✓/✗ line never renders and the agent just vanishes.
1616
+ widget.markRunning(id);
1617
+ widget.ensureTimer();
1618
+ widget.update();
1619
+ fleet.ensureTimer();
1620
+ fleet.update();
1621
+
1622
+ // Resume ignores subagent_type (the record keeps the type it was
1623
+ // spawned with), so report the record's own identity — a "created"
1624
+ // event carrying the caller's type would re-register the agent under
1625
+ // the wrong one in cross-extension mirrors keyed by id.
1626
+ pi.events.emit("subagents:created", {
1627
+ id,
1628
+ type: existing.type,
1629
+ description: existing.description,
1630
+ isBackground: true,
1631
+ });
1632
+
1633
+ return record;
1634
+ }
1635
+
1636
+ // Grab UI context from first tool execution + clear lingering widget on new turn
1637
+ pi.on("tool_execution_start", async (_event, ctx) => {
1638
+ widget.setUICtx(ctx.ui as UICtx);
1639
+ fleet.setUICtx(ctx.ui as unknown as FleetUICtx);
1640
+ watchHandoffKey(ctx.ui);
1641
+ widget.onTurnStart();
1642
+ });
1643
+
1644
+ // The parent's tool batch is done and its next model call not yet made: the
1645
+ // boundary where completions are delivered, as in Claude Code.
1646
+ pi.on("turn_end", (event) => {
1647
+ registerTurnGroup();
1648
+ outbox.onTurnEnd(event.message.role === "assistant" ? event.message.stopReason : undefined);
1649
+ });
1650
+ pi.on("agent_end", () => outbox.onAgentEnd());
1651
+ pi.on("agent_settled", () => outbox.onAgentEnd());
1652
+
1653
+ /** Build the full type list text dynamically from available agents only. */
1654
+ const buildTypeListText = () => {
1655
+ const available = getAvailableTypes();
1656
+
1657
+ return available.map((name) => {
1658
+ const cfg = getAgentConfig(name);
1659
+ const modelSuffix = cfg?.model ? ` (${getModelLabelFromConfig(cfg.model)})` : "";
1660
+ const toolsSuffix = ` (Tools: ${formatToolsSuffix(cfg)})`;
1661
+ return `- ${name}: ${cfg?.description ?? name}${modelSuffix}${toolsSuffix}`;
1662
+ }).join("\n");
1663
+ };
1664
+
1665
+ /** First sentence of an agent description — for the compact type list. */
1666
+ const firstSentence = (text: string): string => {
1667
+ const match = text.match(/^.*?[.!?](?=\s|$)/s);
1668
+ return (match ? match[0] : text).replace(/\s+/g, " ").trim();
1669
+ };
1670
+
1671
+ /** Compact type list: one line per agent, first sentence only. */
1672
+ const buildCompactTypeListText = () =>
1673
+ getAvailableTypes().map((name) => {
1674
+ const cfg = getAgentConfig(name);
1675
+ return `- ${name}: ${firstSentence(cfg?.description ?? name)} (Tools: ${formatToolsSuffix(cfg)})`;
1676
+ }).join("\n");
1677
+
1678
+ /** Derive a short model label from a model string. */
1679
+ function getModelLabelFromConfig(model: string): string {
1680
+ // Strip provider prefix (e.g. "anthropic/claude-sonnet-4-6" → "claude-sonnet-4-6")
1681
+ const name = model.includes("/") ? model.split("/").pop()! : model;
1682
+ // Strip trailing date suffix (e.g. "claude-haiku-4-5-20251001" → "claude-haiku-4-5")
1683
+ return name.replace(/-\d{8}$/, "");
1684
+ }
1685
+
1686
+ // Apply persisted settings on startup and emit `subagents:settings_loaded`.
1687
+ // Global + project merged; missing → defaults; corrupt file emits a warning
1688
+ // to stderr and falls back to defaults.
1689
+ applyAndEmitLoaded(
1690
+ {
1691
+ setMaxConcurrent: (n) => manager.setMaxConcurrent(n),
1692
+ setMaxConcurrentForeground: (n) => manager.setMaxConcurrentForeground(n),
1693
+ setDefaultMaxTurns,
1694
+ setGraceTurns,
1695
+ setDefaultJoinMode,
1696
+ setBackgroundByDefault,
1697
+ setSchedulingEnabled,
1698
+ setScopeModels: setScopeModelsEnabled,
1699
+ setStrictAgentFiles: (b) => { strictAgentFiles = b; },
1700
+ setDisableDefaultAgents: setDisableDefaultAgents,
1701
+ setToolDescriptionMode: setToolDescriptionMode,
1702
+ setFleetView: setFleetViewEnabled,
1703
+ setAgentMentions: setAgentMentionMode,
1704
+ setRememberAgents,
1705
+ setWidgetMode: setWidgetMode,
1706
+ setOutputTranscript: setOutputTranscriptDefault,
1707
+ setWorktreeIsolation: setWorktreeIsolationEnabled,
1708
+ setWorkflowsEnabled: setWorkflowsEnabled,
1709
+ setMaxSubagentDepth: setMaxSubagentDepth,
1710
+ setFallbackSubagent: setFallbackSubagent,
1711
+ setModelAliases,
1712
+ setAgentModels,
1713
+ setReportUsage,
1714
+ setShowCost,
1715
+ setShowModel,
1716
+ setViewerMarkdown,
1717
+ setPackageAgents: setPackageAgentsGate,
1718
+ setPackageWorkflows: setPackageWorkflowsGate,
1719
+ },
1720
+ (event, payload) => pi.events.emit(event, payload),
1721
+ );
1722
+
1723
+ // ---- Agent tool ----
1724
+
1725
+ // Schedule param + its guideline are gated on `schedulingEnabled` (read once
1726
+ // at registration; flipping the setting later requires next pi session for
1727
+ // the schema to update). Defining the shape once and spreading it via Partial
1728
+ // preserves Type.Object's inference when present and produces a
1729
+ // `schedule`-free schema when absent — zero LLM-context cost in disabled mode.
1730
+ const scheduleParamShape = {
1731
+ schedule: Type.Optional(
1732
+ Type.String({
1733
+ description:
1734
+ 'Opt-in only — fire later instead of now. Omit to run immediately (the default, almost always correct). ' +
1735
+ 'Formats: 6-field cron ("0 0 9 * * 1" = 9am Mon), interval ("5m"/"1h"), one-shot ("+10m" or ISO). ' +
1736
+ 'Forces run_in_background; incompatible with inherit_context and resume. Returns job ID.',
1737
+ }),
1738
+ ),
1739
+ };
1740
+ const scheduleParam: Partial<typeof scheduleParamShape> =
1741
+ isSchedulingEnabled() ? scheduleParamShape : {};
1742
+
1743
+ const scheduleGuideline = isSchedulingEnabled()
1744
+ ? `\n- Use \`schedule\` only when the user explicitly asked for scheduled / recurring / delayed execution (e.g. "every Monday", "in an hour"). Don't auto-schedule from vague intent like "monitor X" — run once now or ask.`
1745
+ : "";
1746
+
1747
+ // Same trade as scheduleParam/scheduleGuideline above: `isolationParam` drops
1748
+ // the field from the schema when the project set `worktreeIsolation: false`,
1749
+ // so the prose has to go with it. Left in, it would teach the model to pass a
1750
+ // parameter that isn't declared — accepted (TypeBox sets no
1751
+ // `additionalProperties: false`) and then silently dropped by the resolver.
1752
+ // With no per-result note by design, the model would have every reason to go
1753
+ // on reporting a `pi-agent-*` branch that was never created.
1754
+ const isolationGuideline = isWorktreeIsolationEnabled()
1755
+ ? `\n- Use isolation: "worktree" to give the agent its own git worktree (safe parallel file modifications); leave it unset, or pass "off", for none. The worktree is removed when the agent finishes; if it made changes, they are committed to a branch and the branch is named in the result. The worktree is made from the repo containing \`cwd\` (default: the session cwd); when the target repo is not the session cwd, pass its path as \`cwd\`.`
1756
+ : "";
1757
+
1758
+ const isolationCompactGuideline = isWorktreeIsolationEnabled()
1759
+ ? `\n- isolation: "worktree" gives the agent its own git worktree (removed on completion); changes land on a branch named in the result. Pass \`cwd\` to isolate a repo other than the session cwd.`
1760
+ : "";
1761
+
1762
+ // Compact Agent tool description (#91, `toolDescriptionMode: "compact"`) —
1763
+ // the same load-bearing facts as the full version at ~75% fewer tokens, for
1764
+ // small/local models. Per-option details live in the param descriptions.
1765
+ const compactAgentToolDescription = () => `Launch an autonomous agent for complex, multi-step tasks. Agent types:
1766
+ ${buildCompactTypeListText()}
1767
+
1768
+ Custom agents: .pi/agents/<name>.md (project) or ${getAgentDir()}/agents/<name>.md (global).
1769
+
1770
+ Notes:
1771
+ - description: 3-5 words (shown in UI). Prompts must be self-contained — the agent has not seen this conversation.
1772
+ - Parallel work: one message, multiple Agent calls — they run concurrently.
1773
+ - Subagents run in the background by default; you'll be notified when one completes. Pass run_in_background: false only when your very next action depends on the result and nothing else could usefully happen while it runs. Never fabricate or predict a pending agent's results — if the user asks before the notification arrives, say it's still running.
1774
+ - The result is not shown to the user — summarize it for them. Verify an agent's claimed code changes before reporting work done.
1775
+ - resume continues a previous agent by ID; steer_subagent messages a running one.${isolationCompactGuideline}`;
1776
+
1777
+ const fullAgentToolDescription = () => `Launch a new agent to handle complex, multi-step tasks autonomously. Each agent type has specific capabilities and tools available to it.
1778
+
1779
+ Available agent types and the tools they have access to:
1780
+ ${buildTypeListText()}
1781
+
1782
+ Custom agents can be defined in .pi/agents/<name>.md (project) or ${getAgentDir()}/agents/<name>.md (global) — they are picked up automatically. Project-level agents override global ones. Creating a .md file with the same name as a default agent overrides it.
1783
+
1784
+ When using the Agent tool, specify a subagent_type parameter to select which agent type to use.
1785
+
1786
+ ## When not to use
1787
+
1788
+ If the target is already known, use a direct tool — \`read\` for a known path, \`grep\`/\`find\` for a specific symbol or string. Reserve this tool for open-ended questions that span the codebase, or tasks that match an available agent type.
1789
+
1790
+ ## Usage notes
1791
+
1792
+ - Always include a short (3-5 word) description summarizing what the agent will do (shown in UI).
1793
+ - When you launch multiple agents for independent work, send them in a single message with multiple tool uses so they run concurrently. If the user specifies that they want you to run agents "in parallel", you MUST send a single message with multiple Agent tool use content blocks.
1794
+ - When the agent is done, it returns a single message back to you. The result is not visible to the user — to show the user, send a text message with a concise summary.
1795
+ - Trust but verify: an agent's summary describes what it intended to do, not necessarily what it did. When an agent writes or edits code, check the actual changes before reporting the work as done.
1796
+ - Agents run in the background by default. When an agent runs in the background, you will be automatically notified when it completes — do NOT sleep, poll, or proactively check on its progress. Continue with other work or respond to the user instead.
1797
+ - **Foreground vs background**: Pass \`run_in_background: false\` only when your very next action depends on the agent's result and nothing else could usefully happen while it runs — e.g., a research agent whose finding gates the edit you're about to make. Otherwise let it run in the background (the default) — this includes fire-and-forget work, independent investigations, and anything where the user might hand you something else in the meantime. Wanting the result "next" is not enough on its own.
1798
+ - **Don't race**: after launching a background agent, you know nothing about its results. Never fabricate or predict them in any format — not as prose, summary, or structured output. The completion notification arrives in a later turn; it is never something you write yourself. If the user asks before it lands, say the agent is still running — give status, not a guess.
1799
+ - Use resume with an agent ID to continue a previous agent's work. A new (non-resume) Agent call starts a fresh agent with no memory of prior runs, so the prompt must be self-contained.
1800
+ - Use steer_subagent to send mid-run messages to a running background agent.
1801
+ - Clearly tell the agent whether you expect it to write code or just to do research (search, file reads, etc.), since it is not aware of the user's intent.
1802
+ - If an agent's description says it should be used proactively, try to use it without the user having to ask for it first.
1803
+ - Use model to specify a different model (as "provider/modelId", or fuzzy e.g. "haiku", "sonnet").
1804
+ - Use thinking to control extended thinking level.
1805
+ - Use inherit_context if the agent needs the parent conversation history.${isolationGuideline}${scheduleGuideline}
1806
+
1807
+ ## Writing the prompt
1808
+
1809
+ Brief the agent like a smart colleague who just walked into the room — it hasn't seen this conversation, doesn't know what you've tried, doesn't understand why this task matters.
1810
+ - Explain what you're trying to accomplish and why.
1811
+ - Describe what you've already learned or ruled out.
1812
+ - Give enough context about the surrounding problem that the agent can make judgment calls rather than just following a narrow instruction.
1813
+ - If you need a short response, say so ("report in under 200 words").
1814
+ - Lookups: hand over the exact command. Investigations: hand over the question — prescribed steps become dead weight when the premise is wrong.
1815
+
1816
+ Terse command-style prompts produce shallow, generic work.
1817
+
1818
+ **Never delegate understanding.** Don't write "based on your findings, fix the bug" or "based on the research, implement it." Those phrases push synthesis onto the agent instead of doing it yourself. Write prompts that prove you understood: include file paths, line numbers, what specifically to change.`;
1819
+
1820
+ // `toolDescriptionMode: "custom"` — user-authored description with live
1821
+ // dynamic parts. Project file wins over global; missing/empty falls back to
1822
+ // "full" (a stale fallback beats a blank tool description). Only the prose
1823
+ // is customizable — the parameter schema stays code-owned.
1824
+ const renderToolDescriptionTemplate = (template: string): string => {
1825
+ const vars: Record<string, () => string> = {
1826
+ typeList: buildTypeListText,
1827
+ compactTypeList: buildCompactTypeListText,
1828
+ agentDir: getAgentDir,
1829
+ isolationGuideline: () => isolationGuideline,
1830
+ scheduleGuideline: () => scheduleGuideline,
1831
+ };
1832
+ // Replacement callback (not a string) — agent descriptions may contain `$&` etc.
1833
+ return template.replace(/\{\{(\w+)\}\}/g, (raw, name: string) => {
1834
+ if (vars[name]) return vars[name]();
1835
+ console.warn(`[pi-subagents] agent-tool-description.md: unknown placeholder ${raw} left as-is`);
1836
+ return raw;
1837
+ });
1838
+ };
1839
+
1840
+ const loadCustomToolDescription = (): string | undefined => {
1841
+ // The project file is project-supplied text for the model: same canonical
1842
+ // trust gate as project agents, or it could carry an untrusted agent list.
1843
+ const candidates = [join(getAgentDir(), "agent-tool-description.md")];
1844
+ if (isProjectTrusted()) candidates.unshift(join(process.cwd(), ".pi", "agent-tool-description.md"));
1845
+ for (const path of candidates) {
1846
+ try {
1847
+ if (!existsSync(path)) continue;
1848
+ const text = readFileSync(path, "utf-8").trim();
1849
+ if (text) return renderToolDescriptionTemplate(text);
1850
+ console.warn(`[pi-subagents] ${path} is empty — ignoring`);
1851
+ } catch (err) {
1852
+ console.warn(`[pi-subagents] failed to read ${path}: ${err instanceof Error ? err.message : String(err)}`);
1853
+ }
1854
+ }
1855
+ return undefined;
1856
+ };
1857
+
1858
+ // Mode is fixed at activation, as the description always was.
1859
+ const descriptionMode = getToolDescriptionMode();
1860
+ const buildAgentToolDescription = (): string => {
1861
+ if (descriptionMode === "compact") return compactAgentToolDescription();
1862
+ if (descriptionMode === "custom") {
1863
+ const custom = loadCustomToolDescription();
1864
+ if (custom) return custom;
1865
+ console.warn('[pi-subagents] toolDescriptionMode is "custom" but no agent-tool-description.md found — using "full"');
1866
+ }
1867
+ return fullAgentToolDescription();
1868
+ };
1869
+ const buildSubagentTypeDescription = (): string =>
1870
+ `The type of specialized agent to use. Available types: ${getAvailableTypes().join(", ")}. Custom agents from .pi/agents/*.md (project) or ${getAgentDir()}/agents/*.md (global) are also available.`;
1871
+
1872
+ // Held rather than registered inline: the mention clone reuses this exact
1873
+ // definition, so the agent it starts is an ordinary top-level spawn instead
1874
+ // of a second implementation that has to be kept in step with this one.
1875
+ const agentTool = defineTool({
1876
+ name: SUBAGENT_TOOL_NAMES.AGENT,
1877
+ label: "Agent",
1878
+ description: buildAgentToolDescription(),
1879
+ promptSnippet: "Launch autonomous sub-agents for complex multi-step tasks",
1880
+ promptGuidelines: [
1881
+ "Use Agent with specialized agents when the task matches an agent type's description. Subagents are valuable for parallelizing independent queries or for protecting the main context window from excessive results, but should not be used excessively when not needed. Importantly, avoid duplicating work that subagents are already doing — if you delegate research to a subagent, do not also perform the same searches yourself.",
1882
+ "For broad codebase exploration or research, spawn Agent with an appropriate subagent_type (e.g. Explore). Otherwise use direct tools (read, grep, find) when the target is already known.",
1883
+ "When an agent runs in the background, you will be notified on completion — do not poll or sleep waiting for it. Continue with other work instead.",
1884
+ "Trust but verify: an agent's summary describes intent, not outcome. When an agent writes or edits code, check the actual changes before reporting work as done.",
1885
+ ],
1886
+ parameters: Type.Object({
1887
+ prompt: Type.String({
1888
+ description: "The task for the agent to perform.",
1889
+ }),
1890
+ description: Type.String({
1891
+ description: "A short (3-5 word) description of the task (shown in UI).",
1892
+ }),
1893
+ name: Type.Optional(
1894
+ Type.String({
1895
+ description:
1896
+ 'Optional memorable name for this agent, e.g. "auth-audit", so it can be addressed as `@name` at the prompt and by steer_subagent / get_subagent_result. Letters, digits, `_` and `-`. Worth setting when several agents of the same type run at once; omit for one-off work. The agent stays reachable by its type either way.',
1897
+ }),
1898
+ ),
1899
+ subagent_type: Type.String({
1900
+ description: buildSubagentTypeDescription(),
1901
+ }),
1902
+ model: Type.Optional(
1903
+ Type.String({
1904
+ description:
1905
+ 'Optional model override. Accepts "provider/modelId" or fuzzy name (e.g. "haiku", "sonnet"). Omit to use the agent type\'s default.',
1906
+ }),
1907
+ ),
1908
+ thinking: Type.Optional(
1909
+ Type.String({
1910
+ description: `Thinking level: ${THINKING_LEVELS.join(", ")}. Overrides agent default.`,
1911
+ }),
1912
+ ),
1913
+ max_turns: Type.Optional(
1914
+ Type.Number({
1915
+ description: "Maximum number of agentic turns before stopping. Omit for unlimited (default).",
1916
+ minimum: 1,
1917
+ }),
1918
+ ),
1919
+ run_in_background: Type.Optional(
1920
+ Type.Boolean({
1921
+ description: "Defaults to true — the agent runs detached, returning its ID immediately, and you are notified on completion. Set false only when your very next action depends on the result; the call then blocks and returns the agent's full output inline.",
1922
+ }),
1923
+ ),
1924
+ resume: Type.Optional(
1925
+ Type.String({
1926
+ description: "Optional agent ID to resume from. Continues from previous context. Resumes detached like any other spawn; pass run_in_background: false to block and get the result inline. The resumed run gets a fresh max_turns budget. An agent can only be resumed once its current run has finished — use steer_subagent to reach one mid-run.",
1927
+ }),
1928
+ ),
1929
+ isolated: Type.Optional(
1930
+ Type.Boolean({
1931
+ description: "If true, agent gets no extension/MCP tools — only built-in tools.",
1932
+ }),
1933
+ ),
1934
+ inherit_context: Type.Optional(
1935
+ Type.Boolean({
1936
+ description: "If true, fork parent conversation into the agent. Default: false (fresh context).",
1937
+ }),
1938
+ ),
1939
+ ...isolationParam(isWorktreeIsolationEnabled()),
1940
+ ...cwdParam,
1941
+ ...scheduleParam,
1942
+ }),
1943
+
1944
+ // ---- Custom rendering: Claude Code style ----
1945
+
1946
+ renderCall(args, theme, context) {
1947
+ // A badge closes its own background, which would clear the tool block's row tint
1948
+ // for the rest of the line, so the badge restores it. The tint is opened here too:
1949
+ // the TUI's Box paints it, but HTML export takes it from CSS, and restoring a
1950
+ // background the line never opened is what banded the export before. The line is
1951
+ // deliberately left open — Box.applyBackgroundToLine pads to width and *then*
1952
+ // wraps, so closing here would leave that padding untinted, and HTML export closes
1953
+ // any open span per line anyway. No badge means no tint, so an uncolored agent
1954
+ // renders exactly the line it always did.
1955
+ const rowBackground = hasAgentBadge(args.subagent_type)
1956
+ ? theme.getBgAnsi(context.isPartial ? "toolPendingBg" : context.isError ? "toolErrorBg" : "toolSuccessBg")
1957
+ : "";
1958
+ const desc = args.description ?? "";
1959
+ const name = renderAgentName(args.subagent_type, theme, {
1960
+ fallbackColor: "toolTitle",
1961
+ restoreBackground: rowBackground,
1962
+ bold: true,
1963
+ });
1964
+ // No ▸ here: core draws the disclosure marker on a settled tool row and
1965
+ // folds the collapsed row to `<this line> · <first result line>`.
1966
+ return new AgentCallView(
1967
+ rowBackground + name + (desc ? " " + theme.fg("muted", desc) : ""),
1968
+ context.expanded ? args.prompt : undefined,
1969
+ theme,
1970
+ );
1971
+ },
1972
+
1973
+ renderResult(result, { expanded }, theme, renderContext) {
1974
+ const details = result.details as AgentDetails | undefined;
1975
+ const text = result.content[0]?.type === "text" ? result.content[0].text : "";
1976
+ // Pi reports pre-execution failures (extension block, abort, argument
1977
+ // validation) as `{ content: [reason], details: {} }` with isError set —
1978
+ // no status to render, so show the reason instead of inventing one (#199).
1979
+ if (renderContext.isError || !details?.status) {
1980
+ return new Text(text, 0, 0);
1981
+ }
1982
+ // Anything else ("queued", or a status added later) has no rendering of
1983
+ // its own — raw text beats guessing at one.
1984
+ if (!AGENT_ROW_STATUSES.has(details.status)) return new Text(text, 0, 0);
1985
+
1986
+ keepRowTicking(renderContext, details, agentRowLive, clock);
1987
+ return new AgentResultView(details, text, expanded, agentRowLive, theme, clock);
1988
+ },
1989
+
1990
+ // ---- Execute ----
1991
+
1992
+ execute: async (toolCallId, params, signal, onUpdate, ctx) => {
1993
+ // Ensure we have UI context for widget rendering
1994
+ widget.setUICtx(ctx.ui as UICtx);
1995
+
1996
+ // Reload custom agents so new project/global .md files are picked up without restart
1997
+ reloadCustomAgents();
1998
+
1999
+ const rawType = params.subagent_type as SubagentType;
2000
+ // Single decision point for dispatch (#183): unknown, disabled and
2001
+ // case-ambiguous types are refused here, BEFORE anything spawns, so a
2002
+ // background or scheduled call can't start running the wrong agent while
2003
+ // the caller is still unaware. `fallbackSubagent` decides whether an
2004
+ // unresolvable type falls back or fails closed.
2005
+ const dispatch = resolveSpawnType(rawType);
2006
+ // `resume` replays a stored session and ignores `subagent_type` entirely,
2007
+ // but the parameter is required by the schema — so gating it here would
2008
+ // make a live agent unresumable the moment its type is deleted, disabled,
2009
+ // or gains a case-clashing sibling. Only a real spawn is gated.
2010
+ if (!dispatch.ok && !params.resume) return textResult(dispatch.message);
2011
+ const subagentType = dispatch.ok ? dispatch.type : rawType;
2012
+ // What the caller actually asked for, named once: `fellBackFrom` is "" for
2013
+ // a blank request, so reading it inline invites the `??`-vs-`||` slip that
2014
+ // once persisted an empty type into a scheduled job.
2015
+ const requestedType = (dispatch.ok && dispatch.fellBackFrom) || subagentType;
2016
+ // Computed at resolution rather than after the run, so the background and
2017
+ // schedule branches carry it too — previously it existed only on the
2018
+ // foreground path. Resume deliberately doesn't: it replays the stored
2019
+ // session and ignores `subagent_type` entirely, so a note about type
2020
+ // substitution would be describing something that didn't happen.
2021
+ const fallbackNote = dispatch.ok && dispatch.fellBackFrom !== undefined
2022
+ ? `Note: Unknown agent type "${dispatch.fellBackFrom}" — using ${resolveType(subagentType) ? subagentType : "the fallback agent config"}.\n\n`
2023
+ : "";
2024
+
2025
+ const displayName = getDisplayName(subagentType);
2026
+
2027
+ // Get agent config (if any)
2028
+ const customConfig = getAgentConfig(subagentType);
2029
+
2030
+ const resolvedConfig = resolveAgentInvocationConfig(customConfig, params, {
2031
+ agentType: subagentType,
2032
+ worktreeAllowed: isWorktreeIsolationEnabled(),
2033
+ defaultRunInBackground: getBackgroundByDefault(),
2034
+ });
2035
+
2036
+ // Resolve an explicit tool-call model first; agent config supplies only a default.
2037
+ let model = ctx.model;
2038
+ if (resolvedConfig.modelInput) {
2039
+ const resolved = resolveModel(resolvedConfig.modelInput, ctx.modelRegistry);
2040
+ if (typeof resolved === "string") {
2041
+ if (resolvedConfig.modelFromParams) return textResult(resolved);
2042
+ // config-specified: silent fallback to parent
2043
+ } else {
2044
+ model = resolved;
2045
+ }
2046
+ }
2047
+
2048
+ // Scope validation: the effective resolved model is checked against the
2049
+ // user's enabledModels list. Policy (hard error vs warn-and-proceed) lives
2050
+ // in model-scope.ts so the nested delegation tools apply the same rule.
2051
+ const scopeVerdict = checkModelScope({
2052
+ model,
2053
+ cwd: ctx.cwd,
2054
+ modelRegistry: ctx.modelRegistry,
2055
+ callerSupplied: resolvedConfig.modelFromParams,
2056
+ agentLabel: customConfig?.displayName ?? subagentType,
2057
+ modelInput: resolvedConfig.modelInput,
2058
+ });
2059
+ if (scopeVerdict.kind === "error") return textResult(scopeVerdict.message);
2060
+ if (scopeVerdict.kind === "warn") ctx.ui.notify(scopeVerdict.message, "warning");
2061
+
2062
+ const thinking = resolvedConfig.thinking;
2063
+ const inheritContext = resolvedConfig.inheritContext;
2064
+ const runInBackground = resolvedConfig.runInBackground;
2065
+ const isolated = resolvedConfig.isolated;
2066
+ const isolation = resolvedConfig.isolation;
2067
+ // Undefined = the session cwd. The manager validates it (absolute,
2068
+ // existing directory) at spawn and again at start.
2069
+ const cwd = resolveCwdParam(params.cwd, ctx.cwd);
2070
+ // Whether this spawn writes its .output transcript. Per-agent
2071
+ // frontmatter (`output_transcript`) wins; otherwise the project/global
2072
+ // default applies. `attachTranscript` below is the SOLE gate — every
2073
+ // downstream consumer keys off record.outputFile being set, so no spawn
2074
+ // path can re-enable the transcript by accident.
2075
+ const outputTranscript = customConfig?.outputTranscript ?? getOutputTranscriptDefault();
2076
+ const attachTranscript = (rec: AgentRecord | undefined, agentId: string): void => {
2077
+ if (!rec || !outputTranscript) return;
2078
+ rec.outputFile = createOutputFilePath(ctx.cwd, agentId, ctx.sessionManager.getSessionId());
2079
+ writeInitialEntry(rec.outputFile, agentId, params.prompt, ctx.cwd);
2080
+ };
2081
+
2082
+ // Unconditional, not "only when it differs from the parent": a thinking
2083
+ // level reads as a property of a model, and an agent that inherited the
2084
+ // parent's model used to show the level with nothing to attach it to.
2085
+ // This is the pre-session snapshot — agent-manager overwrites it with the
2086
+ // effective values the moment a session reports them.
2087
+ const { modelName, modelId } = model
2088
+ ? isVirtualModel(model) ? describeVirtualModel(model) : describeModel(model)
2089
+ : { modelName: undefined, modelId: undefined };
2090
+ const effectiveMaxTurns = normalizeMaxTurns(resolvedConfig.maxTurns ?? getDefaultMaxTurns());
2091
+ const agentInvocation: AgentInvocation = {
2092
+ modelName,
2093
+ modelId,
2094
+ thinking,
2095
+ // Only set where the agent file outranked the caller, so the surfaces can
2096
+ // disclose a parameter that was accepted but could not take effect (#182).
2097
+ requestedThinking: resolvedConfig.overridden?.thinking,
2098
+ // Explicit value only — the default fallback would just add noise.
2099
+ // Normalize so `0` (unlimited) doesn't surface as a misleading "max turns: 0".
2100
+ maxTurns: normalizeMaxTurns(resolvedConfig.maxTurns),
2101
+ isolated,
2102
+ inheritContext,
2103
+ runInBackground,
2104
+ isolation,
2105
+ };
2106
+ // Tool-result render shows the mode label too; viewer's header already does.
2107
+ const modeLabel = getPromptModeLabel(subagentType);
2108
+ const { tags: invocationTags } = buildInvocationTags(agentInvocation);
2109
+ const agentTags = modeLabel ? [modeLabel, ...invocationTags] : invocationTags;
2110
+ const detailBase = {
2111
+ displayName,
2112
+ description: params.description,
2113
+ subagentType,
2114
+ modelName,
2115
+ tags: agentTags.length > 0 ? agentTags : undefined,
2116
+ };
2117
+
2118
+ /**
2119
+ * `detailBase` for a record that exists, which outranks it: the base is a
2120
+ * snapshot of what this call REQUESTED, and pi may have resolved a
2121
+ * different model or clamped the thinking level (agent-manager writes the
2122
+ * effective values back when the session reports them). Resume goes
2123
+ * further and ignores the model/thinking parameters outright — it runs on
2124
+ * the session it is reopening — so rendering the base there advertises
2125
+ * settings the run never used.
2126
+ *
2127
+ * The mode label is rebuilt rather than carried over: it hangs off the
2128
+ * agent TYPE, not the invocation, so tags taken straight from
2129
+ * buildInvocationTags would silently drop `twin`.
2130
+ */
2131
+ const detailBaseFor = (rec: AgentRecord | undefined): typeof detailBase => {
2132
+ if (!rec?.invocation) return detailBase;
2133
+ const type = rec.type;
2134
+ const { modelName: recModelName, tags } = buildInvocationTags(rec.invocation);
2135
+ const recModeLabel = getPromptModeLabel(type);
2136
+ const recTags = recModeLabel ? [recModeLabel, ...tags] : tags;
2137
+ return {
2138
+ displayName: getDisplayName(type),
2139
+ description: rec.description,
2140
+ subagentType: type,
2141
+ modelName: recModelName,
2142
+ tags: recTags.length > 0 ? recTags : undefined,
2143
+ };
2144
+ };
2145
+
2146
+ // ---- Schedule: register a job, don't spawn now ----
2147
+ if (params.schedule) {
2148
+ if (!isSchedulingEnabled()) {
2149
+ return textResult("Scheduling is disabled in this project. Enable via /agents → Settings → Scheduling.");
2150
+ }
2151
+ if (params.resume) {
2152
+ return textResult("Cannot combine `schedule` with `resume` — schedules create fresh agents.");
2153
+ }
2154
+ if (params.inherit_context) {
2155
+ return textResult("Cannot combine `schedule` with `inherit_context` — there is no parent conversation at fire time.");
2156
+ }
2157
+ if (params.run_in_background === false) {
2158
+ return textResult("Cannot combine `schedule` with `run_in_background: false` — scheduled jobs always run in background.");
2159
+ }
2160
+ if (cwd !== undefined) {
2161
+ return textResult("Cannot combine `schedule` with `cwd` — scheduled jobs run in the session cwd.");
2162
+ }
2163
+ if (!scheduler.isActive()) {
2164
+ return textResult("Scheduler is not active in this session yet. Try again after the session has fully started.");
2165
+ }
2166
+ try {
2167
+ const job = scheduler.addJob({
2168
+ name: params.description as string,
2169
+ description: params.description as string,
2170
+ schedule: params.schedule as string,
2171
+ // The caller's own name, not the substitute — the scheduler re-resolves
2172
+ // at fire time, and the original is what a user edits.
2173
+ subagent_type: requestedType,
2174
+ prompt: params.prompt as string,
2175
+ model: params.model as string | undefined,
2176
+ thinking: thinking,
2177
+ max_turns: effectiveMaxTurns,
2178
+ isolated: isolated,
2179
+ isolation: isolation,
2180
+ });
2181
+ const next = scheduler.getNextRun(job.id);
2182
+ return textResult(
2183
+ `${fallbackNote}Scheduled "${job.name}" (id: ${job.id}, type: ${job.scheduleType}). ` +
2184
+ `Next run: ${next ?? "(unknown)"}. ` +
2185
+ `Manage via /agents → Scheduled jobs.`,
2186
+ );
2187
+ } catch (err) {
2188
+ return textResult(err instanceof Error ? err.message : String(err));
2189
+ }
2190
+ }
2191
+
2192
+ // Resume existing agent
2193
+ if (params.resume) {
2194
+ const existing = manager.getRecord(params.resume);
2195
+ if (!existing || !isTopLevelAgent(existing)) {
2196
+ return textResult(`Agent not found: "${params.resume}". It may have been cleaned up.`);
2197
+ }
2198
+ if (!existing.session) {
2199
+ return textResult(`Agent "${params.resume}" has no active session to resume.`);
2200
+ }
2201
+ // Not `effectiveMaxTurns`: that was resolved for `subagent_type`, which
2202
+ // a resume ignores, so it may describe another agent.
2203
+ const resumeMaxTurns = resumeTurnLimit(existing, params.max_turns);
2204
+
2205
+ // Background resume: detached run that notifies on completion, mirroring
2206
+ // a background spawn. Previously run_in_background was silently ignored
2207
+ // on resume (this branch returned before the background branch below),
2208
+ // so a resumed agent always blocked the main loop until it finished.
2209
+ if (runInBackground) {
2210
+ const id = existing.id;
2211
+ // A detached resume hands control back while the record stays
2212
+ // "running", so nothing stops the model from resuming the same agent
2213
+ // again mid-run. manager.resume() refuses that (it would orphan the
2214
+ // live run's abort controller); say why here, where the model can act
2215
+ // on it, instead of letting it read as a generic failure.
2216
+ if (existing.status === "running" || existing.status === "queued") {
2217
+ return textResult(
2218
+ `Agent "${params.resume}" is still ${existing.status} — it can only be resumed once its current run finishes.\n` +
2219
+ `Use steer_subagent to send it a message mid-run, or get_subagent_result to wait for it.`,
2220
+ );
2221
+ }
2222
+
2223
+ const record = await startBackgroundResume(ctx, existing, params.prompt, {
2224
+ outputTranscript,
2225
+ maxTurns: resumeMaxTurns,
2226
+ toolCallId,
2227
+ });
2228
+ if (!record) {
2229
+ return textResult(`Failed to resume agent "${params.resume}".`);
2230
+ }
2231
+
2232
+ const isQueued = record.status === "queued";
2233
+ return textResult(
2234
+ `Agent ${isQueued ? "queued" : "resumed"} in background.\n` +
2235
+ `Agent ID: ${id}\n` +
2236
+ `Type: ${existing.type}\n` +
2237
+ (record.outputFile ? `Output file: ${record.outputFile}\n` : "") +
2238
+ (isQueued ? `Position: queued (max ${manager.getMaxConcurrent()} concurrent)\n` : "") +
2239
+ `\nYou will be notified when this agent completes.\n` +
2240
+ `Use get_subagent_result to retrieve full results, or steer_subagent to send it messages.`,
2241
+ { ...detailBaseFor(record), toolUses: record.toolUses, tokens: "", durationMs: 0, status: "background" as const, agentId: id },
2242
+ );
2243
+ }
2244
+
2245
+ // Tracked like a foreground spawn, so the widget row and the result
2246
+ // card show THIS run's `↻N≤M` and tool activity rather than the
2247
+ // previous run's. The session predates this run, so it is seeded here.
2248
+ const { state: resumeState, callbacks: resumeCallbacks } = createActivityTracker(normalizeMaxTurns(resumeMaxTurns), undefined, clock);
2249
+ resumeState.session = existing.session;
2250
+ agentActivity.set(existing.id, resumeState);
2251
+ widget.markRunning(existing.id);
2252
+ widget.ensureTimer();
2253
+ fleet.ensureTimer();
2254
+ fleet.update();
2255
+ let record: AgentRecord | undefined;
2256
+ try {
2257
+ record = await manager.resume(params.resume, params.prompt, signal, {
2258
+ maxTurns: resumeMaxTurns,
2259
+ onTurnEnd: resumeCallbacks.onTurnEnd,
2260
+ onTextDelta: resumeCallbacks.onTextDelta,
2261
+ onModelCall: resumeCallbacks.onModelCall,
2262
+ onToolActivity: resumeCallbacks.onToolActivity,
2263
+ onAssistantUsage: resumeCallbacks.onAssistantUsage,
2264
+ });
2265
+ } finally {
2266
+ agentActivity.delete(existing.id);
2267
+ widget.markFinished(existing.id);
2268
+ fleet.onAgentFinished(existing.id);
2269
+ }
2270
+ if (!record) {
2271
+ return textResult(`Failed to resume agent "${params.resume}".`);
2272
+ }
2273
+ // A failed resume surfaces the error, plus any partial output THIS
2274
+ // resume produced (never the previous turn's answer, #144).
2275
+ if (record.status === "error") {
2276
+ return textResult(`Agent failed: ${record.error}${partialOutputSuffix(record)}`, buildDetails(detailBaseFor(record), record, resumeState));
2277
+ }
2278
+ return textResult(
2279
+ record.result?.trim() || "No output.",
2280
+ buildDetails(detailBaseFor(record), record, resumeState),
2281
+ );
2282
+ }
2283
+
2284
+ // What the model sees for a run it no longer waits on — spawned in the
2285
+ // background, or handed there mid-run (ctrl+b / viewer `b`). One text for
2286
+ // both, as in Claude Code: from here on they are the same kind of agent.
2287
+ const backgroundResult = (id: string, record: AgentRecord | undefined) => {
2288
+ const isQueued = record?.status === "queued";
2289
+ return textResult(
2290
+ `${fallbackNote}Agent ${isQueued ? "queued" : "started"} in background.\n` +
2291
+ `Agent ID: ${id}\n` +
2292
+ `Type: ${displayName}\n` +
2293
+ `Description: ${params.description}\n` +
2294
+ (record?.outputFile ? `Output file: ${record.outputFile}\n` : "") +
2295
+ (isQueued ? `Position: queued (max ${manager.getMaxConcurrent()} concurrent)\n` : "") +
2296
+ `\nYou will be notified when this agent completes.\n` +
2297
+ `Use get_subagent_result to retrieve full results, or steer_subagent to send it messages.\n` +
2298
+ `Do not duplicate this agent's work.`,
2299
+ { ...detailBaseFor(record), toolUses: 0, tokens: "", durationMs: 0, status: "background" as const, agentId: id },
2300
+ );
2301
+ };
2302
+
2303
+ // Quota admission for fresh spawns; a resume keeps its session's model.
2304
+ // A background dispatch parks as a one-shot scheduled job at the reset,
2305
+ // which the schedule store persists across restarts, unless the router's
2306
+ // exhaustion policy says fail now (`wait: false`). Anything else fails
2307
+ // now, with the reset, rather than starting on an exhausted provider.
2308
+ const dispatchModel = model ?? ctx.model;
2309
+ const quotaBlock = dispatchModel ? await checkAdmission(pi.events, modelRef(dispatchModel), undefined, clock) : undefined;
2310
+ if (quotaBlock) {
2311
+ // A parked job is a scheduled job, which has no `cwd` to carry.
2312
+ const canPark = runInBackground && !inheritContext && cwd === undefined && mayPark(quotaBlock)
2313
+ && isSchedulingEnabled() && scheduler.isActive();
2314
+ if (!canPark || !mayPark(quotaBlock)) {
2315
+ throw new Error(`${describeBlock(quotaBlock)} The agent was not started.`);
2316
+ }
2317
+ const job = scheduler.addJob({
2318
+ name: `${params.description} (quota ${nanoid(4)})`,
2319
+ description: params.description as string,
2320
+ schedule: new Date(quotaBlock.resetAt + QUOTA_RESTART_GRACE_MS).toISOString(),
2321
+ subagent_type: requestedType,
2322
+ prompt: params.prompt as string,
2323
+ model: dispatchModel ? modelRef(dispatchModel) : undefined,
2324
+ thinking,
2325
+ max_turns: effectiveMaxTurns,
2326
+ isolated,
2327
+ isolation,
2328
+ quotaParked: true,
2329
+ });
2330
+ return textResult(
2331
+ `${fallbackNote}${describeBlock(quotaBlock)} Queued "${job.name}" (id: ${job.id}) to start at ` +
2332
+ `${scheduler.getNextRun(job.id) ?? job.schedule}. Manage via /agents → Scheduled jobs.`,
2333
+ );
2334
+ }
2335
+
2336
+ // Background execution
2337
+ if (runInBackground) {
2338
+ const { state: bgState, callbacks: bgCallbacks } = createActivityTracker(effectiveMaxTurns, undefined, clock);
2339
+
2340
+ // Wrap onSessionCreated to wire output file streaming.
2341
+ // The callback lazily reads record.outputFile (set right after spawn)
2342
+ // rather than closing over a value that doesn't exist yet.
2343
+ let id: string;
2344
+ const origBgOnSession = bgCallbacks.onSessionCreated;
2345
+ bgCallbacks.onSessionCreated = (session: any) => {
2346
+ origBgOnSession(session);
2347
+ const rec = manager.getRecord(id);
2348
+ if (rec?.outputFile) {
2349
+ rec.outputCleanup = streamToOutputFile(session, rec.outputFile, id, ctx.cwd);
2350
+ }
2351
+ };
2352
+
2353
+ // A throw here means the agent never started. Let it out: pi marks the
2354
+ // tool call failed, whereas a returned message reads to the model as a
2355
+ // subagent that ran and reported this (#179).
2356
+ id = manager.spawn(pi, ctx, subagentType, params.prompt, {
2357
+ description: params.description,
2358
+ name: params.name as string | undefined,
2359
+ model,
2360
+ maxTurns: effectiveMaxTurns,
2361
+ isolated,
2362
+ inheritContext,
2363
+ thinkingLevel: thinking,
2364
+ isBackground: true,
2365
+ isolation,
2366
+ cwd,
2367
+ invocation: agentInvocation,
2368
+ rootSessionId: ctx.sessionManager.getSessionId(),
2369
+ ...bgCallbacks,
2370
+ });
2371
+
2372
+ // Set output file + join mode synchronously after spawn, before the
2373
+ // event loop yields — onSessionCreated is async so this is safe.
2374
+ const joinMode = resolveJoinMode(defaultJoinMode, true);
2375
+ const record = manager.getRecord(id);
2376
+ if (record && joinMode) {
2377
+ record.joinMode = joinMode;
2378
+ record.toolCallId = toolCallId;
2379
+ attachTranscript(record, id);
2380
+ }
2381
+
2382
+ // With isolation: "worktree" the agent isn't running yet — the repo
2383
+ // copy is an awaited git call. Wait for it here, after the synchronous
2384
+ // wiring above, so a strict-isolation failure still fails THIS tool
2385
+ // call instead of being reported as a subagent that ran (#179).
2386
+ await manager.awaitStartup(id);
2387
+
2388
+ joinTurnGroup(id, joinMode);
2389
+
2390
+ agentActivity.set(id, bgState);
2391
+ widget.ensureTimer();
2392
+ widget.update();
2393
+ fleet.ensureTimer();
2394
+ fleet.update();
2395
+
2396
+ // Emit created event
2397
+ pi.events.emit("subagents:created", {
2398
+ id,
2399
+ type: subagentType,
2400
+ description: params.description,
2401
+ isBackground: true,
2402
+ });
2403
+
2404
+ return backgroundResult(id, record);
2405
+ }
2406
+
2407
+ // Foreground (synchronous) execution — stream progress via onUpdate
2408
+ let spinnerFrame = 0;
2409
+ const startedAt = Date.now();
2410
+ let fgId: string | undefined;
2411
+ // Set once the run is live and can be handed to the background — see
2412
+ // `foregroundHandoffs`. Unset while queued: there is no run to hand over.
2413
+ let handoffId: string | undefined;
2414
+ // Set by the handoff. From then on `onUpdate` is a dead handle (pi drops
2415
+ // updates once `execute` has returned), so `streamUpdate` stops building
2416
+ // them; the tracker keeps `fgState` live for the widget and FleetView.
2417
+ let backgrounded = false;
2418
+ // Set only while the spawn is parked on a foreground concurrency slot
2419
+ // (maxConcurrentForeground); undefined the rest of the time, including
2420
+ // always when the limit is unset.
2421
+ let queuedAhead: number | undefined;
2422
+
2423
+ const streamUpdate = () => {
2424
+ if (backgrounded) return;
2425
+ // Spend from the record, everything else from the live tracker. `fgId`
2426
+ // is set in onSessionCreated below, which fires before the first
2427
+ // assistant message — so nothing is spent while this reads zero.
2428
+ const fgRecord = fgId ? manager.getRecord(fgId) : undefined;
2429
+ const details: AgentDetails = {
2430
+ ...detailBaseFor(fgRecord),
2431
+ toolUses: fgState.toolUses,
2432
+ tokens: fgRecord ? formatLifetimeTokens(fgRecord) : "",
2433
+ cost: fgRecord ? getLifetimeCost(fgRecord.lifetimeUsage) : 0,
2434
+ turnCount: fgState.turnCount,
2435
+ maxTurns: fgState.maxTurns,
2436
+ durationMs: Date.now() - startedAt,
2437
+ // Deliberately still "running" while queued: the renderer routes any
2438
+ // status it doesn't know to raw text (see the catch-all below), which
2439
+ // would drop the spinner and read as hung. Only the activity line
2440
+ // changes — "thinking…" would be a lie for an agent that has not
2441
+ // started and may not for minutes.
2442
+ status: "running",
2443
+ activity: queuedAhead === undefined
2444
+ ? describeActivity(fgState.activeTools, fgState.responseText, fgState.modelCall)
2445
+ : `queued — waiting for a foreground slot${queuedAhead > 0 ? ` (${queuedAhead} ahead)` : ""}`,
2446
+ spinnerFrame: spinnerFrame % SPINNER.length,
2447
+ // One hint per batch, as in Claude Code: on the earliest call still
2448
+ // pending (a Map iterates in insertion order). ctrl+b takes them all.
2449
+ backgroundHint: handoffId !== undefined && foregroundHandoffs.keys().next().value === handoffId,
2450
+ };
2451
+ onUpdate?.({
2452
+ content: [{ type: "text", text: `${fgState.toolUses} tool uses...` }],
2453
+ details: details as any,
2454
+ });
2455
+ };
2456
+
2457
+ const { state: fgState, callbacks: fgCallbacks } = createActivityTracker(effectiveMaxTurns, streamUpdate, clock);
2458
+
2459
+ // Wire session creation: register in widget + stream to output file.
2460
+ // The output file path is set synchronously after spawn (below),
2461
+ // before onSessionCreated fires — same pattern as background agents.
2462
+ const origOnSession = fgCallbacks.onSessionCreated;
2463
+ fgCallbacks.onSessionCreated = (session: any) => {
2464
+ origOnSession(session);
2465
+ // It really started — stop reporting it as queued, and repaint now
2466
+ // rather than leaving the stale line up for the next spinner tick.
2467
+ // Guarded, so a spawn that never queued emits no extra update.
2468
+ if (queuedAhead !== undefined) {
2469
+ queuedAhead = undefined;
2470
+ streamUpdate();
2471
+ }
2472
+ for (const a of manager.listAgents()) {
2473
+ if (a.session === session) {
2474
+ fgId = a.id;
2475
+ agentActivity.set(a.id, fgState);
2476
+ widget.ensureTimer();
2477
+ fleet.ensureTimer();
2478
+ fleet.update();
2479
+ break;
2480
+ }
2481
+ }
2482
+ // Stream conversation to output file (foreground agent logging)
2483
+ if (fgId) {
2484
+ const rec = manager.getRecord(fgId);
2485
+ if (rec?.outputFile) {
2486
+ rec.outputCleanup = streamToOutputFile(session, rec.outputFile, fgId, ctx.cwd);
2487
+ }
2488
+ }
2489
+ };
2490
+
2491
+ // Animate spinner at ~80ms (smooth rotation through 10 braille frames)
2492
+ const spinnerInterval = clock.setInterval(() => {
2493
+ spinnerFrame++;
2494
+ streamUpdate();
2495
+ }, 80);
2496
+
2497
+ streamUpdate();
2498
+
2499
+ // ctrl+b or the viewer's `b` wakes this with the run already handed over
2500
+ // (`sendForegroundToBackground`), raced against the run below. The
2501
+ // manager only accepts a handoff of a run that has not settled, and the
2502
+ // wake-up resolves synchronously with it, so whichever promise wins is
2503
+ // the truth: the result is never also delivered as a notification.
2504
+ let wakeBackgrounded!: () => void;
2505
+ const handedOff = new Promise<undefined>(resolve => { wakeBackgrounded = () => resolve(undefined); });
2506
+
2507
+ let fgResult: { record: AgentRecord } | undefined;
2508
+ try {
2509
+ fgResult = await Promise.race([manager.spawnAndWait(pi, ctx, subagentType, params.prompt, {
2510
+ description: params.description,
2511
+ name: params.name as string | undefined,
2512
+ model,
2513
+ maxTurns: effectiveMaxTurns,
2514
+ isolated,
2515
+ inheritContext,
2516
+ thinkingLevel: thinking,
2517
+ isolation,
2518
+ cwd,
2519
+ invocation: agentInvocation,
2520
+ signal,
2521
+ rootSessionId: ctx.sessionManager.getSessionId(),
2522
+ // Deliberately does NOT set fgId: that drives agentActivity, the
2523
+ // widget and the `finally` cleanup below, none of which should see an
2524
+ // agent that has no session and may never get one.
2525
+ onQueued: (_id, ahead) => { queuedAhead = ahead; streamUpdate(); },
2526
+ ...fgCallbacks,
2527
+ }, (fgAgentId) => {
2528
+ // onSpawned: called synchronously after spawn, before onSessionCreated fires.
2529
+ // Set up the output file so streamToOutputFile can pick it up.
2530
+ const fgRec = manager.getRecord(fgAgentId);
2531
+ attachTranscript(fgRec, fgAgentId);
2532
+ // Fires once the run promise exists, i.e. the agent is running. From
2533
+ // here until this call returns, the run can be handed off.
2534
+ handoffId = fgAgentId;
2535
+ foregroundHandoffs.set(fgAgentId, () => {
2536
+ // What a background spawn sets up front: the notification names
2537
+ // this tool call, and it reports alone — its spawn batch is long
2538
+ // closed.
2539
+ if (fgRec) {
2540
+ fgRec.toolCallId = toolCallId;
2541
+ fgRec.joinMode = "async";
2542
+ }
2543
+ backgrounded = true;
2544
+ wakeBackgrounded();
2545
+ });
2546
+ }), handedOff]);
2547
+ } finally {
2548
+ // Runs on both paths, so a startup throw — which now propagates, see
2549
+ // the background spawn above (#179) — no longer leaves the spinner
2550
+ // ticking or a finished agent on the widget.
2551
+ spinnerInterval.cancel();
2552
+ if (handoffId !== undefined) foregroundHandoffs.delete(handoffId);
2553
+ // A handed-off run is still working: its row stays, and the
2554
+ // background settle in `onComplete` tears it down when it really ends.
2555
+ if (fgId && !backgrounded) {
2556
+ agentActivity.delete(fgId);
2557
+ widget.markFinished(fgId);
2558
+ fleet.onAgentFinished(fgId);
2559
+ }
2560
+ }
2561
+
2562
+ if (fgResult === undefined) return backgroundResult(handoffId!, manager.getRecord(handoffId!));
2563
+ const record = fgResult.record;
2564
+
2565
+ // Get final token count — from the record, like the cost below it, so the
2566
+ // two describe the same work when the agent delegated to nested children.
2567
+ const tokenText = formatLifetimeTokens(record);
2568
+
2569
+ const details = buildDetails(detailBaseFor(record), record, fgState, { tokens: tokenText });
2570
+
2571
+ if (record.status === "error") {
2572
+ // Error headline + any partial output the run produced before failing.
2573
+ return textResult(`${fallbackNote}Agent failed: ${record.error}${partialOutputSuffix(record)}`, details);
2574
+ }
2575
+
2576
+ const durationMs = (record.completedAt ?? Date.now()) - record.startedAt;
2577
+ const statsParts = [`${record.toolUses} tool uses`];
2578
+ if (tokenText) statsParts.push(tokenText);
2579
+ if (showCost) {
2580
+ const costText = formatCost(getLifetimeCost(record.lifetimeUsage));
2581
+ if (costText) statsParts.push(costText);
2582
+ }
2583
+ return textResult(
2584
+ `${fallbackNote}Agent completed in ${formatMs(durationMs)} (${statsParts.join(", ")})${getForegroundOutcomeNote(record.status)}.\n\n` +
2585
+ (record.result?.trim() || "No output."),
2586
+ details,
2587
+ );
2588
+ },
2589
+ });
2590
+ /**
2591
+ * Wrap a tool so its results carry back whatever subagent spend the parent
2592
+ * session has not been told about yet (see `PendingUsagePool`).
2593
+ *
2594
+ * Pi copies `AgentToolResult.usage` onto the persisted tool-result message and
2595
+ * folds it into `getSessionStats()`, which is what the footer, the statusline
2596
+ * and `/cost` read — so this is the whole of "report usage to the parent".
2597
+ *
2598
+ * Nothing is attached to a call with no tool-call id. That is the `@handle`
2599
+ * mention path (`mention-clone.ts`), which invokes this tool from a fork of the
2600
+ * conversation that is discarded moments later: the result never becomes a
2601
+ * message in the real session, so usage hung on it would be spend the user paid
2602
+ * for and nobody counted. Skipping leaves it pending for the next real result.
2603
+ */
2604
+ function withUsageReporting<T extends { execute: (...args: any[]) => any }>(tool: T): T {
2605
+ return {
2606
+ ...tool,
2607
+ execute: async (toolCallId: string | undefined, ...rest: any[]) => {
2608
+ const result = await tool.execute(toolCallId, ...rest);
2609
+ if (!reportUsage || !toolCallId) return result;
2610
+ const usage = pendingUsage.drain();
2611
+ return usage ? { ...result, usage } : result;
2612
+ },
2613
+ };
2614
+ }
2615
+ function registerToolReportingUsage(tool: any): void {
2616
+ pi.registerTool(withUsageReporting(tool));
2617
+ }
2618
+
2619
+ // The mention path is handed THIS object, not the bare `agentTool` — see the
2620
+ // mention-clone header on why the clone must call the registered tool.
2621
+ let registeredAgentTool = withUsageReporting(agentTool);
2622
+ let shownDescription = agentTool.description;
2623
+ let shownTypeDescription = buildSubagentTypeDescription();
2624
+ pi.registerTool(registeredAgentTool);
2625
+ /**
2626
+ * Re-register `Agent` with a description and `subagent_type` text rebuilt from
2627
+ * the current registry. Wraps the unwrapped `agentTool` again, never the
2628
+ * previous wrapper, so usage is reported once. A no-op when nothing the model
2629
+ * sees changed. `registeredAgentTool` is what the mention path reads, so it
2630
+ * always points at the current definition.
2631
+ */
2632
+ function refreshAgentTool(): void {
2633
+ const description = buildAgentToolDescription();
2634
+ const typeDescription = buildSubagentTypeDescription();
2635
+ if (description === shownDescription && typeDescription === shownTypeDescription) return;
2636
+ shownDescription = description;
2637
+ shownTypeDescription = typeDescription;
2638
+ const properties = { ...agentTool.parameters.properties, subagent_type: Type.String({ description: typeDescription }) };
2639
+ registeredAgentTool = withUsageReporting({ ...agentTool, description, parameters: { ...agentTool.parameters, properties } });
2640
+ pi.registerTool(registeredAgentTool);
2641
+ }
2642
+
2643
+ // ---- Workflow tool ----
2644
+
2645
+ /**
2646
+ * Live runs, by task id. The tool returns before the run finishes, so its
2647
+ * result card looks the task up here on every render rather than freezing a
2648
+ * snapshot into `details` — that is what makes the inline card follow a
2649
+ * background run.
2650
+ */
2651
+ const workflowTasks = new Map<string, WorkflowTask>();
2652
+
2653
+ /**
2654
+ * Workflow runs as the fleet list wants them.
2655
+ *
2656
+ * Mapped here rather than handing `WorkflowTask` over the seam: the list is
2657
+ * deliberately ignorant of the workflow engine, and a run's counters live in
2658
+ * the progress log rather than on the record, so they are derived per call
2659
+ * the same way the card derives them.
2660
+ */
2661
+ function fleetWorkflows(): FleetWorkflow[] {
2662
+ // Cached counters only, no derivation: the fleet list calls this on a
2663
+ // 200ms tick and reads the roster several times per update, so walking a
2664
+ // run's progress log here would put O(log) work in the render loop.
2665
+ return [...workflowTasks.values()].map(task => ({
2666
+ id: task.id,
2667
+ name: task.meta?.name ?? task.workflowName ?? task.id,
2668
+ status: task.status,
2669
+ doneCount: task.doneCount,
2670
+ totalCount: task.agentCount,
2671
+ startedAt: task.startTime,
2672
+ ...(task.endTime !== undefined ? { completedAt: task.endTime } : {}),
2673
+ tokens: task.totalTokens,
2674
+ }));
2675
+ }
2676
+
2677
+ /**
2678
+ * Run a task to completion against the real manager, settling the record
2679
+ * either way. Never rejects: a run that cannot start (bad `meta`, oversized
2680
+ * source, non-JSON `args`) is a failed workflow, and both callers here are
2681
+ * detached — a rejection would surface as an unhandled one.
2682
+ */
2683
+ async function runWorkflowTask(ctx: ExtensionContext, task: WorkflowTask): Promise<void> {
2684
+ try {
2685
+ const result = await runWorkflow({
2686
+ script: task.script,
2687
+ args: task.args,
2688
+ signal: task.abortController.signal,
2689
+ host: createWorkflowHost({
2690
+ pi,
2691
+ ctx,
2692
+ manager,
2693
+ signal: task.abortController.signal,
2694
+ rootSessionId: ctx.sessionManager.getSessionId(),
2695
+ workflowId: task.id,
2696
+ }),
2697
+ onProgress: entries => updateWorkflowProgressBatch(task, entries),
2698
+ // The dialog's pause / skip / retry keys run through this; it is dropped
2699
+ // again when the task settles.
2700
+ onControl: control => { task.control = control; },
2701
+ journal: {
2702
+ ...(task.replay !== undefined ? { entries: task.replay } : {}),
2703
+ ...(task.journalPath !== undefined
2704
+ ? { append: (entry: WorkflowJournalEntry) => appendJournal(task.journalPath!, entry) }
2705
+ : {}),
2706
+ },
2707
+ });
2708
+ completeWorkflowTask(task, result);
2709
+ } catch (err) {
2710
+ failWorkflowTask(task, err instanceof Error ? err.message : String(err));
2711
+ }
2712
+ }
2713
+
2714
+ /**
2715
+ * Hand a finished run back to the model through the SAME channel a background
2716
+ * agent uses — the completion outbox, which delivers at the parent's next
2717
+ * boundary — rendered by the existing `subagent-notification` renderer.
2718
+ */
2719
+ function notifyWorkflowFinished(task: WorkflowTask) {
2720
+ widget.update();
2721
+ fleet.update();
2722
+ const result = workflowResultText(task);
2723
+ const message = {
2724
+ content: formatWorkflowNotification(task),
2725
+ details: {
2726
+ id: task.id,
2727
+ description: `Workflow ${task.workflowName ?? task.id}`,
2728
+ status: task.status === "completed" ? "completed" : task.status === "killed" ? "stopped" : "error",
2729
+ toolUses: task.totalToolCalls,
2730
+ // A workflow has agents, not turns; rendering "↻0" would be noise.
2731
+ turnCount: 0,
2732
+ totalTokens: task.totalTokens,
2733
+ durationMs: elapsedMs(task, Date.now()),
2734
+ error: task.error,
2735
+ resultPreview: result.length > 500 ? `${result.slice(0, 500)}…` : result,
2736
+ },
2737
+ };
2738
+ outbox.add({ ids: [task.id], build: () => message });
2739
+ }
2740
+
2741
+ // Defined unconditionally, registered only when the feature is on — the same
2742
+ // shape the Agent tool uses. Keeping the definition out of the `if` means the
2743
+ // switch changes exactly one thing: whether pi is ever told about the tool.
2744
+ const workflowTool = defineTool({
2745
+ name: SUBAGENT_TOOL_NAMES.WORKFLOW,
2746
+ label: "SubagentWorkflow",
2747
+ description: renderToolDescriptionTemplate(fullWorkflowToolDescription),
2748
+ promptSnippet: "Run a deterministic script that orchestrates many subagents",
2749
+ promptGuidelines: [
2750
+ "Use SubagentWorkflow when the number of agents depends on something discovered at runtime, when work flows through stages, or when findings should be independently verified. Use Agent for one delegated task or a handful you can name up front.",
2751
+ "Prefer `pipeline` over `parallel` — a barrier costs wall-clock whenever the stages are unevenly sized.",
2752
+ "A workflow runs in the background and notifies you when it finishes — do not poll or sleep waiting for it.",
2753
+ ],
2754
+ parameters: Type.Object({
2755
+ script: Type.Optional(
2756
+ Type.String({
2757
+ maxLength: 524288,
2758
+ description: "Inline workflow source. Must begin with `export const meta = { name, description }`.",
2759
+ }),
2760
+ ),
2761
+ scriptPath: Type.Optional(
2762
+ Type.String({
2763
+ description:
2764
+ "Path to a workflow script file, absolute or relative to the project. Takes precedence over `script` — this is how you re-run an edited workflow.",
2765
+ }),
2766
+ ),
2767
+ name: Type.Optional(
2768
+ Type.String({
2769
+ description:
2770
+ "Name of a saved workflow — `<name>.js` in .pi/workflows/, .agents/workflows/ or the user's agent dir. Lowest precedence: `scriptPath` and `script` both win over it.",
2771
+ }),
2772
+ ),
2773
+ args: Type.Optional(
2774
+ Type.Any({
2775
+ description: "Exposed to the script as the global `args`, verbatim. Must be JSON-shaped.",
2776
+ }),
2777
+ ),
2778
+ resumeFromRunId: Type.Optional(
2779
+ Type.String({
2780
+ pattern: "^wf_[a-z0-9-]{6,}$",
2781
+ description:
2782
+ "Run id of an earlier workflow in this session. Its unchanged leading agent() calls return their recorded results instantly; the first changed or failed call, and everything after it, runs live. Same script and args means nothing re-runs.",
2783
+ }),
2784
+ ),
2785
+ // Accepted and ignored, as in Claude Code. Models reach for them because
2786
+ // every other tool has them, and a hard schema rejection would cost a
2787
+ // whole turn to re-emit a script that was already correct. The `meta`
2788
+ // block is the one place a workflow is named.
2789
+ title: Type.Optional(
2790
+ Type.String({ description: "Ignored — set the workflow title in the script's `meta` block." }),
2791
+ ),
2792
+ description: Type.Optional(
2793
+ Type.String({ description: "Ignored — set the workflow description in the script's `meta` block." }),
2794
+ ),
2795
+ }),
2796
+
2797
+ // No ▸: core draws the disclosure marker on a settled tool row.
2798
+ renderCall(args, theme) {
2799
+ return new Text(
2800
+ `${theme.bold(theme.fg("toolTitle", "SubagentWorkflow"))} ${theme.fg("muted", workflowCallName(args))}`,
2801
+ 0,
2802
+ 0,
2803
+ );
2804
+ },
2805
+
2806
+ renderResult(result, _options, theme, renderContext) {
2807
+ const text = result.content[0]?.type === "text" ? result.content[0].text : "";
2808
+ const taskId = (result.details as { taskId?: string } | undefined)?.taskId;
2809
+ const task = taskId !== undefined ? workflowTasks.get(taskId) : undefined;
2810
+ // No task means the run predates this session (a reloaded transcript) or
2811
+ // the call never started one — show what `execute` said instead.
2812
+ if (renderContext.isError || !task) return new Text(text, 0, 0);
2813
+ // The card reads the live task; a background run settles its tool call
2814
+ // while still going, so redraw until it stops.
2815
+ keepTicking(renderContext, () => task.status === "running" || task.status === "paused", clock);
2816
+ return renderWorkflowCard(
2817
+ {
2818
+ progress: task.workflowProgress,
2819
+ task: {
2820
+ status: task.status,
2821
+ workflowName: task.workflowName,
2822
+ startTime: task.startTime,
2823
+ endTime: task.endTime,
2824
+ totalPausedMs: task.totalPausedMs,
2825
+ },
2826
+ meta: task.meta,
2827
+ agentCount: task.agentCount,
2828
+ totalTokens: task.totalTokens,
2829
+ },
2830
+ theme,
2831
+ );
2832
+ },
2833
+
2834
+ execute: async (toolCallId, params, _signal, _onUpdate, ctx) => {
2835
+ const resumeFrom = resolveResumeTarget(params.resumeFromRunId, workflowTasks);
2836
+ if (resumeFrom !== undefined && !resumeFrom.ok) return textResult(resumeFrom.message);
2837
+
2838
+ // A resume with no source of its own re-runs what that run ran. The
2839
+ // common case is an edited script, but "run that again, cheaply" should
2840
+ // not require repeating a path the run already knows.
2841
+ const resolved = resolveWorkflowScript(
2842
+ params.script === undefined && params.scriptPath === undefined && params.name === undefined
2843
+ && resumeFrom !== undefined
2844
+ ? { scriptPath: resumeFrom.scriptPath }
2845
+ : params,
2846
+ ctx.cwd,
2847
+ );
2848
+ if (!resolved.ok) return textResult(resolved.message);
2849
+
2850
+ // Parsed before anything is scheduled: a bad `meta` is an authoring error
2851
+ // the model can fix immediately, and reporting it as a background run
2852
+ // that failed a second later would just cost a turn.
2853
+ let meta: WorkflowMeta;
2854
+ try {
2855
+ meta = extractMeta(resolved.script).meta;
2856
+ } catch (err) {
2857
+ return textResult(err instanceof Error ? err.message : String(err));
2858
+ }
2859
+
2860
+ const runId = workflowRunId();
2861
+ // Every invocation lands on disk next to the agent transcripts, so
2862
+ // iterating is edit-the-file-then-rerun-with-scriptPath rather than
2863
+ // re-emitting the whole source. The journal sits beside it under the same
2864
+ // id, which is what makes a run id enough to resume from.
2865
+ let savedPath: string | undefined;
2866
+ let journalPath: string | undefined;
2867
+ try {
2868
+ const dir = sessionTaskDir(ctx.cwd, ctx.sessionManager.getSessionId());
2869
+ savedPath = join(dir, `${runId}.workflow.js`);
2870
+ writeFileSync(savedPath, resolved.script, "utf-8");
2871
+ journalPath = join(dir, `${runId}.workflow.jsonl`);
2872
+ } catch (err) {
2873
+ savedPath = undefined;
2874
+ journalPath = undefined;
2875
+ console.warn(`[pi-subagents] could not persist workflow script: ${err instanceof Error ? err.message : String(err)}`);
2876
+ }
2877
+
2878
+ const replay = resumeFrom !== undefined ? readJournal(resumeFrom.journalPath) : undefined;
2879
+
2880
+ const task = createWorkflowTask({
2881
+ id: runId,
2882
+ script: resolved.script,
2883
+ scriptPath: resolved.scriptPath ?? savedPath,
2884
+ args: params.args,
2885
+ meta,
2886
+ toolCallId,
2887
+ ...(journalPath !== undefined ? { journalPath } : {}),
2888
+ ...(replay !== undefined && replay.length > 0 ? { replay, resumedFrom: resumeFrom!.runId } : {}),
2889
+ });
2890
+ workflowTasks.set(runId, task);
2891
+ // The run's own row has to appear now, not when it settles. Its agents
2892
+ // are owned by it, so their lifecycle callbacks no longer refresh these
2893
+ // surfaces — nothing else would register the widget for a run whose
2894
+ // first agent has not started yet.
2895
+ widget.update();
2896
+ fleet.update();
2897
+
2898
+ // Background, like Claude Code: the id comes back now and the run keeps
2899
+ // going without the tool call.
2900
+ void runWorkflowTask(ctx, task).then(() => notifyWorkflowFinished(task));
2901
+
2902
+ return {
2903
+ content: [{
2904
+ type: "text" as const,
2905
+ text:
2906
+ `Workflow "${meta.name}" started in the background.\n` +
2907
+ `Task ID: ${runId}\n` +
2908
+ (task.scriptPath ? `Script: ${task.scriptPath}\n` : "") +
2909
+ (task.resumedFrom !== undefined
2910
+ ? `Resuming ${task.resumedFrom}: ${task.replay?.length ?? 0} recorded call(s) available to replay.\n`
2911
+ : params.resumeFromRunId !== undefined
2912
+ ? `Nothing to replay from ${params.resumeFromRunId} — every agent runs live.\n`
2913
+ : "") +
2914
+ `\nYou will be notified when it finishes — do NOT poll or sleep waiting for it.\n` +
2915
+ `To iterate, edit the script file and call SubagentWorkflow again with scriptPath.`,
2916
+ }],
2917
+ details: { taskId: runId },
2918
+ };
2919
+ },
2920
+ });
2921
+
2922
+ if (isWorkflowsEnabled()) pi.registerTool(workflowTool);
2923
+
2924
+ /**
2925
+ * Act on {@link decideWorkflowCollision} — the half that needs the host.
2926
+ *
2927
+ * The policy (what counts as a conflict, what a pin changes, whether there is
2928
+ * anything left to withdraw) lives in `workflow/collisions.ts`; this is the
2929
+ * host-facing shell around it: read the registry, warn, and take our tool out
2930
+ * of the active set.
2931
+ *
2932
+ * ## Why this can only happen at session_start
2933
+ *
2934
+ * `getAllTools` throws during extension loading ("Action methods cannot be
2935
+ * called during extension loading"), and load order means a check at
2936
+ * registration time could not see an extension that has not loaded yet. So
2937
+ * the decision cannot gate `registerTool`; it has to undo it. `setActiveTools`
2938
+ * is what makes that real rather than cosmetic — pi rebuilds the system
2939
+ * prompt from the new set, and `session_start` runs before any turn, so the
2940
+ * model never sees a spec we withdrew. A later `_refreshToolRegistry` keeps
2941
+ * the active set it had and only adds names new to the registry, so ours does
2942
+ * not creep back.
2943
+ *
2944
+ * Best-effort and swallowed. A diagnostic that took the session down would be
2945
+ * worse than the collision it reports.
2946
+ */
2947
+ let collisionsChecked = false;
2948
+ function resolveWorkflowCollisions(ctx: ExtensionContext): void {
2949
+ if (collisionsChecked) return;
2950
+ collisionsChecked = true;
2951
+
2952
+ const warn = (message: string) => {
2953
+ if (ctx.hasUI) ctx.ui.notify(message, "warning");
2954
+ else console.warn(`[pi-subagents] ${message}`);
2955
+ };
2956
+
2957
+ try {
2958
+ if (!isWorkflowsEnabled()) return;
2959
+
2960
+ const verdict = decideWorkflowCollision({
2961
+ tools: pi.getAllTools(),
2962
+ // Identifies our own registration: this extension does not know its
2963
+ // install path, and the description is the one field certainly ours.
2964
+ ownDescription: workflowTool.description,
2965
+ pinned: isWorkflowsPinned(),
2966
+ });
2967
+ if (verdict.kind === "none") return;
2968
+ if (verdict.kind === "report") {
2969
+ warn(verdict.message);
2970
+ return;
2971
+ }
2972
+
2973
+ workflowsEnabled = false; // not setWorkflowsEnabled: this is not the user pinning it
2974
+ widget.update();
2975
+ fleet.update();
2976
+ warn(verdict.message);
2977
+
2978
+ if (!verdict.withdraw) return;
2979
+ const active = pi.getActiveTools();
2980
+ if (active.includes(SUBAGENT_TOOL_NAMES.WORKFLOW)) {
2981
+ pi.setActiveTools(active.filter(name => name !== SUBAGENT_TOOL_NAMES.WORKFLOW));
2982
+ }
2983
+ } catch {
2984
+ // getAllTools/setActiveTools are unavailable in some hosts (print mode,
2985
+ // RPC). Not being able to check is not a reason to fail the session.
2986
+ }
2987
+ }
2988
+
2989
+ /**
2990
+ * `--subagents-workflow-file=<path>` — run a script at startup, with no LLM
2991
+ * round-trip deciding whether to call the tool.
2992
+ *
2993
+ * Read here rather than at activation because that is the only place the real
2994
+ * value exists: the host activates extensions first and applies collected CLI
2995
+ * flags second, so `getFlag` during activation returns the registered default
2996
+ * and nothing else. `examples/extensions/ssh.ts` reads its flag from
2997
+ * session_start for exactly this reason.
2998
+ */
2999
+ let workflowFlagHandled = false;
3000
+ function runWorkflowFlag(ctx: ExtensionContext): void {
3001
+ if (workflowFlagHandled) return;
3002
+ const flag = pi.getFlag(WORKFLOW_FILE_FLAG);
3003
+ if (flag === undefined || flag === false) return;
3004
+ workflowFlagHandled = true;
3005
+
3006
+ const report = (message: string, level: "info" | "warning") => {
3007
+ if (ctx.hasUI) ctx.ui.notify(message, level);
3008
+ else console.warn(`[pi-subagents] ${message}`);
3009
+ };
3010
+
3011
+ // The flag is the same machinery by another door, so the master switch has
3012
+ // to close it too — silently ignoring a flag the user typed would be worse
3013
+ // than saying why nothing ran.
3014
+ if (!isWorkflowsEnabled()) {
3015
+ report(
3016
+ `--${WORKFLOW_FILE_FLAG} ignored: workflows are off. Turn them on in /agents → Settings → Workflows, ` +
3017
+ 'or set `"workflowsEnabled": true` in .pi/subagents.json.',
3018
+ "warning",
3019
+ );
3020
+ return;
3021
+ }
3022
+
3023
+ // A bare `--subagents-workflow-file` parses to boolean `true`. Say what was
3024
+ // missing rather than reading a file called "true".
3025
+ if (typeof flag !== "string" || flag.trim() === "") {
3026
+ report(`--${WORKFLOW_FILE_FLAG} needs a path: --${WORKFLOW_FILE_FLAG}=<path>`, "warning");
3027
+ return;
3028
+ }
3029
+
3030
+ const path = isAbsolute(flag.trim()) ? flag.trim() : join(ctx.cwd, flag.trim());
3031
+ let script: string;
3032
+ try {
3033
+ script = readFileSync(path, "utf-8");
3034
+ } catch (err) {
3035
+ report(`Could not read ${path}: ${err instanceof Error ? err.message : String(err)}`, "warning");
3036
+ return;
3037
+ }
3038
+
3039
+ let meta: WorkflowMeta | undefined;
3040
+ try {
3041
+ meta = extractMeta(script).meta;
3042
+ } catch (err) {
3043
+ report(err instanceof Error ? err.message : String(err), "warning");
3044
+ return;
3045
+ }
3046
+
3047
+ const task = createWorkflowTask({ id: workflowRunId(), script, scriptPath: path, meta });
3048
+ workflowTasks.set(task.id, task);
3049
+ widget.update();
3050
+ fleet.update();
3051
+ report(`Running workflow ${meta.name}…`, "info");
3052
+
3053
+ // Detached: session_start is awaited by the host, and a workflow can run for
3054
+ // minutes — blocking here would hold the whole session's startup.
3055
+ void runWorkflowTask(ctx, task).then(() => {
3056
+ // No tool call to attach a result card to, so the card becomes a session
3057
+ // entry (same layout), and the outcome is handed to the model as context
3058
+ // for its next turn rather than forcing one.
3059
+ pi.appendEntry<WorkflowEntryData>(WORKFLOW_ENTRY_TYPE, workflowEntryData(task));
3060
+ pi.sendMessage({
3061
+ customType: "workflow-result",
3062
+ content: formatWorkflowNotification(task),
3063
+ display: false,
3064
+ }, { deliverAs: "nextTurn" });
3065
+ widget.update();
3066
+ fleet.update();
3067
+ });
3068
+ }
3069
+
3070
+ /** "Not found", or what became of the agent when its record was evicted. */
3071
+ function agentNotFound(ref: string): string {
3072
+ const note = manager.getEvictedNote(ref);
3073
+ return note ? describeEvicted(note) : `Agent not found: "${ref}". It may have been cleaned up.`;
3074
+ }
3075
+
3076
+ // ---- get_subagent_result tool ----
3077
+
3078
+ registerToolReportingUsage(defineTool({
3079
+ name: SUBAGENT_TOOL_NAMES.GET_RESULT,
3080
+ label: "Get Agent Result",
3081
+ description:
3082
+ "Check status and retrieve a background agent's full result — its completion notification carries only a preview. Use the agent ID returned by Agent.",
3083
+ promptSnippet: "Check status and retrieve results from a background agent",
3084
+ parameters: Type.Object({
3085
+ agent_id: Type.String({
3086
+ description: "The agent ID to check. The agent's handle also works — its `name` if you gave it one, otherwise its type (`explore`, `explore-2`).",
3087
+ }),
3088
+ wait: Type.Optional(
3089
+ Type.Boolean({
3090
+ description: "If true, wait for the agent to complete before returning. Default: false.",
3091
+ }),
3092
+ ),
3093
+ verbose: Type.Optional(
3094
+ Type.Boolean({
3095
+ description: "If true, include the agent's full conversation (messages + tool calls). Default: false.",
3096
+ }),
3097
+ ),
3098
+ }),
3099
+ execute: async (_toolCallId, params, signal, _onUpdate, _ctx) => {
3100
+ const record = resolveAgentRef(params.agent_id);
3101
+ if (!record || !isTopLevelAgent(record)) return textResult(agentNotFound(params.agent_id));
3102
+
3103
+ // Wait for completion if requested. Cancellation stops only this tool
3104
+ // call; the background agent keeps running and remains unconsumed so its
3105
+ // completion notification can still be delivered.
3106
+ // Queued agents have no promise yet (it's created when the queue starts
3107
+ // them), so poll until they leave the queue, then await like a running one.
3108
+ if (params.wait && (record.status === "running" || record.status === "queued")) {
3109
+ while (record.status === "queued") {
3110
+ await abortable(record.startGate ?? new Promise<void>((resolve) => clock.setTimeout(resolve, QUEUE_WAIT_POLL_MS)), signal);
3111
+ }
3112
+ if (record.promise) await abortable(record.promise, signal);
3113
+ }
3114
+
3115
+ const displayName = getDisplayName(record.type);
3116
+ const duration = formatDuration(record.startedAt, record.completedAt, clock.now());
3117
+ const tokens = formatLifetimeTokens(record);
3118
+ const contextPercent = getSessionContextPercent(record.session);
3119
+ const statsParts = [`Tool uses: ${record.toolUses}`];
3120
+ if (tokens) statsParts.push(tokens);
3121
+ if (showCost) {
3122
+ const costText = formatCost(getLifetimeCost(record.lifetimeUsage));
3123
+ if (costText) statsParts.push(`Cost: ${costText}`);
3124
+ }
3125
+ if (contextPercent !== null) statsParts.push(`Context: ${Math.round(contextPercent)}%`);
3126
+ if (record.compactionCount) statsParts.push(`Compactions: ${record.compactionCount}`);
3127
+ statsParts.push(`Duration: ${duration}`);
3128
+
3129
+ let output =
3130
+ `Agent: ${record.id}\n` +
3131
+ `Type: ${displayName} | Status: ${record.status}${getStatusNote(record.status)} | ${statsParts.join(" | ")}\n` +
3132
+ `Description: ${record.description}\n\n`;
3133
+
3134
+ if (record.status === "running") {
3135
+ output += "Agent is still running. Use wait: true or check back later.";
3136
+ } else if (record.status === "error") {
3137
+ output += `Error: ${record.error}${partialOutputSuffix(record)}`;
3138
+ } else {
3139
+ output += record.result?.trim() || "No output.";
3140
+ }
3141
+
3142
+ // Mark result as consumed — suppresses the completion notification
3143
+ if (record.status !== "running" && record.status !== "queued") {
3144
+ markConsumed(record, clock.now());
3145
+ outbox.cancel(record.id);
3146
+ }
3147
+
3148
+ // Verbose: include full conversation
3149
+ if (params.verbose && record.session) {
3150
+ const conversation = getAgentConversation(record.session);
3151
+ if (conversation) {
3152
+ output += `\n\n--- Agent Conversation ---\n${conversation}`;
3153
+ }
3154
+ }
3155
+
3156
+ return textResult(output);
3157
+ },
3158
+ }));
3159
+
3160
+ // ---- steer_subagent tool ----
3161
+
3162
+ registerToolReportingUsage(defineTool({
3163
+ name: SUBAGENT_TOOL_NAMES.STEER,
3164
+ label: "Steer Agent",
3165
+ description:
3166
+ "Send a steering message to a running agent. The message will interrupt the agent after its current tool execution " +
3167
+ "and be injected into its conversation, allowing you to redirect its work mid-run. A queued agent gets the message when it starts.",
3168
+ promptSnippet: "Send a steering message to redirect a running background agent",
3169
+ parameters: Type.Object({
3170
+ agent_id: Type.String({
3171
+ description: "The agent ID to steer (must be running or queued). The agent's handle also works — its `name` if you gave it one, otherwise its type (`explore`, `explore-2`).",
3172
+ }),
3173
+ message: Type.String({
3174
+ description: "The steering message to send. This will appear as a user message in the agent's conversation.",
3175
+ }),
3176
+ }),
3177
+ execute: async (_toolCallId, params, _signal, _onUpdate, _ctx) => {
3178
+ const record = resolveAgentRef(params.agent_id);
3179
+ if (!record || !isTopLevelAgent(record)) return textResult(agentNotFound(params.agent_id));
3180
+ if (record.status !== "running" && record.status !== "queued") {
3181
+ return textResult(`Agent "${params.agent_id}" is not running (status: ${record.status}). Cannot steer a finished agent.`);
3182
+ }
3183
+ if (!record.session) {
3184
+ // Queued for a concurrency slot, or running with its session still
3185
+ // being built: either way the manager flushes `pendingSteers` into the
3186
+ // session the moment it exists, ahead of the first turn.
3187
+ if (!record.pendingSteers) record.pendingSteers = [];
3188
+ record.pendingSteers.push(params.message);
3189
+ pi.events.emit("subagents:steered", { id: record.id, message: params.message });
3190
+ return textResult(record.status === "queued"
3191
+ ? `Steering message queued for agent ${record.id} (status: queued). It will be delivered when the agent starts.`
3192
+ : `Steering message queued for agent ${record.id}. It will be delivered once the session initializes.`);
3193
+ }
3194
+
3195
+ try {
3196
+ await steerAgent(record.session, params.message);
3197
+ pi.events.emit("subagents:steered", { id: record.id, message: params.message });
3198
+ const tokens = formatLifetimeTokens(record);
3199
+ const contextPercent = getSessionContextPercent(record.session);
3200
+ const stateParts: string[] = [];
3201
+ if (tokens) stateParts.push(tokens);
3202
+ if (showCost) {
3203
+ const costText = formatCost(getLifetimeCost(record.lifetimeUsage));
3204
+ if (costText) stateParts.push(costText);
3205
+ }
3206
+ stateParts.push(`${record.toolUses} tool ${record.toolUses === 1 ? "use" : "uses"}`);
3207
+ if (contextPercent !== null) stateParts.push(`context ${Math.round(contextPercent)}% full`);
3208
+ if (record.compactionCount) stateParts.push(`${record.compactionCount} compaction${record.compactionCount === 1 ? "" : "s"}`);
3209
+ return textResult(
3210
+ `Steering message sent to agent ${record.id}. The agent will process it after its current tool execution.\n` +
3211
+ `Current state: ${stateParts.join(" · ")}`,
3212
+ );
3213
+ } catch (err) {
3214
+ return textResult(`Failed to steer agent: ${err instanceof Error ? err.message : String(err)}`);
3215
+ }
3216
+ },
3217
+ }));
3218
+
3219
+ // ---- /agents interactive menu ----
3220
+
3221
+ // Directory resolution and the frontmatter edits live in agent-file-toggle.ts
3222
+ // so they are reachable from tests — this command handler is only registered
3223
+ // through `registerCommand`, which every test mocks.
3224
+
3225
+ function getModelLabel(type: string, registry?: ModelRegistry): string {
3226
+ const cfg = getAgentConfig(type);
3227
+ if (!cfg?.model) return "inherit"; // no model configured → really inherits parent
3228
+ const label = getModelLabelFromConfig(cfg.model);
3229
+ if (!registry) return label;
3230
+ const resolved = resolveModel(cfg.model, registry);
3231
+ // Configured but unresolvable: the runtime silently falls back to the parent
3232
+ // model, so flag it (and the fallback) rather than hiding the config.
3233
+ if (typeof resolved === "string") return `${label} (unavailable, fallback: inherit)`;
3234
+ // Surface what it actually resolved to when that differs from the config —
3235
+ // e.g. a provider fallback or a looser version pin. Cosmetic separator/date
3236
+ // differences are normalized away so an effectively-identical match stays quiet.
3237
+ const resolvedFull = `${resolved.provider}/${resolved.id}`;
3238
+ const norm = (s: string) => s.toLowerCase().replace(/\./g, "-").replace(/-\d{8}$/, "");
3239
+ if (norm(cfg.model) === norm(resolvedFull)) return label;
3240
+ return `${label} (→ ${resolvedFull.replace(/-\d{8}$/, "")})`;
3241
+ }
3242
+
3243
+ async function showAgentsMenu(ctx: ExtensionCommandContext) {
3244
+ reloadCustomAgents();
3245
+ const allNames = getAllTypes();
3246
+
3247
+ // Build select options
3248
+ const options: string[] = [];
3249
+
3250
+ // Running agents entry (only if there are active agents)
3251
+ const agents = manager.listAgents().filter(isTopLevelAgent);
3252
+ if (agents.length > 0) {
3253
+ const running = agents.filter(a => a.status === "running" || a.status === "queued").length;
3254
+ const done = agents.filter(a => a.status === "completed" || a.status === "steered").length;
3255
+ options.push(`Running agents (${agents.length}) — ${running} running, ${done} done`);
3256
+ }
3257
+
3258
+ // Agent types list
3259
+ if (allNames.length > 0) {
3260
+ options.push(`Agent types (${allNames.length})`);
3261
+ }
3262
+
3263
+ // Scheduled jobs entry (always present when scheduler is active)
3264
+ if (scheduler.isActive()) {
3265
+ const jobCount = scheduler.list().length;
3266
+ options.push(`Scheduled jobs (${jobCount})`);
3267
+ }
3268
+
3269
+ // Workflow runs, on the same terms as scheduled jobs: shown only when the
3270
+ // feature is on, so the menu never advertises something switched off.
3271
+ if (isWorkflowsEnabled()) {
3272
+ options.push(`Workflows (${workflowTasks.size})`);
3273
+ }
3274
+
3275
+ // Actions
3276
+ options.push("Create new agent");
3277
+ options.push("Settings");
3278
+
3279
+ const noAgentsMsg = allNames.length === 0 && agents.length === 0
3280
+ ? "No agents found. Create specialized subagents that can be delegated to.\n\n" +
3281
+ "Each subagent has its own context window, custom system prompt, and specific tools.\n\n" +
3282
+ "Try creating: Code Reviewer, Security Auditor, Test Writer, or Documentation Writer.\n\n"
3283
+ : "";
3284
+
3285
+ if (noAgentsMsg) {
3286
+ ctx.ui.notify(noAgentsMsg, "info");
3287
+ }
3288
+
3289
+ const choice = await ctx.ui.select("Agents", options);
3290
+ if (!choice) return;
3291
+
3292
+ if (choice.startsWith("Running agents (")) {
3293
+ await showRunningAgents(ctx);
3294
+ await showAgentsMenu(ctx);
3295
+ } else if (choice.startsWith("Agent types (")) {
3296
+ await showAllAgentsList(ctx);
3297
+ await showAgentsMenu(ctx);
3298
+ } else if (choice.startsWith("Scheduled jobs (")) {
3299
+ await showSchedulesMenu(ctx, scheduler);
3300
+ await showAgentsMenu(ctx);
3301
+ } else if (choice.startsWith("Workflows (")) {
3302
+ await showWorkflowsMenu(ctx, workflowMenuDeps);
3303
+ await showAgentsMenu(ctx);
3304
+ } else if (choice === "Create new agent") {
3305
+ await showCreateWizard(ctx);
3306
+ } else if (choice === "Settings") {
3307
+ await showSettings(ctx);
3308
+ await showAgentsMenu(ctx);
3309
+ }
3310
+ }
3311
+
3312
+ /**
3313
+ * The package that provided an agent, for display only. Undefined for every
3314
+ * other source, and for a package path no currently-admitted package owns —
3315
+ * which is what a stale `sourcePath` looks like after the gate narrows.
3316
+ */
3317
+ function packageNameOf(cfg: AgentConfig | undefined): string | undefined {
3318
+ if (cfg?.source !== "package" || !cfg.sourcePath) return undefined;
3319
+ return packageNameForPath(cfg.sourcePath, process.cwd());
3320
+ }
3321
+
3322
+ async function showAllAgentsList(ctx: ExtensionCommandContext) {
3323
+ const allNames = getAllTypes();
3324
+ if (allNames.length === 0) {
3325
+ ctx.ui.notify("No agents.", "info");
3326
+ return;
3327
+ }
3328
+
3329
+ const configs = allNames.map(name => getAgentConfig(name));
3330
+
3331
+ // One row per agent (name in the left column, model on the right); the
3332
+ // full description renders below the highlighted row via SettingsList,
3333
+ // exactly like the Settings menu — so long descriptions never wrap the list.
3334
+ // A package agent's description is prefixed with the package that provided
3335
+ // it: the `▪` badge says a package won, not which one.
3336
+ const items: SettingItem[] = allNames.map((name, i) => {
3337
+ const cfg = configs[i];
3338
+ const model = getModelLabel(name, ctx.modelRegistry);
3339
+ return {
3340
+ id: name,
3341
+ label: `${sourceIndicator(cfg)}${name}`,
3342
+ currentValue: model,
3343
+ description: rowDescription(name, cfg, packageNameOf(cfg)),
3344
+ // Single-value list so Enter "activates" the row (fires onChange with the
3345
+ // agent's id) without offering anything to actually cycle.
3346
+ values: [model],
3347
+ };
3348
+ });
3349
+
3350
+ const legend = sourceLegend(configs);
3351
+
3352
+ const selected = await ctx.ui.custom<string | undefined>((_tui, _theme, _kb, done) => {
3353
+ const slTheme = getSettingsListTheme();
3354
+ const list = new SettingsList(
3355
+ items,
3356
+ Math.min(items.length, 12),
3357
+ slTheme,
3358
+ id => done(id), // Enter/Space on a row → return that agent's name
3359
+ () => done(undefined), // Esc → cancel
3360
+ );
3361
+ const container = new Container();
3362
+ container.addChild(new Text("Agent types", 0, 0));
3363
+ if (legend) container.addChild(new Text(slTheme.hint(legend), 0, 0));
3364
+ container.addChild(new Spacer(1));
3365
+ container.addChild(list);
3366
+ return {
3367
+ render: (w: number) => container.render(w),
3368
+ invalidate: () => container.invalidate(),
3369
+ handleInput: (data: string) => list.handleInput?.(data),
3370
+ };
3371
+ });
3372
+
3373
+ if (selected && getAgentConfig(selected)) {
3374
+ await showAgentDetail(ctx, selected);
3375
+ await showAllAgentsList(ctx);
3376
+ }
3377
+ }
3378
+
3379
+ async function showRunningAgents(ctx: ExtensionCommandContext) {
3380
+ const agents = manager.listAgents().filter(isTopLevelAgent);
3381
+ if (agents.length === 0) {
3382
+ ctx.ui.notify("No agents.", "info");
3383
+ return;
3384
+ }
3385
+
3386
+ // Numbered + item-paired. Two same-type agents spawned together with the
3387
+ // same description render identically here, and resolving the choice by
3388
+ // string match would open whichever came first.
3389
+ const record = await selectItem(ctx.ui, "Running agents", agents, a => {
3390
+ const dn = getDisplayName(a.type);
3391
+ const dur = formatDuration(a.startedAt, a.completedAt, clock.now());
3392
+ return `${dn} (${a.description}) · ${a.toolUses} tools · ${a.status} · ${dur}`;
3393
+ });
3394
+ if (!record) return;
3395
+
3396
+ await viewAgentConversation(ctx, record);
3397
+ // Back-navigation: re-show the list
3398
+ await showRunningAgents(ctx);
3399
+ }
3400
+
3401
+ async function viewAgentConversation(ctx: ExtensionCommandContext, record: AgentRecord) {
3402
+ if (!record.session) {
3403
+ ctx.ui.notify(`Agent is ${record.status === "queued" ? "queued" : "expired"} — no session available.`, "info");
3404
+ return;
3405
+ }
3406
+
3407
+ const session = record.session;
3408
+ const activity = agentActivity.get(record.id);
3409
+ let overlayOptions = getConversationOverlayOptions({ mode: "regular" });
3410
+
3411
+ await ctx.ui.custom<undefined>(
3412
+ (tui, theme, keybindings, done) => {
3413
+ overlayOptions = getConversationOverlayOptions(tui);
3414
+ return new ConversationViewer(tui, session, record, activity, theme, done, () => {
3415
+ if (manager.abort(record.id)) {
3416
+ ctx.ui.notify(`Stopped "${record.description}".`, "info");
3417
+ }
3418
+ }, keybindings, (message: string) => manager.steer(record.id, message), showCost, getViewerMarkdown, (mode) => chooseViewerMarkdown(mode, ctx), getFullscreenScrollbar(),
3419
+ backgroundActionFor(record, message => ctx.ui.notify(message, "info")), clock);
3420
+ },
3421
+ {
3422
+ overlay: true,
3423
+ overlayOptions: () => overlayOptions,
3424
+ },
3425
+ );
3426
+ }
3427
+
3428
+ async function showAgentDetail(ctx: ExtensionCommandContext, name: string) {
3429
+ const cfg = getAgentConfig(name);
3430
+ if (!cfg) {
3431
+ ctx.ui.notify(`Agent config not found for "${name}".`, "warning");
3432
+ return;
3433
+ }
3434
+
3435
+ const isPackage = cfg.source === "package";
3436
+ const file = locateAgentFile(name, cfg.sourcePath, undefined, cfg.source);
3437
+ const isDefault = cfg.isDefault === true;
3438
+ const disabled = cfg.enabled === false;
3439
+
3440
+ let menuOptions: string[];
3441
+ if (isPackage) {
3442
+ // Read-only: nothing here may write inside pi's install root. `Disable`
3443
+ // still appears because it writes a *local* stub that shadows the package
3444
+ // agent, and `Enable` never does — a package agent that reads as disabled
3445
+ // was disabled by its own author's `enabled: false`, which only the
3446
+ // package can take back. Eject is the way out of that: the copy it writes
3447
+ // carries no `enabled:` line, so the ejected agent is on.
3448
+ menuOptions = disabled
3449
+ ? ["Eject (export as .md)", "Back"]
3450
+ : ["Eject (export as .md)", "Disable", "Back"];
3451
+ } else if (disabled && file) {
3452
+ // Disabled agent with a file — offer Enable
3453
+ menuOptions = isDefault
3454
+ ? ["Enable", "Edit", "Reset to default", "Delete", "Back"]
3455
+ : ["Enable", "Edit", "Delete", "Back"];
3456
+ } else if (isDefault && !file) {
3457
+ // Default agent with no .md override
3458
+ menuOptions = ["Eject (export as .md)", "Disable", "Back"];
3459
+ } else if (isDefault && file) {
3460
+ // Default agent with .md override (ejected)
3461
+ menuOptions = ["Edit", "Disable", "Reset to default", "Delete", "Back"];
3462
+ } else {
3463
+ // User-defined agent
3464
+ menuOptions = ["Edit", "Disable", "Delete", "Back"];
3465
+ }
3466
+
3467
+ // Name the package in the title: this menu's options are the read-only set,
3468
+ // and "which package do I have to uninstall or allowlist away" is the
3469
+ // question they raise.
3470
+ const pkgName = packageNameOf(cfg);
3471
+ const choice = await ctx.ui.select(pkgName ? `${name} — ${pkgName}` : name, menuOptions);
3472
+ if (!choice || choice === "Back") return;
3473
+
3474
+ if (choice === "Edit" && file) {
3475
+ const content = readFileSync(file.path, "utf-8");
3476
+ const edited = await ctx.ui.editor(`Edit ${name}`, content);
3477
+ if (edited !== undefined && edited !== content) {
3478
+ const { writeFileSync } = await import("node:fs");
3479
+ writeFileSync(file.path, edited, "utf-8");
3480
+ reloadCustomAgents();
3481
+ ctx.ui.notify(`Updated ${file.path}`, "info");
3482
+ }
3483
+ } else if (choice === "Delete") {
3484
+ if (file) {
3485
+ const confirmed = await ctx.ui.confirm("Delete agent", `Delete ${name} from ${file.location} (${file.path})?`);
3486
+ if (confirmed) {
3487
+ unlinkSync(file.path);
3488
+ reloadCustomAgents();
3489
+ ctx.ui.notify(`Deleted ${file.path}`, "info");
3490
+ }
3491
+ }
3492
+ } else if (choice === "Reset to default" && file) {
3493
+ const confirmed = await ctx.ui.confirm("Reset to default", `Delete override ${file.path} and restore embedded default?`);
3494
+ if (confirmed) {
3495
+ unlinkSync(file.path);
3496
+ reloadCustomAgents();
3497
+ ctx.ui.notify(`Restored default ${name}`, "info");
3498
+ }
3499
+ } else if (choice.startsWith("Eject")) {
3500
+ await ejectAgent(ctx, name, cfg);
3501
+ } else if (choice === "Disable") {
3502
+ await disableAgent(ctx, name);
3503
+ } else if (choice === "Enable") {
3504
+ await enableAgent(ctx, name);
3505
+ }
3506
+ }
3507
+
3508
+ /** Eject a default agent: write its embedded config as a .md file. */
3509
+ async function ejectAgent(ctx: ExtensionCommandContext, name: string, cfg: AgentConfig) {
3510
+ const location = await ctx.ui.select("Choose location", [
3511
+ "Project (.pi/agents/)",
3512
+ `Personal (${personalAgentsDir()})`,
3513
+ ]);
3514
+ if (!location) return;
3515
+
3516
+ const targetDir = location.startsWith("Project") ? projectAgentsDir() : personalAgentsDir();
3517
+ mkdirSync(targetDir, { recursive: true });
3518
+
3519
+ const targetPath = join(targetDir, `${name}.md`);
3520
+ if (existsSync(targetPath)) {
3521
+ const overwrite = await ctx.ui.confirm("Overwrite", `${targetPath} already exists. Overwrite?`);
3522
+ if (!overwrite) return;
3523
+ }
3524
+
3525
+ const content = serializeAgentFile(cfg);
3526
+
3527
+ const { writeFileSync } = await import("node:fs");
3528
+ writeFileSync(targetPath, content, "utf-8");
3529
+ reloadCustomAgents();
3530
+ ctx.ui.notify(`Ejected ${name} to ${targetPath}`, "info");
3531
+ }
3532
+
3533
+ /** Disable an agent: set enabled: false in its .md file, or create a stub for built-in defaults. */
3534
+ async function disableAgent(ctx: ExtensionCommandContext, name: string) {
3535
+ const cfg = getAgentConfig(name);
3536
+ // `locateAgentFile` refuses a package agent's own path, so this is either the
3537
+ // local stub already shadowing it or nothing — never a file inside pi's
3538
+ // install root. An unshadowed package agent therefore falls to the stub
3539
+ // branch below, which writes `enabled: false` at project or personal scope.
3540
+ const file = locateAgentFile(name, cfg?.sourcePath, undefined, cfg?.source);
3541
+ if (file) {
3542
+ // Existing file — set enabled: false in frontmatter (idempotent)
3543
+ const content = readFileSync(file.path, "utf-8");
3544
+ const { content: updated, outcome } = disableInContent(content);
3545
+ if (outcome === "already-disabled") {
3546
+ ctx.ui.notify(`${name} is already disabled.`, "info");
3547
+ return;
3548
+ }
3549
+ if (outcome === "no-frontmatter") {
3550
+ // Nothing to edit — say so rather than rewriting the file unchanged and
3551
+ // reporting success for a change that never happened.
3552
+ ctx.ui.notify(`Cannot disable ${name}: ${file.path} has no frontmatter block.`, "error");
3553
+ return;
3554
+ }
3555
+ const { writeFileSync } = await import("node:fs");
3556
+ writeFileSync(file.path, updated, "utf-8");
3557
+ reloadCustomAgents();
3558
+ ctx.ui.notify(`Disabled ${name} (${file.path})`, "info");
3559
+ return;
3560
+ }
3561
+
3562
+ // No editable file (a built-in default, or a package-provided agent) —
3563
+ // create a stub that shadows it.
3564
+ const location = await ctx.ui.select("Choose location", [
3565
+ "Project (.pi/agents/)",
3566
+ `Personal (${personalAgentsDir()})`,
3567
+ ]);
3568
+ if (!location) return;
3569
+
3570
+ const targetDir = location.startsWith("Project") ? projectAgentsDir() : personalAgentsDir();
3571
+ mkdirSync(targetDir, { recursive: true });
3572
+
3573
+ const targetPath = join(targetDir, `${name}.md`);
3574
+ const { writeFileSync } = await import("node:fs");
3575
+ writeFileSync(targetPath, "---\nenabled: false\n---\n", "utf-8");
3576
+ reloadCustomAgents();
3577
+ ctx.ui.notify(`Disabled ${name} (${targetPath})`, "info");
3578
+ }
3579
+
3580
+ /** Enable a disabled agent by removing enabled: false from its frontmatter. */
3581
+ async function enableAgent(ctx: ExtensionCommandContext, name: string) {
3582
+ const cfg = getAgentConfig(name);
3583
+ const file = locateAgentFile(name, cfg?.sourcePath, undefined, cfg?.source);
3584
+ if (!file) return;
3585
+
3586
+ const content = readFileSync(file.path, "utf-8");
3587
+ const { content: updated, changed } = enableInContent(content);
3588
+ if (!changed && !isEmptyStub(updated)) {
3589
+ // The file carries no `enabled: false` to remove, so it was never disabled
3590
+ // by us — reporting success here would hide a no-op.
3591
+ ctx.ui.notify(`${name} is not disabled in ${file.path}.`, "info");
3592
+ return;
3593
+ }
3594
+ const { writeFileSync } = await import("node:fs");
3595
+
3596
+ // If the file was just a stub ("---\n---\n"), delete it to restore the built-in default
3597
+ if (isEmptyStub(updated)) {
3598
+ unlinkSync(file.path);
3599
+ reloadCustomAgents();
3600
+ ctx.ui.notify(`Enabled ${name} (removed ${file.path})`, "info");
3601
+ } else {
3602
+ writeFileSync(file.path, updated, "utf-8");
3603
+ reloadCustomAgents();
3604
+ ctx.ui.notify(`Enabled ${name} (${file.path})`, "info");
3605
+ }
3606
+ }
3607
+
3608
+ async function showCreateWizard(ctx: ExtensionCommandContext) {
3609
+ const location = await ctx.ui.select("Choose location", [
3610
+ "Project (.pi/agents/)",
3611
+ `Personal (${personalAgentsDir()})`,
3612
+ ]);
3613
+ if (!location) return;
3614
+
3615
+ const targetDir = location.startsWith("Project") ? projectAgentsDir() : personalAgentsDir();
3616
+
3617
+ const method = await ctx.ui.select("Creation method", [
3618
+ "Generate with Claude (recommended)",
3619
+ "Manual configuration",
3620
+ ]);
3621
+ if (!method) return;
3622
+
3623
+ if (method.startsWith("Generate")) {
3624
+ await showGenerateWizard(ctx, targetDir);
3625
+ } else {
3626
+ await showManualWizard(ctx, targetDir);
3627
+ }
3628
+ }
3629
+
3630
+ async function showGenerateWizard(ctx: ExtensionCommandContext, targetDir: string) {
3631
+ const description = await ctx.ui.input("Describe what this agent should do");
3632
+ if (!description) return;
3633
+
3634
+ const name = await ctx.ui.input("Agent name (filename, no spaces)");
3635
+ if (!name) return;
3636
+
3637
+ mkdirSync(targetDir, { recursive: true });
3638
+
3639
+ const targetPath = join(targetDir, `${name}.md`);
3640
+ if (existsSync(targetPath)) {
3641
+ const overwrite = await ctx.ui.confirm("Overwrite", `${targetPath} already exists. Overwrite?`);
3642
+ if (!overwrite) return;
3643
+ }
3644
+
3645
+ ctx.ui.notify("Generating agent definition...", "info");
3646
+
3647
+ const generatePrompt = `Create a custom pi sub-agent definition file based on this description: "${description}"
3648
+
3649
+ Write a markdown file to: ${targetPath}
3650
+
3651
+ The file format is a markdown file with YAML frontmatter and a system prompt body:
3652
+
3653
+ \`\`\`markdown
3654
+ ---
3655
+ description: <one-line description shown in UI>
3656
+ color: <optional agent name badge color: red, blue, green, yellow, purple, orange, pink, cyan, an Agency Agents alias, or quoted "#RRGGBB">
3657
+ tools: <comma-separated built-in tools: read, bash, edit, write, grep, find, ls. Use "none" for no tools. Omit for all tools>
3658
+ model: <optional model as "provider/modelId", e.g. "anthropic/claude-haiku-4-5". Omit to inherit parent model>
3659
+ thinking: <optional thinking level: ${THINKING_LEVELS.join(", ")}. Omit to inherit>
3660
+ max_turns: <optional max agentic turns. 0 or omit for unlimited (default)>
3661
+ prompt_mode: <"replace" (body IS the full system prompt) or "append" (body is appended to default prompt). Default: replace>
3662
+ extensions: <true (inherit all MCP/extension tools), false (none), or comma-separated names. Default: true>
3663
+ skills: <true (inherit all), false (none), or comma-separated skill names to preload into prompt. Default: true>
3664
+ disallowed_tools: <comma-separated tool names to block, even if otherwise available. Omit for none>
3665
+ inherit_context: <true to fork parent conversation into agent so it sees chat history. Default: false>
3666
+ run_in_background: <pin this agent to background (true) or foreground (false). Omit to follow the backgroundByDefault setting, which is background>
3667
+ output_transcript: <false to write no transcript file or path for this agent. Independent of persist_session. Default: true>
3668
+ isolated: <true for no extension/MCP tools, only built-in tools. Default: false>
3669
+ memory: <"user" (global), "project" (per-project), or "local" (gitignored per-project) for persistent memory. Omit for none>${
3670
+ // Offering the field on a project that turned worktrees off would bake a
3671
+ // request that is refused at spawn time into a file that outlives the
3672
+ // session — the #231 pathology (models fill the fields they are shown)
3673
+ // one layer up. Built per invocation, so this read is live.
3674
+ isWorktreeIsolationEnabled()
3675
+ ? `\nisolation: <"worktree" to run in isolated git worktree; "off" to refuse one even when the caller asks. Omit for normal>`
3676
+ : ""
3677
+ }
3678
+ ---
3679
+
3680
+ <system prompt body — instructions for the agent>
3681
+ \`\`\`
3682
+
3683
+ Guidelines for choosing settings:
3684
+ - For read-only tasks (review, analysis): tools: read, bash, grep, find, ls
3685
+ - For code modification tasks: include edit, write
3686
+ - Use prompt_mode: append if the agent should keep the default system prompt and add specialization on top
3687
+ - Use prompt_mode: replace for fully custom agents with their own personality/instructions
3688
+ - Set inherit_context: true if the agent needs to know what was discussed in the parent conversation
3689
+ - Set isolated: true if the agent should NOT have access to MCP servers or other extensions
3690
+ - Set output_transcript: false to skip writing this agent's transcript; this alone doesn't keep the run off disk (persist_session, isolation: worktree commits, and memory still write) — set those too if that's the goal
3691
+ - Only include frontmatter fields that differ from defaults — omit fields where the default is fine
3692
+
3693
+ Write the file using the write tool. Only write the file, nothing else.`;
3694
+
3695
+ const { record } = await manager.spawnAndWait(pi, ctx, "general-purpose", generatePrompt, {
3696
+ description: `Generate ${name} agent`,
3697
+ maxTurns: 5,
3698
+ // Exempt from maxConcurrentForeground. This runs from a modal wizard, not
3699
+ // a tool call: it passes no signal, and Esc in `ctx.ui` never reaches the
3700
+ // manager — so a user waiting behind a full pool would have no way to
3701
+ // cancel at all. It is also one human action that cannot fan out, which
3702
+ // is what the limit exists to bound. It still counts once started.
3703
+ bypassQueue: true,
3704
+ });
3705
+
3706
+ if (record.status === "error") {
3707
+ ctx.ui.notify(`Generation failed: ${record.error}`, "warning");
3708
+ return;
3709
+ }
3710
+
3711
+ reloadCustomAgents();
3712
+
3713
+ if (existsSync(targetPath)) {
3714
+ ctx.ui.notify(`Created ${targetPath}`, "info");
3715
+ } else {
3716
+ ctx.ui.notify("Agent generation completed but file was not created. Check the agent output.", "warning");
3717
+ }
3718
+ }
3719
+
3720
+ async function showManualWizard(ctx: ExtensionCommandContext, targetDir: string) {
3721
+ // 1. Name
3722
+ const name = await ctx.ui.input("Agent name (filename, no spaces)");
3723
+ if (!name) return;
3724
+
3725
+ // 2. Description
3726
+ const description = await ctx.ui.input("Description (one line)");
3727
+ if (!description) return;
3728
+
3729
+ // 3. Tools
3730
+ const toolChoice = await ctx.ui.select("Tools", ["all", "none", "read-only (read, bash, grep, find, ls)", "custom..."]);
3731
+ if (!toolChoice) return;
3732
+
3733
+ let tools: string;
3734
+ if (toolChoice === "all") {
3735
+ tools = BUILTIN_TOOL_NAMES.join(", ");
3736
+ } else if (toolChoice === "none") {
3737
+ tools = "none";
3738
+ } else if (toolChoice.startsWith("read-only")) {
3739
+ tools = "read, bash, grep, find, ls";
3740
+ } else {
3741
+ const customTools = await ctx.ui.input("Tools (comma-separated)", BUILTIN_TOOL_NAMES.join(", "));
3742
+ if (!customTools) return;
3743
+ tools = customTools;
3744
+ }
3745
+
3746
+ // 4. Model
3747
+ const modelChoice = await ctx.ui.select("Model", [
3748
+ "inherit (parent model)",
3749
+ "haiku",
3750
+ "sonnet",
3751
+ "opus",
3752
+ "custom...",
3753
+ ]);
3754
+ if (!modelChoice) return;
3755
+
3756
+ let model: string | undefined;
3757
+ if (modelChoice === "haiku") model = "anthropic/claude-haiku-4-5";
3758
+ else if (modelChoice === "sonnet") model = "anthropic/claude-sonnet-4-6";
3759
+ else if (modelChoice === "opus") model = "anthropic/claude-opus-4-6";
3760
+ else if (modelChoice === "custom...") {
3761
+ model = (await ctx.ui.input("Model (provider/modelId)")) || undefined;
3762
+ }
3763
+
3764
+ // 5. Thinking
3765
+ // "inherit" is a UI-only pseudo-choice (omit the field); the rest mirror pi.
3766
+ const thinkingChoice = await ctx.ui.select("Thinking level", ["inherit", ...THINKING_LEVELS]);
3767
+ if (!thinkingChoice) return;
3768
+
3769
+ // 6. System prompt
3770
+ const systemPrompt = await ctx.ui.editor("System prompt", "");
3771
+ if (systemPrompt === undefined) return;
3772
+
3773
+ const content = buildNewAgentFile({
3774
+ description,
3775
+ tools,
3776
+ model,
3777
+ thinking: thinkingChoice === "inherit" ? undefined : thinkingChoice,
3778
+ systemPrompt,
3779
+ });
3780
+
3781
+ mkdirSync(targetDir, { recursive: true });
3782
+ const targetPath = join(targetDir, `${name}.md`);
3783
+
3784
+ if (existsSync(targetPath)) {
3785
+ const overwrite = await ctx.ui.confirm("Overwrite", `${targetPath} already exists. Overwrite?`);
3786
+ if (!overwrite) return;
3787
+ }
3788
+
3789
+ const { writeFileSync } = await import("node:fs");
3790
+ writeFileSync(targetPath, content, "utf-8");
3791
+ reloadCustomAgents();
3792
+ ctx.ui.notify(`Created ${targetPath}`, "info");
3793
+ }
3794
+
3795
+ /**
3796
+ * Every settings mutation writes this WHOLE object back to disk, so a field
3797
+ * missing here is erased from the user's subagents.json the next time they
3798
+ * toggle something unrelated. `SubagentsSettings` has every field optional,
3799
+ * so a `: SubagentsSettings` return annotation would let a newly-added setting
3800
+ * be forgotten here and still type-check. `satisfies` instead: it still checks
3801
+ * each value's type and rejects a mistyped key, but leaves the return type
3802
+ * inferred so `_NoMissingSettingsKeys` below can check completeness.
3803
+ */
3804
+ function snapshotSettings() {
3805
+ return {
3806
+ maxConcurrent: manager.getMaxConcurrent(),
3807
+ // 0 = unlimited, and the default — see SubagentsSettings.
3808
+ maxConcurrentForeground: manager.getMaxConcurrentForeground(),
3809
+ // 0 = unlimited — per SubagentsSettings.defaultMaxTurns docstring and
3810
+ // normalizeMaxTurns() in agent-runner.ts (which maps 0 → undefined).
3811
+ defaultMaxTurns: getDefaultMaxTurns() ?? 0,
3812
+ graceTurns: getGraceTurns(),
3813
+ defaultJoinMode: getDefaultJoinMode(),
3814
+ backgroundByDefault: getBackgroundByDefault(),
3815
+ schedulingEnabled: isSchedulingEnabled(),
3816
+ scopeModels: isScopeModelsEnabled(),
3817
+ strictAgentFiles,
3818
+ disableDefaultAgents: isDefaultsDisabled(),
3819
+ toolDescriptionMode: getToolDescriptionMode(),
3820
+ fleetView: isFleetViewEnabled(),
3821
+ agentMentions: getAgentMentionMode(),
3822
+ rememberAgents: getRememberAgents(),
3823
+ widgetMode: getWidgetMode(),
3824
+ outputTranscript: getOutputTranscriptDefault(),
3825
+ worktreeIsolation: isWorktreeIsolationEnabled(),
3826
+ // The user's answer, not the effective one. A stand-down for another
3827
+ // extension's workflow tool is scoped to the session it was detected in;
3828
+ // writing it here would let an unrelated settings change three menus away
3829
+ // freeze it into the file as an explicit `false`, which then survives
3830
+ // uninstalling the extension it was deferring to. undefined is dropped by
3831
+ // JSON.stringify, so unset stays unset — same reasoning as
3832
+ // `fallbackSubagent` below.
3833
+ workflowsEnabled: isWorkflowsPinned() ? isWorkflowsEnabled() : undefined,
3834
+ maxSubagentDepth: getMaxSubagentDepth(),
3835
+ // Deliberately NOT `?? "general-purpose"`: every settings change writes the
3836
+ // whole snapshot, and materializing the implicit default would turn it into
3837
+ // explicit configuration — which then fails loudly if general-purpose later
3838
+ // goes away. undefined is dropped by JSON.stringify.
3839
+ fallbackSubagent: getFallbackSubagent(),
3840
+ // Global-only (see loadSettings): never written into the project file.
3841
+ modelAliases: undefined,
3842
+ agentModels: undefined,
3843
+ reportUsage: isReportUsageEnabled(),
3844
+ showCost: isShowCostEnabled(),
3845
+ showModel: isShowModelEnabled(),
3846
+ viewerMarkdown: getViewerMarkdown(),
3847
+ packageAgents: getPackageAgentsGate(),
3848
+ packageWorkflows: getPackageWorkflowsGate(),
3849
+ } satisfies SubagentsSettings;
3850
+ }
3851
+
3852
+ // Compile-time completeness guard for snapshotSettings(). If a field is added
3853
+ // to SubagentsSettings and not mirrored above, this Exclude is non-empty and
3854
+ // fails to satisfy `never` — turning a silent settings-erasure bug into a
3855
+ // typecheck error. `npm run typecheck` runs in CI.
3856
+ type _NoMissingSettingsKeys =
3857
+ Exclude<keyof SubagentsSettings, keyof ReturnType<typeof snapshotSettings>> extends never
3858
+ ? true
3859
+ : ["snapshotSettings() is missing a SubagentsSettings key"];
3860
+ const _settingsSnapshotIsComplete: _NoMissingSettingsKeys = true;
3861
+ void _settingsSnapshotIsComplete;
3862
+
3863
+ /**
3864
+ * Whether a package gate lets anything through. `undefined` is the unset
3865
+ * default and reads as `true`; an empty allowlist matches nothing, like
3866
+ * `false`.
3867
+ */
3868
+ function gateAdmitsAnything(gate: ReturnType<typeof getPackageAgentsGate>): boolean {
3869
+ if (gate === undefined || gate === true) return true;
3870
+ if (gate === false) return false;
3871
+ return gate.length > 0;
3872
+ }
3873
+
3874
+ /**
3875
+ * Settings-row description for a package gate, naming the configured allowlist
3876
+ * when there is one. The row itself is on/off — an allowlist is hand-written in
3877
+ * `subagents.json` — so the description is the only place it is visible, and a
3878
+ * user about to toggle the row can see what they are about to replace.
3879
+ */
3880
+ function describeGateSetting(gate: ReturnType<typeof getPackageAgentsGate>, base: string): string {
3881
+ if (!Array.isArray(gate)) return base;
3882
+ return gate.length > 0
3883
+ ? `${base} Currently limited to: ${gate.join(", ")}.`
3884
+ : `${base} Currently limited to an empty list, so nothing loads.`;
3885
+ }
3886
+
3887
+ const NUMERIC_IDS = new Set([
3888
+ "maxConcurrent", "maxConcurrentForeground", "defaultMaxTurns", "graceTurns", "maxSubagentDepth",
3889
+ ]);
3890
+
3891
+ async function showSettings(ctx: ExtensionCommandContext) {
3892
+ function buildItems(): SettingItem[] {
3893
+ const mc = manager.getMaxConcurrent();
3894
+ const mcf = manager.getMaxConcurrentForeground();
3895
+ const dmt = getDefaultMaxTurns() ?? 0;
3896
+ const gt = getGraceTurns();
3897
+ const msd = getMaxSubagentDepth();
3898
+ // Label what unset actually does — it targets general-purpose even when
3899
+ // that is unregistered (the permissive hardcoded tier), so showing "none"
3900
+ // there would advertise strict dispatch for the most permissive state.
3901
+ // `values` still offers only resolvable targets, so the user cannot
3902
+ // persist a fallback that would hard-error on every dispatch.
3903
+ const fallbackValue = getFallbackSubagent() ?? "general-purpose";
3904
+ const fallbackValues = [...new Set([...getAvailableTypes(), NO_FALLBACK])];
3905
+
3906
+ return [
3907
+ {
3908
+ id: "maxConcurrent",
3909
+ label: "Max concurrency",
3910
+ description: "Max concurrent background agents (Enter to type)",
3911
+ currentValue: String(mc),
3912
+ values: [String(mc)],
3913
+ },
3914
+ {
3915
+ id: "maxConcurrentForeground",
3916
+ label: "Max foreground concurrency",
3917
+ description: "Max concurrent foreground (blocking) agents (0 = unlimited, Enter to type)",
3918
+ currentValue: String(mcf),
3919
+ values: [String(mcf)],
3920
+ },
3921
+ {
3922
+ id: "defaultMaxTurns",
3923
+ label: "Default max turns",
3924
+ description: "Default max turns before wrap-up (0 = unlimited, Enter to type)",
3925
+ currentValue: String(dmt),
3926
+ values: [String(dmt)],
3927
+ },
3928
+ {
3929
+ id: "graceTurns",
3930
+ label: "Grace turns",
3931
+ description: "Grace turns after wrap-up steer (Enter to type)",
3932
+ currentValue: String(gt),
3933
+ values: [String(gt)],
3934
+ },
3935
+ {
3936
+ id: "maxSubagentDepth",
3937
+ label: "Nested depth",
3938
+ description: "Hard cap on nested delegation — main is 0, its subagents 1 (0/1 = nesting off, Enter to type)",
3939
+ currentValue: String(msd),
3940
+ values: [String(msd)],
3941
+ },
3942
+ {
3943
+ id: "joinMode",
3944
+ label: "Join mode",
3945
+ description: "smart = each completion at the parent's next boundary, as in Claude Code; group = one notification per turn's batch",
3946
+ // `async` is an older name for `smart`; settings files may still hold it.
3947
+ currentValue: getDefaultJoinMode() === "group" ? "group" : "smart",
3948
+ values: ["smart", "group"],
3949
+ },
3950
+ {
3951
+ id: "backgroundByDefault",
3952
+ label: "Background by default",
3953
+ description: "An Agent call that doesn't say runs detached (off = blocks the turn and returns inline)",
3954
+ currentValue: getBackgroundByDefault() ? "on" : "off",
3955
+ values: ["on", "off"],
3956
+ },
3957
+ {
3958
+ id: "schedulingEnabled",
3959
+ label: "Scheduling",
3960
+ description: "Schedule subagent feature (off removes `schedule` param from Agent tool spec on next pi session)",
3961
+ currentValue: isSchedulingEnabled() ? "on" : "off",
3962
+ values: ["on", "off"],
3963
+ },
3964
+ {
3965
+ id: "workflowsEnabled",
3966
+ label: "Workflows",
3967
+ description:
3968
+ "Scripted workflows, on unless another extension provides a workflow tool "
3969
+ + "(off keeps the SubagentWorkflow tool out of the tool spec; applies on next pi session)",
3970
+ currentValue: isWorkflowsEnabled() ? "on" : "off",
3971
+ values: ["on", "off"],
3972
+ },
3973
+ {
3974
+ id: "packageAgents",
3975
+ label: "Package agents",
3976
+ description: describeGateSetting(
3977
+ getPackageAgentsGate(),
3978
+ "Load agents declared by installed pi packages (`pi.subagents.agents` in their package.json). "
3979
+ + "They rank below your global and project agents, so a same-named local file always wins.",
3980
+ ),
3981
+ currentValue: gateAdmitsAnything(getPackageAgentsGate()) ? "on" : "off",
3982
+ values: ["on", "off"],
3983
+ },
3984
+ {
3985
+ id: "packageWorkflows",
3986
+ label: "Package workflows",
3987
+ description: describeGateSetting(
3988
+ getPackageWorkflowsGate(),
3989
+ "Resolve saved workflow names against scripts declared by installed pi packages "
3990
+ + "(`pi.subagents.workflows`). Searched last, after your project, workspace and personal directories.",
3991
+ ),
3992
+ currentValue: gateAdmitsAnything(getPackageWorkflowsGate()) ? "on" : "off",
3993
+ values: ["on", "off"],
3994
+ },
3995
+ {
3996
+ id: "scopeModels",
3997
+ label: "Scope models",
3998
+ description: "Validate subagent models against scoped models (/scoped-models)",
3999
+ currentValue: isScopeModelsEnabled() ? "on" : "off",
4000
+ values: ["on", "off"],
4001
+ },
4002
+ {
4003
+ id: "strictAgentFiles",
4004
+ label: "Strict agent files",
4005
+ description: "Fail startup on an unreadable/unparseable agent .md instead of skipping it with a warning",
4006
+ currentValue: strictAgentFiles ? "on" : "off",
4007
+ values: ["on", "off"],
4008
+ },
4009
+ {
4010
+ id: "disableDefaultAgents",
4011
+ label: "Disable defaults",
4012
+ description: "Hide built-in agents (general-purpose, Explore, Plan) — custom agents are unaffected",
4013
+ currentValue: isDefaultsDisabled() ? "on" : "off",
4014
+ values: ["on", "off"],
4015
+ },
4016
+ {
4017
+ id: "fallbackSubagent",
4018
+ label: "Fallback agent",
4019
+ description: `Agent used when subagent_type is unknown, disabled, or ambiguous; "${NO_FALLBACK}" rejects the call instead (strict dispatch)`,
4020
+ currentValue: fallbackValue,
4021
+ values: fallbackValues,
4022
+ },
4023
+ {
4024
+ id: "outputTranscript",
4025
+ label: "Output transcript",
4026
+ description: "Write each subagent's .output transcript by default. A custom agent's output_transcript frontmatter overrides this.",
4027
+ currentValue: getOutputTranscriptDefault() ? "on" : "off",
4028
+ values: ["on", "off"],
4029
+ },
4030
+ {
4031
+ id: "worktreeIsolation",
4032
+ label: "Worktree isolation",
4033
+ description:
4034
+ "Allow isolation: worktree to copy the repo. Off refuses worktrees on every path immediately — for repos where a copy costs too much time or disk — and drops the `isolation` param from the Agent tool spec on next pi session.",
4035
+ currentValue: isWorktreeIsolationEnabled() ? "on" : "off",
4036
+ values: ["on", "off"],
4037
+ },
4038
+ {
4039
+ id: "reportUsage",
4040
+ label: "Report usage to session",
4041
+ description:
4042
+ "Add subagent tokens and cost to this session's own totals, so pi's footer and /cost stop reading a delegating session as nearly free. Reported on the next tool result (agents that finish in the background are counted on the one after). Context-window % is unaffected.",
4043
+ currentValue: isReportUsageEnabled() ? "on" : "off",
4044
+ values: ["on", "off"],
4045
+ },
4046
+ {
4047
+ id: "showCost",
4048
+ label: "Show cost",
4049
+ description:
4050
+ "Show an estimated `~$0.0042` beside subagent token counts in the widget, fleet view, results and notifications. Priced by pi from the model's rates — omitted entirely for a model it has no rates for.",
4051
+ currentValue: isShowCostEnabled() ? "on" : "off",
4052
+ values: ["on", "off"],
4053
+ },
4054
+ {
4055
+ id: "showModel",
4056
+ label: "Show model",
4057
+ description:
4058
+ "Name the model driving each agent, and the thinking level it is running at, on the widget's running rows. The Agent tool result and the conversation viewer show the pair either way — this adds it to the widget, where the row is already dense.",
4059
+ currentValue: isShowModelEnabled() ? "on" : "off",
4060
+ values: ["on", "off"],
4061
+ },
4062
+ {
4063
+ id: "viewerMarkdown",
4064
+ label: "Viewer markdown",
4065
+ description:
4066
+ "How much of the conversation viewer renders as Markdown. assistant = assistant text only (default); all = tool results too, for tools that emit Markdown — accepting that a Markdown pass over a diff or a log eats `#` comments, swallows a `---` line and re-fences indented output; off = everything verbatim. `m` in the viewer cycles the same setting (footer: raw / md / md+).",
4067
+ currentValue: getViewerMarkdown(),
4068
+ values: ["off", "assistant", "all"],
4069
+ },
4070
+ {
4071
+ id: "fleetView",
4072
+ label: "Fleet view",
4073
+ description: "Claude Code-style main+subagents list below the editor (↓/← to navigate, Enter to view)",
4074
+ currentValue: isFleetViewEnabled() ? "on" : "off",
4075
+ values: ["on", "off"],
4076
+ },
4077
+ {
4078
+ id: "agentMentions",
4079
+ label: "Agent mentions",
4080
+ description: "Route `@handle message` at the prompt to that agent. model = an off-screen clone of this conversation calls the Agent tool, so the agent gets a context-written prompt, a transcript and per-tool detail, and the chat stays clean; direct = started here from your text, no model call. Messaging and resuming are direct either way.",
4081
+ currentValue: getAgentMentionMode(),
4082
+ values: ["model", "direct", "off"],
4083
+ },
4084
+ {
4085
+ id: "rememberAgents",
4086
+ label: "Remember agents",
4087
+ description: "Persist subagent sessions so `@handle` can resume one long after it finished (they also appear in /resume)",
4088
+ currentValue: getRememberAgents() ? "on" : "off",
4089
+ values: ["on", "off"],
4090
+ },
4091
+ {
4092
+ id: "widgetMode",
4093
+ label: "Widget",
4094
+ description: "Above-editor agent widget: all = every agent; background = hide foreground (they already render inline); off = hide the widget.",
4095
+ currentValue: getWidgetMode(),
4096
+ values: ["all", "background", "off"],
4097
+ },
4098
+ {
4099
+ id: "toolDescriptionMode",
4100
+ label: "Tool description",
4101
+ description: "Agent tool description sent to the LLM: full (rich, default), compact (~75% fewer tokens, for small/local models), or custom (.pi/agent-tool-description.md with {{placeholders}})",
4102
+ currentValue: getToolDescriptionMode(),
4103
+ values: ["full", "compact", "custom"],
4104
+ },
4105
+ ];
4106
+ }
4107
+
4108
+ function applyValue(id: string, value: string) {
4109
+ if (id === "maxConcurrent") {
4110
+ const n = parseInt(value, 10);
4111
+ if (n >= 1) {
4112
+ manager.setMaxConcurrent(n);
4113
+ notifyApplied(ctx, `Max concurrency set to ${n}`);
4114
+ }
4115
+ } else if (id === "maxConcurrentForeground") {
4116
+ // 0 is meaningful here, unlike maxConcurrent above: it means unlimited.
4117
+ const n = parseInt(value, 10);
4118
+ if (n >= 0) {
4119
+ manager.setMaxConcurrentForeground(n);
4120
+ notifyApplied(ctx, n === 0
4121
+ ? "Max foreground concurrency set to unlimited"
4122
+ : `Max foreground concurrency set to ${n}`);
4123
+ }
4124
+ } else if (id === "defaultMaxTurns") {
4125
+ const n = parseInt(value, 10);
4126
+ if (n === 0) {
4127
+ setDefaultMaxTurns(undefined);
4128
+ notifyApplied(ctx, "Default max turns set to unlimited");
4129
+ } else if (n >= 1) {
4130
+ setDefaultMaxTurns(n);
4131
+ notifyApplied(ctx, `Default max turns set to ${n}`);
4132
+ }
4133
+ } else if (id === "graceTurns") {
4134
+ const n = parseInt(value, 10);
4135
+ if (n >= 1) {
4136
+ setGraceTurns(n);
4137
+ notifyApplied(ctx, `Grace turns set to ${n}`);
4138
+ }
4139
+ } else if (id === "maxSubagentDepth") {
4140
+ const n = parseInt(value, 10);
4141
+ if (n >= 0) {
4142
+ setMaxSubagentDepth(n);
4143
+ notifyApplied(
4144
+ ctx,
4145
+ n <= 1
4146
+ ? "Nested delegation disabled"
4147
+ : `Nested depth set to ${n}. Applies to agents started from now on.`,
4148
+ );
4149
+ }
4150
+ } else if (id === "joinMode") {
4151
+ setDefaultJoinMode(value as JoinMode);
4152
+ notifyApplied(ctx, `Default join mode set to ${value}`);
4153
+ } else if (id === "backgroundByDefault") {
4154
+ const enabled = value === "on";
4155
+ setBackgroundByDefault(enabled);
4156
+ notifyApplied(
4157
+ ctx,
4158
+ enabled
4159
+ ? "Agent calls run in the background unless they pass run_in_background: false"
4160
+ : "Agent calls block and return inline unless they pass run_in_background: true",
4161
+ );
4162
+ } else if (id === "schedulingEnabled") {
4163
+ const enabled = value === "on";
4164
+ if (enabled === isSchedulingEnabled()) {
4165
+ ctx.ui.notify(`Scheduling already ${enabled ? "enabled" : "disabled"}.`, "info");
4166
+ } else {
4167
+ setSchedulingEnabled(enabled);
4168
+ if (!enabled) scheduler.stop(); // immediate kill — outstanding fires stop ticking
4169
+ notifyApplied(
4170
+ ctx,
4171
+ `Scheduling ${enabled ? "enabled" : "disabled"}. Tool spec change takes effect on next pi session.`,
4172
+ );
4173
+ }
4174
+ } else if (id === "workflowsEnabled") {
4175
+ const enabled = value === "on";
4176
+ if (enabled === isWorkflowsEnabled()) {
4177
+ ctx.ui.notify(`Workflows already ${enabled ? "enabled" : "disabled"}.`, "info");
4178
+ } else {
4179
+ setWorkflowsEnabled(enabled);
4180
+ // Runs already in flight keep going: the switch governs whether the
4181
+ // tool is offered, and killing live agents on a settings toggle would
4182
+ // lose work the user never asked to discard.
4183
+ notifyApplied(
4184
+ ctx,
4185
+ `Workflows ${enabled ? "enabled" : "disabled"}. Tool spec change takes effect on next pi session.`,
4186
+ );
4187
+ }
4188
+ } else if (id === "scopeModels") {
4189
+ const enabled = value === "on";
4190
+ setScopeModelsEnabled(enabled);
4191
+ notifyApplied(ctx, `Scope models ${enabled ? "enabled" : "disabled"}`);
4192
+ } else if (id === "strictAgentFiles") {
4193
+ const enabled = value === "on";
4194
+ strictAgentFiles = enabled;
4195
+ notifyApplied(ctx, `Strict agent files ${enabled ? "enabled" : "disabled"}. Takes effect on next pi session.`);
4196
+ } else if (id === "disableDefaultAgents") {
4197
+ const enabled = value === "on";
4198
+ setDisableDefaultAgents(enabled);
4199
+ notifyApplied(ctx, `Default agents ${enabled ? "disabled" : "enabled"}. Tool spec change takes effect on next pi session.`);
4200
+ } else if (id === "fallbackSubagent") {
4201
+ setFallbackSubagent(value);
4202
+ notifyApplied(
4203
+ ctx,
4204
+ value === NO_FALLBACK
4205
+ ? "Unknown or disabled agent types will now be rejected"
4206
+ : `Unknown agent types will fall back to ${value}`,
4207
+ );
4208
+ } else if (id === "outputTranscript") {
4209
+ const enabled = value === "on";
4210
+ setOutputTranscriptDefault(enabled);
4211
+ notifyApplied(ctx, `Output transcript ${enabled ? "enabled" : "disabled"} by default`);
4212
+ } else if (id === "worktreeIsolation") {
4213
+ const enabled = value === "on";
4214
+ setWorktreeIsolationEnabled(enabled);
4215
+ // The refusal is live, but the tool schema is built at registration, so
4216
+ // the isolation parameter only appears/disappears next session.
4217
+ notifyApplied(
4218
+ ctx,
4219
+ `Worktree isolation ${enabled ? "enabled" : "disabled"}. Tool parameter updates on next pi session.`,
4220
+ );
4221
+ } else if (id === "packageAgents" || id === "packageWorkflows") {
4222
+ const enabled = value === "on";
4223
+ const isAgents = id === "packageAgents";
4224
+ const previous = isAgents ? getPackageAgentsGate() : getPackageWorkflowsGate();
4225
+ (isAgents ? setPackageAgentsGate : setPackageWorkflowsGate)(enabled);
4226
+ reloadCustomAgents();
4227
+ const label = isAgents ? "Package agents" : "Package workflows";
4228
+ // An allowlist cannot be expressed by an on/off row, so say plainly that
4229
+ // it is gone rather than letting a toggle quietly widen the gate.
4230
+ const replaced = Array.isArray(previous) && previous.length > 0
4231
+ ? ` Replaced the allowlist (${previous.join(", ")}) — re-add it in .pi/subagents.json to narrow it again.`
4232
+ : "";
4233
+ // Same caveat every row that changes the advertised agent set carries:
4234
+ // the `Agent` tool's description and schema are built at registration,
4235
+ // so until the next session the model is reading a stale type list.
4236
+ const specNote = isAgents ? " Tool spec change takes effect on next pi session." : "";
4237
+ notifyApplied(ctx, `${label} ${enabled ? "enabled" : "disabled"}.${replaced}${specNote}`);
4238
+ } else if (id === "toolDescriptionMode") {
4239
+ setToolDescriptionMode(value as ToolDescriptionMode);
4240
+ notifyApplied(ctx, `Tool description set to ${value}. Takes effect on next pi session.`);
4241
+ } else if (id === "reportUsage") {
4242
+ const enabled = value === "on";
4243
+ setReportUsage(enabled);
4244
+ notifyApplied(
4245
+ ctx,
4246
+ enabled
4247
+ ? "Subagent usage now counted in this session's totals"
4248
+ : "Subagent usage no longer counted in this session's totals",
4249
+ );
4250
+ } else if (id === "showCost") {
4251
+ const enabled = value === "on";
4252
+ setShowCost(enabled);
4253
+ notifyApplied(ctx, `Cost display ${enabled ? "enabled" : "disabled"}`);
4254
+ } else if (id === "showModel") {
4255
+ const enabled = value === "on";
4256
+ setShowModel(enabled);
4257
+ notifyApplied(ctx, `Model display ${enabled ? "enabled" : "disabled"}`);
4258
+ } else if (id === "viewerMarkdown") {
4259
+ setViewerMarkdown(value as ViewerMarkdownMode);
4260
+ notifyApplied(ctx, `Viewer markdown set to ${value}`);
4261
+ } else if (id === "fleetView") {
4262
+ const enabled = value === "on";
4263
+ setFleetViewEnabled(enabled);
4264
+ notifyApplied(ctx, `Fleet view ${enabled ? "enabled" : "disabled"}`);
4265
+ } else if (id === "agentMentions") {
4266
+ const mode = value as AgentMentionMode;
4267
+ setAgentMentionMode(mode);
4268
+ notifyApplied(
4269
+ ctx,
4270
+ mode === "off"
4271
+ ? "Agent mentions disabled"
4272
+ : mode === "model"
4273
+ ? "Agent mentions on — a conversation clone starts a mentioned agent off-screen"
4274
+ : "Agent mentions on — a mentioned agent starts here, with no model call",
4275
+ );
4276
+ } else if (id === "rememberAgents") {
4277
+ const enabled = value === "on";
4278
+ setRememberAgents(enabled);
4279
+ notifyApplied(ctx, `Remember agents ${enabled ? "enabled" : "disabled"}`);
4280
+ } else if (id === "widgetMode") {
4281
+ setWidgetMode(value as WidgetMode);
4282
+ notifyApplied(ctx, `Widget set to ${value}`);
4283
+ }
4284
+ }
4285
+
4286
+ let list: SettingsList;
4287
+ // Track current selection index directly (SettingsList doesn't expose it).
4288
+ // Updated on arrow keys so Enter knows which field is selected immediately.
4289
+ let currentIndex = 0;
4290
+
4291
+ const result = await ctx.ui.custom<string | undefined>((_tui, _theme, _kb, done) => {
4292
+ const items = buildItems();
4293
+
4294
+ list = new SettingsList(
4295
+ items,
4296
+ items.length + 2,
4297
+ getSettingsListTheme(),
4298
+ (id, newValue) => {
4299
+ applyValue(id, newValue);
4300
+ },
4301
+ () => done(undefined as undefined),
4302
+ );
4303
+
4304
+ const container = new Container();
4305
+ container.addChild(new Text("⚙ Subagent Settings", 0, 0));
4306
+ container.addChild(new Spacer(1));
4307
+ container.addChild(list);
4308
+
4309
+ return {
4310
+ render: (w: number) => container.render(w),
4311
+ invalidate: () => container.invalidate(),
4312
+ handleInput: (data: string) => {
4313
+ // Track navigation so Enter knows the current field
4314
+ if (matchesKey(data, "up")) {
4315
+ currentIndex = Math.max(0, currentIndex - 1);
4316
+ } else if (matchesKey(data, "down")) {
4317
+ currentIndex = Math.min(items.length - 1, currentIndex + 1);
4318
+ }
4319
+
4320
+ // Enter on numeric field → close and prompt for typed input
4321
+ if (matchesKey(data, Key.enter) && NUMERIC_IDS.has(items[currentIndex].id)) {
4322
+ done(items[currentIndex].id);
4323
+ return;
4324
+ }
4325
+ list.handleInput?.(data);
4326
+ },
4327
+ };
4328
+ });
4329
+
4330
+ // If a numeric field ID was returned, prompt for typed input
4331
+ if (result && NUMERIC_IDS.has(result)) {
4332
+ const current = result === "maxConcurrent"
4333
+ ? String(manager.getMaxConcurrent())
4334
+ : result === "maxConcurrentForeground"
4335
+ ? String(manager.getMaxConcurrentForeground())
4336
+ : result === "defaultMaxTurns"
4337
+ ? String(getDefaultMaxTurns() ?? 0)
4338
+ : result === "maxSubagentDepth"
4339
+ ? String(getMaxSubagentDepth())
4340
+ : String(getGraceTurns());
4341
+
4342
+ const label = result === "maxConcurrent"
4343
+ ? "Max concurrency (1+)"
4344
+ : result === "maxConcurrentForeground"
4345
+ ? "Max foreground concurrency (0 = unlimited)"
4346
+ : result === "defaultMaxTurns"
4347
+ ? "Default max turns (0 = unlimited)"
4348
+ : result === "maxSubagentDepth"
4349
+ ? "Nested depth (0/1 = nesting off)"
4350
+ : "Grace turns (1+)";
4351
+
4352
+ // Loop until user enters a valid integer or cancels (Esc / null).
4353
+ // Silently trims whitespace; rejects non-numeric input by re-prompting.
4354
+ let input: string | undefined = await ctx.ui.input(label, current);
4355
+ while (input != null) {
4356
+ const trimmed = input.trim();
4357
+ const n = Number(trimmed);
4358
+ if (trimmed !== "" && Number.isInteger(n)) {
4359
+ applyValue(result, String(n));
4360
+ await showSettings(ctx);
4361
+ return;
4362
+ }
4363
+ // Invalid — re-prompt with the user's last entry so they can edit it
4364
+ input = await ctx.ui.input(label, trimmed);
4365
+ }
4366
+ }
4367
+ }
4368
+
4369
+ // Persist the current snapshot, emit `subagents:settings_changed`, and surface
4370
+ // the right toast. Successful saves show info; persistence failures downgrade
4371
+ // to warning so users aren't silently reverted on restart. Event fires regardless
4372
+ // of outcome so listeners see the in-memory change.
4373
+ /**
4374
+ * Persist + broadcast the settings, silent on success — for a change whose
4375
+ * feedback is the UI it just changed: the viewer's `m` key, where a
4376
+ * notification per press would talk over the overlay it is describing.
4377
+ *
4378
+ * A *failed* write still speaks. Every other settings path warns when the
4379
+ * value is session-only, and swallowing it here would leave a preference
4380
+ * looking persisted when the next session will not have it.
4381
+ */
4382
+ function persistSettings(ctx: ExtensionCommandContext | undefined, changeMsg: string): void {
4383
+ const { message, level } = saveAndEmitChanged(
4384
+ snapshotSettings(),
4385
+ changeMsg,
4386
+ (event, payload) => pi.events.emit(event, payload),
4387
+ );
4388
+ // `ctx` is absent only on the fleet path between sessions, where
4389
+ // `currentCtx` has been cleared and there is no UI to carry the warning to.
4390
+ // The write still happens.
4391
+ if (level === "warning") ctx?.ui.notify(message, level);
4392
+ }
4393
+
4394
+ function notifyApplied(ctx: ExtensionCommandContext, successMsg: string) {
4395
+ const { message, level } = saveAndEmitChanged(
4396
+ snapshotSettings(),
4397
+ successMsg,
4398
+ (event, payload) => pi.events.emit(event, payload),
4399
+ );
4400
+ ctx.ui.notify(message, level);
4401
+ }
4402
+
4403
+ pi.registerCommand("agents", {
4404
+ description: "Manage agents",
4405
+ handler: async (_args, ctx) => { await showAgentsMenu(ctx); },
4406
+ });
4407
+
4408
+ /**
4409
+ * What `/agents → Workflows` and the fleet list's `workflow` rows need from
4410
+ * here. One object, built once: both entry points open the same inspector,
4411
+ * and handing them different views of the session would let the two drift.
4412
+ */
4413
+ const workflowMenuDeps: WorkflowMenuDeps = {
4414
+ tasks: workflowTasks,
4415
+ getRecord: id => manager.getRecord(id),
4416
+ viewAgentConversation,
4417
+ // Read lazily: `currentCtx` is rebound on every session_start, and the
4418
+ // fleet list may act between sessions, when there is none.
4419
+ getCtx: () => currentCtx as unknown as ExtensionCommandContext | undefined,
4420
+ };
4421
+
4422
+ fleet.setWorkflowSource(fleetWorkflows, id => openWorkflowFromFleet(id, workflowMenuDeps));
4423
+ }