@esso0428/pi-subagents 0.17.6 → 0.17.8

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 (260) hide show
  1. package/CHANGELOG.md +14 -0
  2. package/CONTRIBUTING.md +4 -0
  3. package/dist/abortable.d.ts +13 -0
  4. package/dist/abortable.d.ts.map +1 -0
  5. package/dist/abortable.js +43 -0
  6. package/dist/abortable.js.map +1 -0
  7. package/dist/agent-color.d.ts +36 -0
  8. package/dist/agent-color.d.ts.map +1 -0
  9. package/dist/agent-color.js +124 -0
  10. package/dist/agent-color.js.map +1 -0
  11. package/dist/agent-file-toggle.d.ts +126 -0
  12. package/dist/agent-file-toggle.d.ts.map +1 -0
  13. package/dist/agent-file-toggle.js +259 -0
  14. package/dist/agent-file-toggle.js.map +1 -0
  15. package/dist/agent-history.d.ts +4 -0
  16. package/dist/agent-history.d.ts.map +1 -1
  17. package/dist/agent-history.js +47 -1
  18. package/dist/agent-history.js.map +1 -1
  19. package/dist/agent-manager.d.ts +370 -56
  20. package/dist/agent-manager.d.ts.map +1 -1
  21. package/dist/agent-manager.js +1123 -409
  22. package/dist/agent-manager.js.map +1 -1
  23. package/dist/agent-runner.d.ts +100 -10
  24. package/dist/agent-runner.d.ts.map +1 -1
  25. package/dist/agent-runner.js +166 -21
  26. package/dist/agent-runner.js.map +1 -1
  27. package/dist/agent-types.d.ts +57 -5
  28. package/dist/agent-types.d.ts.map +1 -1
  29. package/dist/agent-types.js +164 -32
  30. package/dist/agent-types.js.map +1 -1
  31. package/dist/child-context.d.ts +3 -0
  32. package/dist/child-context.d.ts.map +1 -0
  33. package/dist/child-context.js +13 -0
  34. package/dist/child-context.js.map +1 -0
  35. package/dist/cross-extension-rpc.d.ts +23 -3
  36. package/dist/cross-extension-rpc.d.ts.map +1 -1
  37. package/dist/cross-extension-rpc.js +79 -17
  38. package/dist/cross-extension-rpc.js.map +1 -1
  39. package/dist/custom-agents.d.ts +38 -1
  40. package/dist/custom-agents.d.ts.map +1 -1
  41. package/dist/custom-agents.js +164 -12
  42. package/dist/custom-agents.js.map +1 -1
  43. package/dist/index.d.ts +34 -0
  44. package/dist/index.d.ts.map +1 -1
  45. package/dist/index.js +1912 -492
  46. package/dist/index.js.map +1 -1
  47. package/dist/invocation-config.d.ts +87 -2
  48. package/dist/invocation-config.d.ts.map +1 -1
  49. package/dist/invocation-config.js +71 -3
  50. package/dist/invocation-config.js.map +1 -1
  51. package/dist/mention-clone.d.ts +88 -0
  52. package/dist/mention-clone.d.ts.map +1 -0
  53. package/dist/mention-clone.js +154 -0
  54. package/dist/mention-clone.js.map +1 -0
  55. package/dist/mention.d.ts +82 -0
  56. package/dist/mention.d.ts.map +1 -0
  57. package/dist/mention.js +132 -0
  58. package/dist/mention.js.map +1 -0
  59. package/dist/model-resolver.d.ts +17 -0
  60. package/dist/model-resolver.d.ts.map +1 -1
  61. package/dist/model-resolver.js +15 -0
  62. package/dist/model-resolver.js.map +1 -1
  63. package/dist/model-scope.d.ts +50 -0
  64. package/dist/model-scope.d.ts.map +1 -0
  65. package/dist/model-scope.js +49 -0
  66. package/dist/model-scope.js.map +1 -0
  67. package/dist/nested-tools.d.ts +57 -0
  68. package/dist/nested-tools.d.ts.map +1 -0
  69. package/dist/nested-tools.js +301 -0
  70. package/dist/nested-tools.js.map +1 -0
  71. package/dist/output-file.d.ts +22 -3
  72. package/dist/output-file.d.ts.map +1 -1
  73. package/dist/output-file.js +58 -7
  74. package/dist/output-file.js.map +1 -1
  75. package/dist/prompts.d.ts +23 -0
  76. package/dist/prompts.d.ts.map +1 -1
  77. package/dist/prompts.js +20 -2
  78. package/dist/prompts.js.map +1 -1
  79. package/dist/schedule.d.ts.map +1 -1
  80. package/dist/schedule.js +36 -15
  81. package/dist/schedule.js.map +1 -1
  82. package/dist/settings.d.ts +228 -2
  83. package/dist/settings.d.ts.map +1 -1
  84. package/dist/settings.js +94 -0
  85. package/dist/settings.js.map +1 -1
  86. package/dist/status-note.d.ts +49 -1
  87. package/dist/status-note.d.ts.map +1 -1
  88. package/dist/status-note.js +62 -1
  89. package/dist/status-note.js.map +1 -1
  90. package/dist/structured-output.d.ts +62 -0
  91. package/dist/structured-output.d.ts.map +1 -0
  92. package/dist/structured-output.js +113 -0
  93. package/dist/structured-output.js.map +1 -0
  94. package/dist/types.d.ts +176 -10
  95. package/dist/types.d.ts.map +1 -1
  96. package/dist/ui/agent-mention.d.ts +83 -0
  97. package/dist/ui/agent-mention.d.ts.map +1 -0
  98. package/dist/ui/agent-mention.js +188 -0
  99. package/dist/ui/agent-mention.js.map +1 -0
  100. package/dist/ui/agent-widget.d.ts +97 -75
  101. package/dist/ui/agent-widget.d.ts.map +1 -1
  102. package/dist/ui/agent-widget.js +398 -420
  103. package/dist/ui/agent-widget.js.map +1 -1
  104. package/dist/ui/conversation-blocks.d.ts.map +1 -1
  105. package/dist/ui/conversation-blocks.js +6 -0
  106. package/dist/ui/conversation-blocks.js.map +1 -1
  107. package/dist/ui/conversation-timeline.d.ts +10 -2
  108. package/dist/ui/conversation-timeline.d.ts.map +1 -1
  109. package/dist/ui/conversation-timeline.js +130 -23
  110. package/dist/ui/conversation-timeline.js.map +1 -1
  111. package/dist/ui/conversation-viewer.d.ts +15 -5
  112. package/dist/ui/conversation-viewer.d.ts.map +1 -1
  113. package/dist/ui/conversation-viewer.js +202 -50
  114. package/dist/ui/conversation-viewer.js.map +1 -1
  115. package/dist/ui/fleet-list.d.ts +198 -0
  116. package/dist/ui/fleet-list.d.ts.map +1 -0
  117. package/dist/ui/fleet-list.js +487 -0
  118. package/dist/ui/fleet-list.js.map +1 -0
  119. package/dist/ui/schedule-menu.d.ts.map +1 -1
  120. package/dist/ui/schedule-menu.js +6 -7
  121. package/dist/ui/schedule-menu.js.map +1 -1
  122. package/dist/ui/select-item.d.ts +28 -0
  123. package/dist/ui/select-item.d.ts.map +1 -0
  124. package/dist/ui/select-item.js +35 -0
  125. package/dist/ui/select-item.js.map +1 -0
  126. package/dist/ui/workflow-card.d.ts +176 -0
  127. package/dist/ui/workflow-card.d.ts.map +1 -0
  128. package/dist/ui/workflow-card.js +333 -0
  129. package/dist/ui/workflow-card.js.map +1 -0
  130. package/dist/ui/workflow-dialog.d.ts +306 -0
  131. package/dist/ui/workflow-dialog.d.ts.map +1 -0
  132. package/dist/ui/workflow-dialog.js +844 -0
  133. package/dist/ui/workflow-dialog.js.map +1 -0
  134. package/dist/ui/workflow-menu.d.ts +61 -0
  135. package/dist/ui/workflow-menu.d.ts.map +1 -0
  136. package/dist/ui/workflow-menu.js +148 -0
  137. package/dist/ui/workflow-menu.js.map +1 -0
  138. package/dist/usage.d.ts +86 -1
  139. package/dist/usage.d.ts.map +1 -1
  140. package/dist/usage.js +72 -1
  141. package/dist/usage.js.map +1 -1
  142. package/dist/workflow/collisions.d.ts +96 -0
  143. package/dist/workflow/collisions.d.ts.map +1 -0
  144. package/dist/workflow/collisions.js +89 -0
  145. package/dist/workflow/collisions.js.map +1 -0
  146. package/dist/workflow/entry.d.ts +33 -0
  147. package/dist/workflow/entry.d.ts.map +1 -0
  148. package/dist/workflow/entry.js +30 -0
  149. package/dist/workflow/entry.js.map +1 -0
  150. package/dist/workflow/host.d.ts +63 -0
  151. package/dist/workflow/host.d.ts.map +1 -0
  152. package/dist/workflow/host.js +363 -0
  153. package/dist/workflow/host.js.map +1 -0
  154. package/dist/workflow/journal.d.ts +98 -0
  155. package/dist/workflow/journal.d.ts.map +1 -0
  156. package/dist/workflow/journal.js +121 -0
  157. package/dist/workflow/journal.js.map +1 -0
  158. package/dist/workflow/json-schema.d.ts +52 -0
  159. package/dist/workflow/json-schema.d.ts.map +1 -0
  160. package/dist/workflow/json-schema.js +112 -0
  161. package/dist/workflow/json-schema.js.map +1 -0
  162. package/dist/workflow/meta.d.ts +68 -0
  163. package/dist/workflow/meta.d.ts.map +1 -0
  164. package/dist/workflow/meta.js +318 -0
  165. package/dist/workflow/meta.js.map +1 -0
  166. package/dist/workflow/progress.d.ts +225 -0
  167. package/dist/workflow/progress.d.ts.map +1 -0
  168. package/dist/workflow/progress.js +362 -0
  169. package/dist/workflow/progress.js.map +1 -0
  170. package/dist/workflow/runtime.d.ts +335 -0
  171. package/dist/workflow/runtime.d.ts.map +1 -0
  172. package/dist/workflow/runtime.js +831 -0
  173. package/dist/workflow/runtime.js.map +1 -0
  174. package/dist/workflow/saved.d.ts +91 -0
  175. package/dist/workflow/saved.d.ts.map +1 -0
  176. package/dist/workflow/saved.js +204 -0
  177. package/dist/workflow/saved.js.map +1 -0
  178. package/dist/workflow/task.d.ts +137 -0
  179. package/dist/workflow/task.d.ts.map +1 -0
  180. package/dist/workflow/task.js +208 -0
  181. package/dist/workflow/task.js.map +1 -0
  182. package/dist/workflow/tool-description.d.ts +39 -0
  183. package/dist/workflow/tool-description.d.ts.map +1 -0
  184. package/dist/workflow/tool-description.js +200 -0
  185. package/dist/workflow/tool-description.js.map +1 -0
  186. package/dist/workflow/worker-source.d.ts +48 -0
  187. package/dist/workflow/worker-source.d.ts.map +1 -0
  188. package/dist/workflow/worker-source.js +779 -0
  189. package/dist/workflow/worker-source.js.map +1 -0
  190. package/dist/worktree.d.ts +10 -3
  191. package/dist/worktree.d.ts.map +1 -1
  192. package/dist/worktree.js +58 -54
  193. package/dist/worktree.js.map +1 -1
  194. package/dist/xml.d.ts +11 -0
  195. package/dist/xml.d.ts.map +1 -0
  196. package/dist/xml.js +13 -0
  197. package/dist/xml.js.map +1 -0
  198. package/docs/rpc.md +183 -0
  199. package/docs/superpowers/plans/2026-09-30-upstream-event-workflow-partial-history.md +195 -0
  200. package/docs/superpowers/specs/2026-09-30-upstream-event-workflow-partial-history-design.md +49 -0
  201. package/docs/workflows.md +437 -0
  202. package/examples/agent-tool-description.md +7 -7
  203. package/examples/workflows/compose.js +51 -0
  204. package/examples/workflows/fan-out-audit.js +47 -0
  205. package/examples/workflows/gated-fix.js +60 -0
  206. package/examples/workflows/lib/count-child.js +27 -0
  207. package/examples/workflows/review-panel.js +63 -0
  208. package/examples/workflows/structured-findings.js +78 -0
  209. package/package.json +1 -1
  210. package/src/abortable.ts +43 -0
  211. package/src/agent-color.ts +161 -0
  212. package/src/agent-file-toggle.ts +269 -0
  213. package/src/agent-history.ts +54 -2
  214. package/src/agent-manager.ts +1263 -402
  215. package/src/agent-runner.ts +251 -27
  216. package/src/agent-types.ts +188 -32
  217. package/src/child-context.ts +15 -0
  218. package/src/cross-extension-rpc.ts +96 -20
  219. package/src/custom-agents.ts +170 -13
  220. package/src/index.ts +2029 -536
  221. package/src/invocation-config.ts +118 -3
  222. package/src/mention-clone.ts +196 -0
  223. package/src/mention.ts +141 -0
  224. package/src/model-resolver.ts +18 -0
  225. package/src/model-scope.ts +70 -0
  226. package/src/nested-tools.ts +424 -0
  227. package/src/output-file.ts +61 -6
  228. package/src/prompts.ts +45 -2
  229. package/src/schedule.ts +35 -14
  230. package/src/settings.ts +312 -2
  231. package/src/status-note.ts +66 -1
  232. package/src/structured-output.ts +130 -0
  233. package/src/types.ts +177 -10
  234. package/src/ui/agent-mention.ts +216 -0
  235. package/src/ui/agent-widget.ts +393 -441
  236. package/src/ui/conversation-blocks.ts +6 -0
  237. package/src/ui/conversation-timeline.ts +139 -25
  238. package/src/ui/conversation-viewer.ts +212 -48
  239. package/src/ui/fleet-list.ts +558 -0
  240. package/src/ui/schedule-menu.ts +9 -8
  241. package/src/ui/select-item.ts +45 -0
  242. package/src/ui/workflow-card.ts +470 -0
  243. package/src/ui/workflow-dialog.ts +1115 -0
  244. package/src/ui/workflow-menu.ts +193 -0
  245. package/src/usage.ts +109 -2
  246. package/src/workflow/collisions.ts +123 -0
  247. package/src/workflow/entry.ts +47 -0
  248. package/src/workflow/host.ts +403 -0
  249. package/src/workflow/journal.ts +164 -0
  250. package/src/workflow/json-schema.ts +128 -0
  251. package/src/workflow/meta.ts +325 -0
  252. package/src/workflow/progress.ts +550 -0
  253. package/src/workflow/runtime.ts +1219 -0
  254. package/src/workflow/saved.ts +217 -0
  255. package/src/workflow/task.ts +302 -0
  256. package/src/workflow/tool-description.ts +200 -0
  257. package/src/workflow/worker-source.ts +781 -0
  258. package/src/worktree.ts +69 -55
  259. package/src/xml.ts +13 -0
  260. package/vitest.config.ts +0 -18
package/src/index.ts CHANGED
@@ -10,28 +10,36 @@
10
10
  * /agents — Interactive agent management menu
11
11
  */
12
12
 
13
- import { existsSync, mkdirSync, readFileSync, unlinkSync } from "node:fs";
14
- import { join } from "node:path";
15
- import { defineTool, type ExtensionAPI, type ExtensionCommandContext, type ExtensionContext, getAgentDir, getSelectListTheme, getSettingsListTheme } from "@earendil-works/pi-coding-agent";
16
- import { Container, Key, matchesKey, SelectList, type SettingItem, SettingsList, Spacer, Text } from "@earendil-works/pi-tui";
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, getAgentDir, getSettingsListTheme } from "@earendil-works/pi-coding-agent";
16
+ import { Container, Key, matchesKey, type SettingItem, SettingsList, Spacer, Text } from "@earendil-works/pi-tui";
17
17
  import { Type } from "@sinclair/typebox";
18
- import { agentHistoryLocator, createAgentHistoryPath, readAgentHistory, readAgentHistoryResult } from "./agent-history.js";
19
- import { buildAgentStatusMenuEntries, canOpenActiveAgent, canOpenAgentHistory, formatAgentHistoryOption, splitAgentRecords } from "./agent-history-list.js";
20
- import { AgentManager } from "./agent-manager.js";
21
- import { getAgentConversation, getDefaultMaxTurns, getGraceTurns, normalizeMaxTurns, SUBAGENT_TOOL_NAMES, setDefaultMaxTurns, setGraceTurns, steerAgent } from "./agent-runner.js";
22
- import { applyNicoOverrides, BUILTIN_TOOL_NAMES, getAgentConfig, getAllTypes, getAvailableTypes, isDefaultsDisabled, registerAgents, resolveType, setDefaultsDisabled } from "./agent-types.js";
18
+ import { abortable } from "./abortable.js";
19
+ import { hasAgentBadge, renderAgentName } from "./agent-color.js";
20
+ import { buildNewAgentFile, disableInContent, enableInContent, isEmptyStub, locateAgentFile, personalAgentsDir, projectAgentsDir, serializeAgentFile } from "./agent-file-toggle.js";
21
+ import { readAgentHistory } from "./agent-history.js";
22
+ import { canOpenAgentHistory, splitAgentRecords } from "./agent-history-list.js";
23
+ import { AgentManager, isTopLevelAgent } from "./agent-manager.js";
24
+ import { getAgentConversation, getDefaultMaxTurns, getGraceTurns, getRememberAgents, normalizeMaxTurns, resolveEffectiveMaxTurns, SUBAGENT_TOOL_NAMES, setDefaultMaxTurns, setGraceTurns, setRememberAgents, steerAgent } from "./agent-runner.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";
23
27
  import { type RpcHandle, registerRpcHandlers } from "./cross-extension-rpc.js";
24
28
  import { loadCustomAgents } from "./custom-agents.js";
25
- import { isModelInScope, readEnabledModels, resolveEnabledModels } from "./enabled-models.js";
26
29
  import { GroupJoinManager } from "./group-join.js";
27
- import { resolveAgentInvocationConfig, resolveJoinMode } from "./invocation-config.js";
28
- import { type ModelRegistry, resolveModel } from "./model-resolver.js";
29
- import { createOutputFilePath, streamToOutputFile, writeInitialEntry } from "./output-file.js";
30
+ import { isolationParam, resolveAgentInvocationConfig, resolveJoinMode } from "./invocation-config.js";
31
+ import { describeMention, handleBase, isReservedHandle, parseMention, resolveHandleToType, stripAgentPrefix } from "./mention.js";
32
+ import { runMentionClone } from "./mention-clone.js";
33
+ import { describeModel, type ModelRegistry, resolveModel } from "./model-resolver.js";
34
+ import { checkModelScope, isScopeModelsEnabled, setScopeModelsEnabled } from "./model-scope.js";
35
+ import { getMaxSubagentDepth, setMaxSubagentDepth } from "./nested-tools.js";
36
+ import { createOutputFilePath, ensureOutputFile, getOutputTranscriptDefault, sessionTaskDir, setOutputTranscriptDefault, streamToOutputFile, writeInitialEntry } from "./output-file.js";
30
37
  import { SubagentScheduler } from "./schedule.js";
31
38
  import { resolveStorePath, ScheduleStore } from "./schedule-store.js";
32
- import { applyAndEmitLoaded, type SubagentsSettings, saveAndEmitChanged, type ToolDescriptionMode } from "./settings.js";
33
- import { getStatusNote } from "./status-note.js";
34
- import { type AgentConfig, type AgentInvocation, type AgentRecord, type JoinMode, type NotificationDetails, type SubagentType, type WidgetMode } from "./types.js";
39
+ import { applyAndEmitLoaded, loadSettings, type SubagentsSettings, saveAndEmitChanged, type ToolDescriptionMode } from "./settings.js";
40
+ import { getForegroundOutcomeNote, getStatusNote, partialOutputSuffix } from "./status-note.js";
41
+ import { type AgentConfig, type AgentInvocation, type AgentMentionMode, type AgentRecord, type JoinMode, type NotificationDetails, type SubagentType, type ViewerMarkdownMode, type WidgetMode } from "./types.js";
42
+ import { createMentionProvider, mentionRoster, type TypeInfo } from "./ui/agent-mention.js";
35
43
  import {
36
44
  type AgentActivity,
37
45
  type AgentDetails,
@@ -39,6 +47,7 @@ import {
39
47
  buildInvocationTags,
40
48
  describeActivity,
41
49
  fgPreservingNestedStyles,
50
+ formatCost,
42
51
  formatDuration,
43
52
  formatMs,
44
53
  formatTokens,
@@ -49,8 +58,23 @@ import {
49
58
  type Theme,
50
59
  type UICtx,
51
60
  } from "./ui/agent-widget.js";
61
+ import { FleetList, type FleetUICtx, type FleetWorkflow } from "./ui/fleet-list.js";
52
62
  import { showSchedulesMenu } from "./ui/schedule-menu.js";
53
- import { addUsage, getLifetimeTotal, getSessionContextPercent, type LifetimeUsage } from "./usage.js";
63
+ import { renderWorkflowCard, renderWorkflowEntryCard } from "./ui/workflow-card.js";
64
+ import { openWorkflowFromFleet, showWorkflowsMenu, type WorkflowMenuDeps } from "./ui/workflow-menu.js";
65
+ import { getLifetimeCost, getLifetimeTotal, getSessionContextPercent, type LifetimeUsage, PendingUsagePool, toReportedUsage } from "./usage.js";
66
+ import { decideWorkflowCollision, FOREIGN_WORKFLOW_TOOL_NAMES } from "./workflow/collisions.js";
67
+ import { WORKFLOW_ENTRY_TYPE, type WorkflowEntryData, workflowEntryData } from "./workflow/entry.js";
68
+ import { createWorkflowHost } from "./workflow/host.js";
69
+ import { appendJournal, readJournal, type WorkflowJournalEntry } from "./workflow/journal.js";
70
+ import { extractMeta, type WorkflowMeta, workflowCallName } from "./workflow/meta.js";
71
+ import { elapsedMs } from "./workflow/progress.js";
72
+ import { runWorkflow } from "./workflow/runtime.js";
73
+ import { resolveWorkflowScript } from "./workflow/saved.js";
74
+ import { completeWorkflowTask, createWorkflowTask, failWorkflowTask, formatWorkflowNotification, resolveResumeTarget, updateWorkflowProgressBatch, type WorkflowTask, workflowResultText, workflowRunId } from "./workflow/task.js";
75
+ import { fullWorkflowToolDescription } from "./workflow/tool-description.js";
76
+ import { isWorktreeIsolationEnabled, setWorktreeIsolationEnabled } from "./worktree.js";
77
+ import { escapeXml } from "./xml.js";
54
78
 
55
79
  // ---- Shared helpers ----
56
80
 
@@ -59,39 +83,6 @@ function textResult(msg: string, details?: AgentDetails) {
59
83
  return { content: [{ type: "text" as const, text: msg }], details: details as any };
60
84
  }
61
85
 
62
- /** Await a promise until it settles or the caller cancels, without aborting the underlying work. */
63
- function abortable<T>(promise: Promise<T>, signal?: AbortSignal): Promise<T> {
64
- if (!signal) return promise;
65
- if (signal.aborted) return Promise.reject(signal.reason);
66
-
67
- return new Promise<T>((resolve, reject) => {
68
- let settled = false;
69
- const cleanup = () => signal.removeEventListener("abort", onAbort);
70
- const onAbort = () => {
71
- if (settled) return;
72
- settled = true;
73
- cleanup();
74
- reject(signal.reason);
75
- };
76
-
77
- signal.addEventListener("abort", onAbort, { once: true });
78
- promise.then(
79
- (value) => {
80
- if (settled) return;
81
- settled = true;
82
- cleanup();
83
- resolve(value);
84
- },
85
- (error: unknown) => {
86
- if (settled) return;
87
- settled = true;
88
- cleanup();
89
- reject(error);
90
- },
91
- );
92
- });
93
- }
94
-
95
86
  export function renderRunningAgentStatus(
96
87
  frame: string,
97
88
  statsText: string,
@@ -148,8 +139,9 @@ function createActivityTracker(maxTurns?: number, onStreamUpdate?: () => void) {
148
139
  onSessionCreated: (session: any) => {
149
140
  state.session = session;
150
141
  },
151
- onAssistantUsage: (usage: { input: number; output: number; cacheWrite: number }) => {
152
- addUsage(state.lifetimeUsage, usage);
142
+ // Spend is accumulated on the AgentRecord (agent-manager), which is what
143
+ // every surface reads; this callback exists here only to repaint on it.
144
+ onAssistantUsage: (_usage: LifetimeUsage) => {
153
145
  onStreamUpdate?.();
154
146
  },
155
147
  };
@@ -166,16 +158,6 @@ function createActivityTracker(maxTurns?: number, onStreamUpdate?: () => void) {
166
158
  */
167
159
  const THINKING_LEVELS = ["off", "minimal", "low", "medium", "high", "xhigh", "max"] as const;
168
160
 
169
- /**
170
- * Salvaged partial output of a failed run, as a labeled suffix for the error
171
- * surfaces (or "" if the run produced nothing). `record.result` is bounded to
172
- * the run's own turns, so this is never a stale earlier answer (#144).
173
- */
174
- function partialOutputSuffix(record: AgentRecord, fallback?: string): string {
175
- const partial = record.result?.trim() || fallback?.trim();
176
- return partial ? `\n\nPartial output before the failure:\n${partial}` : "";
177
- }
178
-
179
161
  /** Human-readable status label for agent completion. */
180
162
  function getStatusLabel(status: string, error?: string): string {
181
163
  switch (status) {
@@ -187,19 +169,18 @@ function getStatusLabel(status: string, error?: string): string {
187
169
  }
188
170
  }
189
171
 
190
- /** Escape XML special characters to prevent injection in structured notifications. */
191
- function escapeXml(s: string): string {
192
- return s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
193
- }
194
-
195
172
  /** Format a structured task notification matching Claude Code's <task-notification> XML. */
196
- function formatTaskNotification(record: AgentRecord, resultMaxLen: number): string {
173
+ function formatTaskNotification(record: AgentRecord, resultMaxLen: number, showCost = false): string {
197
174
  const status = getStatusLabel(record.status, record.error);
198
175
  const durationMs = record.completedAt ? record.completedAt - record.startedAt : 0;
199
176
  const totalTokens = getLifetimeTotal(record.lifetimeUsage);
200
177
  const contextPercent = getSessionContextPercent(record.session);
201
178
  const ctxXml = contextPercent !== null ? `<context_percent>${Math.round(contextPercent)}</context_percent>` : "";
202
179
  const compactXml = record.compactionCount ? `<compactions>${record.compactionCount}</compactions>` : "";
180
+ // Only under `showCost`: this is LLM context, and a figure the orchestrator
181
+ // did not ask for is a figure it may start reporting unprompted.
182
+ const cost = showCost ? getLifetimeCost(record.lifetimeUsage) : 0;
183
+ const costXml = cost > 0 ? `<estimated_cost_usd>${cost.toFixed(4)}</estimated_cost_usd>` : "";
203
184
 
204
185
  const resultPreview = record.result
205
186
  ? record.result.length > resultMaxLen
@@ -215,7 +196,7 @@ function formatTaskNotification(record: AgentRecord, resultMaxLen: number): stri
215
196
  `<status>${escapeXml(status)}</status>`,
216
197
  `<summary>Agent "${escapeXml(record.description)}" ${record.status}${getStatusNote(record.status)}</summary>`,
217
198
  `<result>${escapeXml(resultPreview)}</result>`,
218
- `<usage><total_tokens>${totalTokens}</total_tokens><tool_uses>${record.toolUses}</tool_uses>${ctxXml}${compactXml}<duration_ms>${durationMs}</duration_ms></usage>`,
199
+ `<usage><total_tokens>${totalTokens}</total_tokens><tool_uses>${record.toolUses}</tool_uses>${ctxXml}${compactXml}${costXml}<duration_ms>${durationMs}</duration_ms></usage>`,
219
200
  `</task-notification>`,
220
201
  ].filter(Boolean).join('\n');
221
202
  }
@@ -231,6 +212,10 @@ function buildDetails(
231
212
  ...base,
232
213
  toolUses: record.toolUses,
233
214
  tokens: formatLifetimeTokens(record),
215
+ // Raw, and unconditional: `tokens` is preformatted because it is one stat,
216
+ // but a cost is joined by "·" in one surface, "," in another and "|" in a
217
+ // third — so it travels as a number and each renderer punctuates its own.
218
+ cost: getLifetimeCost(record.lifetimeUsage),
234
219
  turnCount: activity?.turnCount,
235
220
  maxTurns: activity?.maxTurns,
236
221
  durationMs: (record.completedAt ?? Date.now()) - record.startedAt,
@@ -253,6 +238,10 @@ function buildNotificationDetails(record: AgentRecord, resultMaxLen: number, act
253
238
  turnCount: activity?.turnCount ?? 0,
254
239
  maxTurns: activity?.maxTurns,
255
240
  totalTokens,
241
+ // Carried unconditionally; the renderer gates on the setting. Details are
242
+ // data, and a notification rendered before a mid-session toggle should not
243
+ // be stuck with the old answer.
244
+ totalCost: getLifetimeCost(record.lifetimeUsage),
256
245
  durationMs: record.completedAt ? record.completedAt - record.startedAt : 0,
257
246
  outputFile: record.outputFile,
258
247
  error: record.error,
@@ -264,7 +253,59 @@ function buildNotificationDetails(record: AgentRecord, resultMaxLen: number, act
264
253
  };
265
254
  }
266
255
 
256
+ /**
257
+ * Format an agent's tool scope for the Agent tool description.
258
+ *
259
+ * This suffix describes BUILT-IN scope only — extension tools are resolved when
260
+ * the agent runs (extensions can register asynchronously), so they cannot be
261
+ * enumerated while the description is being built. That is why an agent with
262
+ * `tools: "*, ext:mcp/search"` renders "*" and always has.
263
+ *
264
+ * Two distinctions matter, both of them capability claims the orchestrator acts on:
265
+ *
266
+ * - absent vs empty. `builtinToolNames: undefined` means the agent never narrowed
267
+ * its tools (the shipped defaults); `[]` is what `tools: none` and an `ext:`-only
268
+ * `tools:` parse to, and the runtime really does hand those agents no built-ins.
269
+ * Rendering both "*" tells the orchestrator a tool-less agent can run `bash`.
270
+ * - empty-with-extensions vs empty-without. Zero built-ins does NOT imply zero
271
+ * tools: `tools: none` alongside `extensions:` still surfaces every extension
272
+ * tool (see test/fixtures/.pi/agents/tools-none.md, which expects three). Calling
273
+ * that "none" understates the agent instead of overstating it — better, but still
274
+ * wrong, and it would route work away from the only agent able to do it. "none"
275
+ * is therefore reserved for agents that genuinely can call nothing: `isolated`
276
+ * agents and those with `extensions: false`.
277
+ */
278
+ export function formatToolsSuffix(cfg: AgentConfig | undefined): string {
279
+ const tools = cfg?.builtinToolNames;
280
+ if (!tools) return "*";
281
+ if (tools.length === 0) {
282
+ // `isolated` overrides extensions to false in the runner, so both mean the
283
+ // agent has no extension tools either — and then it truly has nothing.
284
+ const noExtensionTools = cfg?.isolated === true || cfg?.extensions === false;
285
+ return noExtensionTools ? "none" : "no built-ins, extension tools only";
286
+ }
287
+ const isFullSet =
288
+ tools.length === BUILTIN_TOOL_NAMES.length
289
+ && BUILTIN_TOOL_NAMES.every((t) => tools.includes(t));
290
+ return isFullSet ? "*" : tools.join(", ");
291
+ }
292
+
293
+ /** CLI flag that runs a workflow script at session start. */
294
+ export const WORKFLOW_FILE_FLAG = "subagents-workflow-file";
295
+
296
+ /**
297
+ * Re-exported from where they now live, because this is where they were
298
+ * defined and a consumer (or a test) that matched a session entry on
299
+ * {@link WORKFLOW_ENTRY_TYPE} imports it from here.
300
+ */
301
+ export { FOREIGN_WORKFLOW_TOOL_NAMES, WORKFLOW_ENTRY_TYPE, type WorkflowEntryData, workflowEntryData };
302
+
267
303
  export default function (pi: ExtensionAPI) {
304
+ // Child AgentSessions load normal extensions. Re-entering this extension there
305
+ // would create another manager and leak handlers. Nested orchestration is
306
+ // injected as scoped custom tools by the existing manager instead.
307
+ if (inChildSessionContext()) return;
308
+
268
309
  // ---- Register custom notification renderer ----
269
310
  pi.registerMessageRenderer<NotificationDetails>(
270
311
  "subagent-notification",
@@ -287,6 +328,10 @@ export default function (pi: ExtensionAPI) {
287
328
  if (d.turnCount > 0) parts.push(formatTurns(d.turnCount, d.maxTurns));
288
329
  if (d.toolUses > 0) parts.push(`${d.toolUses} tool use${d.toolUses === 1 ? "" : "s"}`);
289
330
  if (d.totalTokens > 0) parts.push(formatTokens(d.totalTokens));
331
+ if (showCost) {
332
+ const costText = formatCost(d.totalCost ?? 0);
333
+ if (costText) parts.push(costText);
334
+ }
290
335
  if (d.durationMs > 0) parts.push(formatMs(d.durationMs));
291
336
  if (parts.length) {
292
337
  line += "\n " + parts.map(p => theme.fg("dim", p)).join(" " + theme.fg("dim", "·") + " ");
@@ -310,23 +355,101 @@ export default function (pi: ExtensionAPI) {
310
355
  }
311
356
 
312
357
  const all = [d, ...(d.others ?? [])];
313
- return new Text(all.map(renderOne).join("\n"), 0, 0);
358
+ const rendered = all.map(renderOne);
359
+ // A group of agents lands as one notification, and the number a user wants
360
+ // from it is what the batch cost — not four figures to add up by hand.
361
+ // Derived from the per-agent details rather than carried alongside them:
362
+ // one source, so the total can never disagree with the rows above it.
363
+ if (showCost && all.length > 1) {
364
+ const total = formatCost(all.reduce((sum, a) => sum + (a.totalCost ?? 0), 0));
365
+ if (total) {
366
+ const tokens = all.reduce((sum, a) => sum + a.totalTokens, 0);
367
+ rendered.unshift(theme.fg("dim", `${all.length} agents · ${formatTokens(tokens)} · ${total}`));
368
+ }
369
+ }
370
+ return new Text(rendered.join("\n"), 0, 0);
314
371
  }
315
372
  );
316
373
 
374
+ // ---- Workflow run rendered as a session entry ----
375
+ // A workflow launched from the CLI flag has no tool call to hang its result
376
+ // card on, so it renders here instead — through the SAME layout the tool
377
+ // result uses, not a second one. Custom entries with no registered renderer
378
+ // are silently dropped by the host, which is why this is registered at
379
+ // activation rather than lazily.
380
+ if (typeof pi.registerEntryRenderer === "function") {
381
+ pi.registerEntryRenderer<WorkflowEntryData>(WORKFLOW_ENTRY_TYPE, (entry, _options, theme) =>
382
+ renderWorkflowEntryCard(entry.data, theme));
383
+ }
384
+
385
+ // Registered at activation; READ from session_start. The host applies CLI
386
+ // values after every extension factory has run, so `getFlag` here would only
387
+ // ever hand back the registered default (see the read site below).
388
+ if (typeof pi.registerFlag === "function") {
389
+ pi.registerFlag(WORKFLOW_FILE_FLAG, {
390
+ type: "string",
391
+ description:
392
+ `Run a workflow script at startup: --${WORKFLOW_FILE_FLAG}=<path>. ` +
393
+ "Use the `=` form — the space form consumes the next argument, which would swallow a following prompt.",
394
+ });
395
+ }
396
+
397
+ // Read directly rather than waiting for applyAndEmitLoaded below: this decides
398
+ // the initial load, which happens hundreds of lines before settings are applied.
399
+ let strictAgentFiles = loadSettings(process.cwd()).strictAgentFiles === true;
400
+
317
401
  /** Reload agents from project/global custom agent dirs and merge with defaults (called on init and each Agent invocation). */
318
- const reloadCustomAgents = () => {
319
- const userAgents = loadCustomAgents(process.cwd());
402
+ const reloadCustomAgents = (strict = false) => {
403
+ const userAgents = loadCustomAgents(process.cwd(), strict);
320
404
  registerAgents(userAgents);
321
- applyNicoOverrides();
322
405
  };
323
406
 
324
- // Initial load
325
- reloadCustomAgents();
407
+ // Initial load — the only strict one. A bad edit mid-session must not kill the
408
+ // session on the next unrelated spawn, so every later reload keeps warning.
409
+ reloadCustomAgents(strictAgentFiles);
326
410
 
327
411
  // ---- Agent activity tracking + widget ----
328
412
  const agentActivity = new Map<string, AgentActivity>();
329
413
 
414
+ // ---- Usage reporting (both off by default; see SubagentsSettings) ----
415
+ /** Attach subagent spend to tool results, so the parent session counts it. */
416
+ let reportUsage = false;
417
+ function isReportUsageEnabled(): boolean { return reportUsage; }
418
+ function setReportUsage(b: boolean): void {
419
+ reportUsage = b;
420
+ // Whatever accumulated while it was on is stale the moment it goes off:
421
+ // draining it later would bill the parent for a window the user opted out
422
+ // of, in one lump, on some unrelated later tool call.
423
+ if (!b) pendingUsage.drain();
424
+ }
425
+ /** Show `~$X` next to token counts in the subagent surfaces. */
426
+ let showCost = false;
427
+ function isShowCostEnabled(): boolean { return showCost; }
428
+ function setShowCost(b: boolean): void { showCost = b; widget.update(); fleet.update(); }
429
+ /** Name the model and thinking level on the widget's running rows. */
430
+ let showModel = false;
431
+ function isShowModelEnabled(): boolean { return showModel; }
432
+ function setShowModel(b: boolean): void { showModel = b; widget.update(); }
433
+ /**
434
+ * How much of the conversation viewer renders as Markdown. Read through a
435
+ * getter by the viewer rather than captured like `showCost`, because the
436
+ * viewer's `m` key writes back here while the overlay is on screen.
437
+ */
438
+ let viewerMarkdown: ViewerMarkdownMode = "assistant";
439
+ function getViewerMarkdown(): ViewerMarkdownMode { return viewerMarkdown; }
440
+ function setViewerMarkdown(mode: ViewerMarkdownMode): void { viewerMarkdown = mode; }
441
+ /**
442
+ * The viewer's `m` key, from either entry point: set the mode and persist it,
443
+ * so the key and `/agents → Settings` stay one setting rather than one per
444
+ * entry point. `ctx` carries only the warning a failed write notifies with,
445
+ * and the fleet list may be acting without one.
446
+ */
447
+ function chooseViewerMarkdown(mode: ViewerMarkdownMode, ctx?: ExtensionCommandContext): void {
448
+ setViewerMarkdown(mode);
449
+ persistSettings(ctx, `Viewer markdown set to ${mode}`);
450
+ }
451
+ const pendingUsage = new PendingUsagePool();
452
+
330
453
  // ---- Cancellable pending notifications ----
331
454
  // Holds notifications briefly so get_subagent_result can cancel them
332
455
  // before they reach pi.sendMessage (fire-and-forget).
@@ -356,7 +479,7 @@ export default function (pi: ExtensionAPI) {
356
479
  function emitIndividualNudge(record: AgentRecord) {
357
480
  if (record.resultConsumed) return; // re-check at send time
358
481
 
359
- const notification = formatTaskNotification(record, 500);
482
+ const notification = formatTaskNotification(record, 500, showCost);
360
483
  const footer = record.outputFile ? `\nFull transcript available at: ${record.outputFile}` : '';
361
484
 
362
485
  pi.sendMessage<NotificationDetails>({
@@ -370,6 +493,7 @@ export default function (pi: ExtensionAPI) {
370
493
  function sendIndividualNudge(record: AgentRecord) {
371
494
  agentActivity.delete(record.id);
372
495
  widget.markFinished(record.id);
496
+ fleet.onAgentFinished(record.id);
373
497
  scheduleNudge(record.id, () => emitIndividualNudge(record));
374
498
  widget.update();
375
499
  }
@@ -377,18 +501,15 @@ export default function (pi: ExtensionAPI) {
377
501
  // ---- Group join manager ----
378
502
  const groupJoin = new GroupJoinManager(
379
503
  (records, partial) => {
380
- for (const r of records) { agentActivity.delete(r.id); widget.markFinished(r.id); }
504
+ for (const r of records) { agentActivity.delete(r.id); widget.markFinished(r.id); fleet.onAgentFinished(r.id); }
381
505
 
382
506
  const groupKey = `group:${records.map(r => r.id).join(",")}`;
383
507
  scheduleNudge(groupKey, () => {
384
508
  // Re-check at send time
385
509
  const unconsumed = records.filter(r => !r.resultConsumed);
386
- if (unconsumed.length === 0) {
387
- widget.update();
388
- return;
389
- }
510
+ if (unconsumed.length === 0) { widget.update(); return; }
390
511
 
391
- const notifications = unconsumed.map(r => formatTaskNotification(r, 300)).join('\n\n');
512
+ const notifications = unconsumed.map(r => formatTaskNotification(r, 300, showCost)).join('\n\n');
392
513
  const label = partial
393
514
  ? `${unconsumed.length} agent(s) finished (partial — others still running)`
394
515
  : `${unconsumed.length} agent(s) finished`;
@@ -423,21 +544,43 @@ export default function (pi: ExtensionAPI) {
423
544
  const tokens = total > 0
424
545
  ? { input: u.input, output: u.output, total }
425
546
  : undefined;
547
+ // The whole run's spend as a pi `Usage` — pi's convention for handing spend
548
+ // to a consumer, so `usage.cost.total` and `usage.cacheRead` are where a
549
+ // listener already expects them and anything pi adds to `Usage` arrives
550
+ // without a change here. Omitted when nothing was spent, so "spent nothing"
551
+ // and "never ran" stay distinguishable. Ungated by `showCost`: that setting
552
+ // governs what a human is shown, not what the event carries.
553
+ //
554
+ // `tokens` above is the other convention, kept as it shipped: a flat view
555
+ // model like pi's own `SessionStats`, carrying the DISPLAY total, which
556
+ // excludes cacheRead (#38). The two answer different questions and neither
557
+ // derives from the other.
558
+ const usage = toReportedUsage(u);
426
559
  return {
427
560
  id: record.id,
428
561
  type: record.type,
429
562
  description: record.description,
430
- result: record.result,
563
+ result: record.transcriptPath ? undefined : record.result,
431
564
  error: record.error,
565
+ transcriptPath: record.transcriptPath,
432
566
  status: record.status,
433
567
  toolUses: record.toolUses,
434
568
  durationMs,
435
569
  tokens,
570
+ usage,
436
571
  };
437
572
  }
438
573
 
439
574
  // Background completion: route through group join or send individual nudge
575
+ let historySelectionIndex = 0;
576
+ let runningSelectionIndex = 0;
440
577
  const manager = new AgentManager((record) => {
578
+ // Owned children — nested, or a workflow's — report only through their
579
+ // owner: the parent's scoped tools, or the workflow's card, notification
580
+ // and dialog. Keep them out of top-level lifecycle, transcript,
581
+ // notification, and UI channels.
582
+ if (!isTopLevelAgent(record)) return;
583
+
441
584
  // Emit lifecycle event based on terminal status
442
585
  const isError = record.status === "error" || record.status === "stopped" || record.status === "aborted";
443
586
  const eventData = buildEventData(record);
@@ -451,22 +594,17 @@ export default function (pi: ExtensionAPI) {
451
594
  pi.appendEntry("subagents:record", {
452
595
  id: record.id, type: record.type, description: record.description,
453
596
  status: record.status,
454
- // Durable transcripts are the source of truth for full output. Avoid
455
- // copying a potentially large result into the parent session branch;
456
- // get_subagent_result reloads it on demand after cleanup/restart.
457
597
  result: record.transcriptPath ? undefined : record.result,
458
598
  error: record.error,
459
- startedAt: record.startedAt, completedAt: record.completedAt,
460
- toolUses: record.toolUses,
461
- lifetimeUsage: record.lifetimeUsage,
462
- invocation: record.invocation,
463
599
  transcriptPath: record.transcriptPath,
600
+ startedAt: record.startedAt, completedAt: record.completedAt,
464
601
  });
465
602
 
466
603
  // Skip notification if result was already consumed via get_subagent_result
467
604
  if (record.resultConsumed) {
468
605
  agentActivity.delete(record.id);
469
606
  widget.markFinished(record.id);
607
+ fleet.onAgentFinished(record.id);
470
608
  widget.update();
471
609
  return;
472
610
  }
@@ -486,15 +624,21 @@ export default function (pi: ExtensionAPI) {
486
624
  // 'delivered' → group callback already fired
487
625
  widget.update();
488
626
  }, undefined, (record) => {
627
+ if (!isTopLevelAgent(record)) return;
628
+ // Agent-tool spawns refresh these surfaces in their tool handler, but RPC
629
+ // and scheduler spawns enter through the manager directly.
630
+ if (currentCtx?.hasUI && (currentCtx.mode === undefined || currentCtx.mode === "tui")) {
631
+ widget.ensureTimer();
632
+ widget.update();
633
+ }
489
634
  // Emit started event when agent transitions to running (including from queue)
490
635
  pi.events.emit("subagents:started", {
491
636
  id: record.id,
492
637
  type: record.type,
493
638
  description: record.description,
494
639
  });
495
- widget.ensureTimer();
496
- widget.update();
497
640
  }, (record, info) => {
641
+ if (!isTopLevelAgent(record)) return;
498
642
  // Emit compacted event when agent's session compacts (preserves count on record).
499
643
  pi.events.emit("subagents:compacted", {
500
644
  id: record.id,
@@ -504,10 +648,17 @@ export default function (pi: ExtensionAPI) {
504
648
  tokensBefore: info.tokensBefore,
505
649
  compactionCount: record.compactionCount,
506
650
  });
651
+ }, (_record, usage) => {
652
+ // Every assistant message from every agent — nested included, exactly once.
653
+ // Parked here until a tool result can carry it back to the parent session;
654
+ // see `PendingUsagePool`. Skipped entirely when the feature is off, so no
655
+ // pool grows in a session that will never drain it.
656
+ if (reportUsage) pendingUsage.add(usage);
507
657
  });
508
658
 
509
659
  // Expose manager via Symbol.for() global registry for cross-package access.
510
660
  // Standard Node.js pattern for cross-package singletons (used by OpenTelemetry, etc.).
661
+ // Documented for callers in docs/rpc.md ("The manager registry").
511
662
  //
512
663
  // Claim the slot only if it's free: subagent sessions re-activate this
513
664
  // extension in the same process (session.bindExtensions in agent-runner.ts),
@@ -516,12 +667,92 @@ export default function (pi: ExtensionAPI) {
516
667
  // session's entry. The first activation (the root session) wins; child
517
668
  // activations leave it alone.
518
669
  const MANAGER_KEY = Symbol.for("pi-subagents:manager");
670
+ // Process-external callers may supply arbitrary options. Nested ownership and
671
+ // config-root metadata are internal capabilities issued only by scoped tools.
672
+ /**
673
+ * Resolve the agent type and spawn. Trusts its options — every caller must
674
+ * either be in-process or have gone through `spawnTopLevel` first.
675
+ */
676
+ const spawnResolved = (piRef: any, ctxRef: any, type: string, prompt: string, options: any) => {
677
+ // Cross-extension callers get the same dispatch contract as the LLM (#183).
678
+ // The RPC layer already throws for an unresolvable model rather than falling
679
+ // back silently; a bad agent type should not be quieter. Throws become error
680
+ // envelopes at the RPC boundary. Reload first so an agent file added mid
681
+ // session is spawnable here too, not only through the Agent tool.
682
+ reloadCustomAgents();
683
+ const dispatch = resolveSpawnType(type);
684
+ if (!dispatch.ok) throw new Error(dispatch.message);
685
+ // Every programmatic spawn lands here — cross-extension RPC, both `@handle`
686
+ // mention paths, and the `Symbol.for("pi-subagents:manager")` registry — and
687
+ // none came through the Agent tool, which is where the UI activity tracker is
688
+ // otherwise created. Without one the widget and FleetView have no tool name
689
+ // and no turn count, so the row reads `thinking…` for the agent's whole life
690
+ // while the header's tool-use count climbs beside it (#181). Double-tracking
691
+ // is not possible: the Agent tool calls `manager.spawn` directly. The tracker
692
+ // callbacks are the funnel's own — a caller's are not honoured, since a
693
+ // half-wired tracker renders worse than none.
694
+ //
695
+ // The turn limit is resolved rather than read off `options`, which a mention
696
+ // spawn deliberately omits so the agent's own config can decide: a tracker
697
+ // built with `undefined` renders `↻3` where the Agent tool renders `↻3≤20`.
698
+ // Like the tool's own, it is a prediction — editing the agent file mid-run
699
+ // leaves the displayed ceiling stale.
700
+ const { state, callbacks } = createActivityTracker(resolveEffectiveMaxTurns(dispatch.type, options?.maxTurns));
701
+ // Repaints are left to the manager's `onStart` callback, which already starts
702
+ // the widget/fleet timers for agents that enter this way.
703
+ const id = manager.spawn(piRef, ctxRef, dispatch.type, prompt, { ...options, ...callbacks });
704
+ agentActivity.set(id, state);
705
+ return id;
706
+ };
707
+
708
+ const spawnTopLevel = (piRef: any, ctxRef: any, type: string, prompt: string, options: any) => {
709
+ const safeOptions = { ...(options ?? {}) };
710
+ delete safeOptions.parentAgentId;
711
+ // Internal too: a forged value would hide an RPC-spawned agent inside
712
+ // someone else's workflow, and take it out of the concurrency pool with it.
713
+ delete safeOptions.workflowId;
714
+ delete safeOptions.depth;
715
+ delete safeOptions.maxSubagentDepth;
716
+ delete safeOptions.configCwd;
717
+ // Also internal: it names a transcript directory, so a forged value would
718
+ // be a path-traversal primitive.
719
+ delete safeOptions.rootSessionId;
720
+ // Worse than rootSessionId: this one names a file to OPEN and replay as a
721
+ // conversation. Only the mention dispatcher may set it, and only from a
722
+ // path this extension itself recorded — never from anything a caller sent.
723
+ delete safeOptions.resumeSessionFile;
724
+ // Bypasses handle allocation, so a forged value would duplicate a live
725
+ // agent's name and make `@handle` ambiguous. Same rule: dispatcher only.
726
+ delete safeOptions.reclaim;
727
+ // Every spawn through here is DETACHED — the caller gets an id back and
728
+ // awaits nothing. A forged `blocking` would charge it to the foreground
729
+ // pool and could defer it behind a queue whose gate nobody is holding.
730
+ delete safeOptions.blocking;
731
+ return spawnResolved(piRef, ctxRef, type, prompt, safeOptions);
732
+ };
733
+
734
+ /**
735
+ * Resolve a tool's `agent_id` as an id OR a handle, so the model addresses
736
+ * agents by the same names the user types. Ids are tried first, keeping the
737
+ * existing behaviour exact — a handle is only consulted when the string is
738
+ * not an id at all. Only live records: a tombstone has nothing to steer and
739
+ * no result to read. Callers still enforce the nested-ownership rejection.
740
+ */
741
+ const resolveAgentRef = (ref: string): AgentRecord | undefined => {
742
+ const byId = manager.getRecord(ref);
743
+ if (byId) return byId;
744
+ const resolved = manager.resolveMention(ref);
745
+ return resolved?.kind === "live" ? resolved.record : undefined;
746
+ };
747
+
519
748
  const registryEntry = {
520
749
  waitForAll: () => manager.waitForAll(),
521
750
  hasRunning: () => manager.hasRunning(),
522
- spawn: (piRef: any, ctx: any, type: string, prompt: string, options: any) =>
523
- manager.spawn(piRef, ctx, type, prompt, options),
524
- getRecord: (id: string) => manager.getRecord(id),
751
+ spawn: spawnTopLevel,
752
+ getRecord: (id: string) => {
753
+ const record = manager.getRecord(id);
754
+ return record !== undefined && isTopLevelAgent(record) ? record : undefined;
755
+ },
525
756
  };
526
757
  const ownsManagerRegistry = (globalThis as any)[MANAGER_KEY] === undefined;
527
758
  if (ownsManagerRegistry) {
@@ -538,6 +769,8 @@ export default function (pi: ExtensionAPI) {
538
769
  // (currentCtx would stay undefined → spawn always "No active session"). Gating
539
770
  // here makes a filtered session behave like an absent one (#142).
540
771
  let rpcHandle: RpcHandle | undefined;
772
+ /** Whether the `@handle` autocomplete wrapper has been stacked on pi's provider. */
773
+ let mentionProviderRegistered = false;
541
774
 
542
775
  // ---- Subagent scheduler ----
543
776
  // Session-scoped: store is constructed inside session_start once sessionId
@@ -560,35 +793,26 @@ export default function (pi: ExtensionAPI) {
560
793
  }
561
794
  }
562
795
 
563
- type AgentMenuSelection = { id?: string; index: number };
564
- let runningAgentSelection: AgentMenuSelection = { index: 0 };
565
- let historyAgentSelection: AgentMenuSelection = { index: 0 };
566
-
567
- function resetAgentMenuSelections() {
568
- runningAgentSelection = { index: 0 };
569
- historyAgentSelection = { index: 0 };
570
- }
571
-
572
796
  // Capture ctx from session_start for RPC spawn handler + start the scheduler.
573
797
  // This also wires the RPC handlers and broadcasts readiness — on the first
574
798
  // bound session_start, so a filtered-out activation never advertises (#142).
575
799
  pi.on("session_start", async (_event, ctx) => {
576
- resetAgentMenuSelections();
577
800
  currentCtx = ctx;
578
- manager.clearCompleted(true);
579
- const branch = ctx.sessionManager?.getBranch?.() ?? [];
580
- manager.restoreCompleted(branch
581
- .filter((entry: any) => entry?.type === "custom" && entry?.customType === "subagents:record")
582
- .map((entry: any) => entry.data));
583
- // Checkpoint files cover agents whose parent session never got a terminal
584
- // branch entry (shutdown, session switch, or a process restart).
585
801
  manager.restoreRecovered(ctx.cwd);
586
- // Attach the panel during TUI startup, after restored records are present,
587
- // so terminal agents from the session branch are immediately visible.
588
- if (ctx.mode === "tui") {
589
- widget.setUICtx(ctx.ui as UICtx);
802
+ const branchEntries = ctx.sessionManager?.getBranch?.() ?? [];
803
+ const restoredRecords = branchEntries
804
+ .filter((entry: any) => entry?.customType === "subagents:record" && entry?.data && typeof entry.data.id === "string")
805
+ .map((entry: any) => entry.data as Partial<AgentRecord>);
806
+ manager.restoreCompleted(restoredRecords);
807
+ historySelectionIndex = 0;
808
+ runningSelectionIndex = 0;
809
+ if (ctx.hasUI && (ctx.mode === undefined || ctx.mode === "tui")) {
810
+ widget.setUICtx(ctx.ui);
590
811
  widget.update();
812
+ fleet.setUICtx(ctx.ui as any, false);
813
+ fleet.setCwd(ctx.cwd);
591
814
  }
815
+ manager.clearCompleted(true);
592
816
  // Guard mirrors the `!scheduler.isActive()` pattern below: session_start
593
817
  // fires once per activation, but a double-bind must not leak listeners.
594
818
  if (!rpcHandle) {
@@ -596,7 +820,26 @@ export default function (pi: ExtensionAPI) {
596
820
  events: pi.events,
597
821
  pi,
598
822
  getCtx: () => currentCtx,
599
- manager,
823
+ manager: {
824
+ spawn: spawnTopLevel,
825
+ awaitStartup: (id) => manager.awaitStartup(id),
826
+ getRecord: (id) => manager.getRecord(id),
827
+ // Unguarded on purpose: the stop handler now runs the top-level check
828
+ // itself off `getRecord`, and reports the refusal instead of the
829
+ // "Agent not found" a false from here used to be read as.
830
+ abort: (id) => manager.abort(id),
831
+ consumeResult: (id) => {
832
+ const record = resolveAgentRef(id);
833
+ // Same guard as get_subagent_result: a running agent has no result
834
+ // to consume, and its notification is still the caller's only
835
+ // signal that it finished.
836
+ if (!record || record.parentAgentId) return false;
837
+ if (record.status === "running" || record.status === "queued") return false;
838
+ record.resultConsumed = true;
839
+ cancelNudge(record.id);
840
+ return true;
841
+ },
842
+ },
600
843
  });
601
844
  // Broadcast readiness so extensions loaded alongside us can discover us.
602
845
  // Emitting after all factories have run (rather than at factory time)
@@ -604,13 +847,267 @@ export default function (pi: ExtensionAPI) {
604
847
  pi.events.emit("subagents:ready", {});
605
848
  }
606
849
  if (isSchedulingEnabled() && !scheduler.isActive()) startScheduler(ctx);
850
+ // Stack `@handle` suggestions on pi's built-in autocomplete. Registered at
851
+ // most once per activation: pi appends wrappers to a list it never prunes,
852
+ // so a second call would layer a duplicate provider on the first. TUI only
853
+ // — print mode has no such method, and RPC mode's is a no-op.
854
+ if (ctx.mode === "tui" && !mentionProviderRegistered && typeof ctx.ui.addAutocompleteProvider === "function") {
855
+ mentionProviderRegistered = true;
856
+ ctx.ui.addAutocompleteProvider(current =>
857
+ createMentionProvider(
858
+ current,
859
+ // Plain text, not renderAgentName: the same label FleetView and the
860
+ // widget show, but the autocomplete description cannot carry ANSI.
861
+ () => mentionRoster(manager, mentionTypes(), type => getConfig(type).displayName),
862
+ isAgentMentionsEnabled,
863
+ ),
864
+ );
865
+ }
866
+ // Last, and only here: CLI flag values are applied by the host AFTER every
867
+ // extension factory has run, so this is the earliest point the real value
868
+ // exists. Detached inside — a workflow must not hold up session startup.
869
+ resolveWorkflowCollisions(ctx);
870
+ runWorkflowFlag(ctx);
871
+ });
872
+
873
+ /** Agent types `@` can start, in the shape the roster wants. */
874
+ const mentionTypes = (): TypeInfo[] =>
875
+ getAvailableTypes().map(name => ({ name, description: getAgentConfig(name)?.description ?? name }));
876
+
877
+ /**
878
+ * `@handle message` typed at the prompt addresses that agent instead of the
879
+ * main model — Claude Code's prompt mention, same grammar (see mention.ts).
880
+ *
881
+ * The handle names the *agent*, not one process, so one syntax covers its
882
+ * whole lifecycle: message it while it runs, resume it once it has finished,
883
+ * start it if it never ran. Everything that isn't an agent mention falls
884
+ * through untouched, which is what keeps `@src/foo.ts summarize this`, a bare
885
+ * `@handle`, and ordinary prose working. A delivered mention costs no
886
+ * main-model turn; the answer arrives through the ordinary completion
887
+ * notification either way.
888
+ */
889
+ pi.on("input", async (event, ctx) => {
890
+ // Never hijack text the extension layer itself submitted (pi.sendMessage,
891
+ // scheduled prompts) — only something a person typed can be a mention.
892
+ if (event.source === "extension" || !isAgentMentionsEnabled()) return { action: "continue" };
893
+ // Claiming the turn is TUI only, matching the `@` completion that teaches
894
+ // the syntax. Pi defaults `session.prompt()` to source "interactive", so a
895
+ // headless `pi -p "@explore …"` reaches here too — and claiming it would
896
+ // answer with silence, which the background hold cannot fix: `handled`
897
+ // returns from prompt() before any turn starts, so the loop that patch wraps
898
+ // never runs (it holds subagents spawned by the Agent tool MID-turn, a
899
+ // different path). The agent would detach, `ctx.ui.notify` is a no-op
900
+ // outside the TUI, and print mode would exit having printed nothing.
901
+ //
902
+ // `model` mode has none of that problem: it queues a reminder and lets the
903
+ // turn run, so the answer is the model's own, printed as usual. It is the
904
+ // only branch allowed to act headlessly; everything else falls through to
905
+ // the main model exactly as it did before mentions existed.
906
+ const canDispatchDirectly = ctx.mode === "tui";
907
+ if (!canDispatchDirectly && getAgentMentionMode() !== "model") return { action: "continue" };
908
+
909
+ const mention = parseMention(event.text);
910
+ if (!mention) return { action: "continue" };
911
+
912
+ // `@main` addresses the main conversation, never a subagent — the one name
913
+ // `assignHandle` refuses to allocate. An explicit escape hatch for text
914
+ // that would otherwise read as a mention, so the prefix is dropped and the
915
+ // rest goes to the model with its attachments intact.
916
+ if (isReservedHandle(mention.handle)) {
917
+ return { action: "transform", text: mention.message, ...(event.images && { images: event.images }) };
918
+ }
919
+
920
+ // As typed first, so an agent actually called `agent-foo` wins over Claude
921
+ // Code's `@agent-` + `foo` spelling rather than being shadowed by it.
922
+ const alias = stripAgentPrefix(mention.handle);
923
+ const resolved = manager.resolveMention(mention.handle)
924
+ ?? (alias ? manager.resolveMention(alias) : undefined);
925
+
926
+ // Steering and resuming are direct in every mode, so headless they are not
927
+ // available at all. Falling through here rather than dropping to the start
928
+ // path below matters: the handle names an agent that already exists, and
929
+ // asking the model to start another one is not what was typed.
930
+ if (resolved && !canDispatchDirectly) return { action: "continue" };
931
+
932
+ if (resolved?.kind === "live") {
933
+ const record = resolved.record;
934
+ const target = `@${record.alias ?? record.handle ?? mention.handle}`;
935
+
936
+ if (record.status === "running" || record.status === "queued") {
937
+ // Steering interrupts after the current tool call, exactly like the
938
+ // steer_subagent tool. Un-consume the result so the agent's reply to
939
+ // this message is still relayed even if the LLM read its last answer.
940
+ record.resultConsumed = false;
941
+ manager.steer(record.id, mention.message);
942
+ pi.events.emit("subagents:steered", { id: record.id, message: mention.message });
943
+ ctx.ui.notify(`Sent to ${target}`, "info");
944
+ return { action: "handled" };
945
+ }
946
+
947
+ if (record.session) {
948
+ // Both derived from the record's OWN type: a mention names an existing
949
+ // agent, so its frontmatter is what governs — `output_transcript: false`
950
+ // must keep holding, since record.outputFile is the sole gate every
951
+ // downstream consumer keys off and a resume must not re-open it.
952
+ const config = getAgentConfig(record.type);
953
+ const resumedRecord = await startBackgroundResume(ctx, record, mention.message, {
954
+ outputTranscript: config?.outputTranscript ?? getOutputTranscriptDefault(),
955
+ maxTurns: normalizeMaxTurns(config?.maxTurns ?? getDefaultMaxTurns()),
956
+ });
957
+ ctx.ui.notify(
958
+ resumedRecord ? `Resuming ${target}` : `Could not resume ${target} — it is still running.`,
959
+ resumedRecord ? "info" : "warning",
960
+ );
961
+ return { action: "handled" };
962
+ }
963
+ // A live record with no session never got far enough to continue, so it
964
+ // falls through to the start-fresh path below, like Claude's
965
+ // `no_transcript`.
966
+ }
967
+
968
+ // Evicted, but its conversation is still on disk: reopen it. This is an
969
+ // ordinary spawn carrying a session file, so the new record picks up the
970
+ // widget, fleet row, transcript and completion notification unchanged —
971
+ // and `reclaim` hands it back the names the tombstone was holding.
972
+ if (resolved?.kind === "tombstone") {
973
+ const entry = resolved.entry;
974
+ const target = `@${entry.alias ?? entry.handle}`;
975
+
976
+ // Checked here rather than left to SessionManager.open: that runs inside
977
+ // runAgent, whose rejection lands on the record as an agent error, not in
978
+ // the catch below. A `/new` in another pi window or a manual delete makes
979
+ // the conversation unrecoverable (Claude Code's `not_reachable`), so drop
980
+ // the entry — a row that can only ever fail is worse than none — and say
981
+ // so rather than quietly sending this message to an unrelated agent.
982
+ if (!existsSync(entry.sessionFile)) {
983
+ manager.dropTombstone(entry.handle);
984
+ ctx.ui.notify(`Could not resume ${target} — its session is gone.`, "warning");
985
+ return { action: "handled" };
986
+ }
987
+
988
+ // The Agent tool deliberately falls back to general-purpose for a type it
989
+ // cannot resolve (#183), which covers a deleted file AND a merely
990
+ // disabled one. A resume must not inherit that: reopening this
991
+ // conversation under a different agent's prompt and tools is not
992
+ // continuing it, and the new record would re-tombstone under the
993
+ // substitute, so the handle would never find its way back.
994
+ reloadCustomAgents();
995
+ const dispatch = resolveSpawnType(entry.type);
996
+ if (!dispatch.ok || dispatch.fellBackFrom !== undefined) {
997
+ // The tombstone stays: re-enabling the agent makes the handle work
998
+ // again, which a drop would foreclose.
999
+ ctx.ui.notify(`Could not resume ${target} — the ${entry.type} agent is no longer available.`, "warning");
1000
+ return { action: "handled" };
1001
+ }
1002
+
1003
+ try {
1004
+ // spawnResolved, not spawnTopLevel: the latter strips
1005
+ // `resumeSessionFile` and `reclaim` as untrusted. This path is the
1006
+ // exception — both come from a tombstone this extension wrote.
1007
+ const id = spawnResolved(pi, ctx, dispatch.type, mention.message, {
1008
+ description: entry.description,
1009
+ reclaim: { handle: entry.handle, alias: entry.alias },
1010
+ resumeSessionFile: entry.sessionFile,
1011
+ isBackground: true,
1012
+ });
1013
+ // The agent may still be starting — wait, so a startup failure lands in
1014
+ // the catch below instead of being announced as a resume.
1015
+ await manager.awaitStartup(id);
1016
+ // The tombstone deliberately stays. `resolveMention` prefers the live
1017
+ // record holding these same names, so it cannot shadow the resume — and
1018
+ // if this run dies before establishing its own session, the original
1019
+ // transcript is still the right thing for the next mention to reopen.
1020
+ // Once the resumed record is evicted it overwrites this entry in place,
1021
+ // keyed by the same handle, so nothing accumulates.
1022
+ ctx.ui.notify(`Resuming ${target}`, "info");
1023
+ } catch (err) {
1024
+ // The type is already settled above, so what is left is a spawn-time
1025
+ // failure: a strict worktree-isolation error, an unusable cwd.
1026
+ ctx.ui.notify(
1027
+ `Could not resume ${target}: ${err instanceof Error ? err.message : String(err)}`,
1028
+ "warning",
1029
+ );
1030
+ }
1031
+ return { action: "handled" };
1032
+ }
1033
+
1034
+ // No agent under that handle — but the name may still be an agent type, in
1035
+ // which case the mention starts one.
1036
+ const typeHandle = mention.handle;
1037
+ const type = resolveHandleToType(typeHandle, getAvailableTypes())
1038
+ ?? (alias ? resolveHandleToType(alias, getAvailableTypes()) : undefined);
1039
+ if (!type) return { action: "continue" };
1040
+
1041
+ // Claude Code never starts the agent itself: `@agent-<type>` becomes an
1042
+ // attachment asking the main model to do it, and the model writes the
1043
+ // agent's prompt from the conversation rather than forwarding the typed
1044
+ // text. That buys a real `Agent` tool call — transcript, per-tool widget
1045
+ // detail, tool-use-id correlation, join grouping — and a prompt with the
1046
+ // context a cold spawn lacks.
1047
+ //
1048
+ // It also costs a visible turn, spent narrating a decision the user already
1049
+ // made by typing the handle. So the turn is taken by a clone of this
1050
+ // conversation instead (mention-clone.ts): same messages, same system
1051
+ // prompt, off-screen, holding only the `Agent` tool. Nothing reaches the
1052
+ // chat, and what it starts is an ordinary top-level agent.
1053
+ if (getAgentMentionMode() === "model") {
1054
+ const label = `@${handleBase(type)}`;
1055
+ // "Prompting", not "Starting": in this mode nothing starts until the
1056
+ // off-screen clone has taken a whole model turn writing the agent's
1057
+ // prompt, and that wait is the one thing the chat cannot show. `direct`
1058
+ // says "Started" because by then it has. The distinction tells the user
1059
+ // which of the two they are waiting on.
1060
+ ctx.ui.notify(`Prompting ${label}…`, "info");
1061
+ // Not awaited: the clone runs a full model turn, and prompt() is blocked
1062
+ // until this hook returns. The user gets their prompt back immediately
1063
+ // and the agent appears in the widget when it starts.
1064
+ void runMentionClone({ ctx, type, message: mention.message, agentTool: registeredAgentTool })
1065
+ .then(async (result) => {
1066
+ if (result.spawned) return;
1067
+ // A clone that could not run must not swallow the mention: start the
1068
+ // agent the direct way rather than leaving the user with a toast and
1069
+ // nothing running.
1070
+ try {
1071
+ const id = spawnTopLevel(pi, ctx, type, mention.message, {
1072
+ description: describeMention(mention.message),
1073
+ isBackground: true,
1074
+ });
1075
+ // Same reason as the direct path below: the agent may still be
1076
+ // starting, and a failure there must reach this catch.
1077
+ await manager.awaitStartup(id);
1078
+ ctx.ui.notify(`Started ${label} directly — ${result.error}`, "warning");
1079
+ } catch (err) {
1080
+ ctx.ui.notify(
1081
+ `Could not start ${label}: ${err instanceof Error ? err.message : String(err)}`,
1082
+ "error",
1083
+ );
1084
+ }
1085
+ });
1086
+ return { action: "handled" };
1087
+ }
1088
+
1089
+ try {
1090
+ // Nothing else to pass: runAgent resolves model, thinking and max turns
1091
+ // from the agent's own config when the spawn omits them, and the
1092
+ // manager's onStart/onComplete callbacks own the widget, the fleet list
1093
+ // and the completion notification — the same contract the scheduler and
1094
+ // cross-extension RPC spawns run under.
1095
+ const id = spawnTopLevel(pi, ctx, type, mention.message, {
1096
+ description: describeMention(mention.message),
1097
+ isBackground: true,
1098
+ });
1099
+ // The agent may still be starting (a worktree copy is an awaited git
1100
+ // call) — report a failure that lands there as a failed start, not as a
1101
+ // "Started" toast for an agent that never ran.
1102
+ await manager.awaitStartup(id);
1103
+ ctx.ui.notify(`Started @${handleBase(type)}`, "info");
1104
+ } catch (err) {
1105
+ ctx.ui.notify(`Could not start @${handleBase(type)}: ${err instanceof Error ? err.message : String(err)}`, "error");
1106
+ }
1107
+ return { action: "handled" };
607
1108
  });
608
1109
 
609
1110
  pi.on("session_before_switch", () => {
610
- resetAgentMenuSelections();
611
- // A switch is catchable. Stop and checkpoint live/queued agents before the
612
- // old session context is discarded, then retain their unread history.
613
- manager.abortAll();
614
1111
  manager.clearCompleted(true);
615
1112
  scheduler.stop();
616
1113
  });
@@ -618,10 +1115,10 @@ export default function (pi: ExtensionAPI) {
618
1115
  // On shutdown, abort all agents immediately and clean up.
619
1116
  // If the session is going down, there's nothing left to consume agent results.
620
1117
  pi.on("session_shutdown", async () => {
621
- resetAgentMenuSelections();
622
1118
  rpcHandle?.unsubSpawn();
623
1119
  rpcHandle?.unsubStop();
624
1120
  rpcHandle?.unsubPing();
1121
+ rpcHandle?.unsubConsume();
625
1122
  rpcHandle = undefined;
626
1123
  currentCtx = undefined;
627
1124
  // Only release the global slot if this activation claimed it — a child
@@ -630,45 +1127,77 @@ export default function (pi: ExtensionAPI) {
630
1127
  delete (globalThis as any)[MANAGER_KEY];
631
1128
  }
632
1129
  scheduler.stop();
1130
+ // Before abortAll, and not folded into it: a workflow owns a worker thread
1131
+ // as well as its children, and only its own signal terminates that.
1132
+ for (const task of workflowTasks.values()) task.abortController.abort();
1133
+ workflowTasks.clear();
633
1134
  manager.abortAll();
634
1135
  for (const timer of pendingNudges.values()) clearTimeout(timer);
635
1136
  pendingNudges.clear();
636
1137
  widget.dispose();
637
- manager.dispose();
1138
+ fleet.dispose();
1139
+ // Awaited: it emits `session_shutdown` into every retained child session so
1140
+ // extensions bound there can release what they armed in `session_start` (#242).
1141
+ // pi awaits this handler, and the process exits right after — unawaited, those
1142
+ // handlers would never run. Internally bounded, so a hung one can't strand quit.
1143
+ await manager.dispose(pi);
638
1144
  });
639
1145
 
640
- // Live widget: show all agents above the editor. Read live at render time.
641
- let widgetMode: WidgetMode = "all";
1146
+ // Live widget: show running agents above editor.
1147
+ // widgetMode (default "background") selects what the widget shows: "all" =
1148
+ // every agent; "background" = hide foreground (they already render inline as
1149
+ // the Agent tool result, so showing them here too is a duplicate, #118), keep
1150
+ // everything else; "off" = hide the widget entirely. Read live at render time.
1151
+ let widgetMode: WidgetMode = "background";
642
1152
  function getWidgetMode(): WidgetMode { return widgetMode; }
643
- const widget = new AgentWidget(
644
- manager,
645
- agentActivity,
646
- getWidgetMode,
647
- {
648
- canOpenHistory: (record) => canOpenAgentHistory(record, currentCtx?.cwd),
649
- onOpen: (record, mode) => {
650
- const ctx = currentCtx;
651
- if (ctx) void viewAgentConversation(ctx as ExtensionCommandContext, record, mode);
652
- },
1153
+ const widget = new AgentWidget(manager, agentActivity, getWidgetMode, {
1154
+ canOpenHistory: (record) => canOpenAgentHistory(record, currentCtx?.cwd),
1155
+ onOpen: (record) => {
1156
+ if (currentCtx) void viewAgentConversation(currentCtx as unknown as ExtensionCommandContext, record);
653
1157
  },
654
- );
655
- function setWidgetMode(m: WidgetMode): void {
656
- widgetMode = m;
657
- widget.update();
658
- }
659
-
660
- // Project/global default for writing the subagent .output transcript. A custom
661
- // agent's `output_transcript` frontmatter overrides this per spawn; when the
662
- // frontmatter is silent, this default applies. Read live at spawn time.
663
- let outputTranscriptDefault = true;
664
- function getOutputTranscriptDefault(): boolean { return outputTranscriptDefault; }
665
- function setOutputTranscript(b: boolean): void { outputTranscriptDefault = b; }
1158
+ showCost: isShowCostEnabled,
1159
+ }, isShowModelEnabled);
1160
+ function setWidgetMode(m: WidgetMode): void { widgetMode = m; widget.update(); }
1161
+
1162
+ // Claude Code-style FleetView: navigable list of main + subagents below the editor.
1163
+ // The last two arguments keep a conversation overlay opened here identical to
1164
+ // one opened from `/agents`: same setting on the way in, same persist out.
1165
+ const fleet = new FleetList(manager, agentActivity, isShowCostEnabled, getViewerMarkdown,
1166
+ (mode) => chooseViewerMarkdown(mode, currentCtx as unknown as ExtensionCommandContext | undefined),
1167
+ process.cwd());
1168
+ let fleetViewEnabled = true;
1169
+ function isFleetViewEnabled(): boolean { return fleetViewEnabled; }
1170
+ function setFleetViewEnabled(b: boolean): void { fleetViewEnabled = b; fleet.setEnabled(b); }
1171
+
1172
+ // Claude Code-style `@handle message` prompt mentions. Read live by both the
1173
+ // `input` hook and the stacked autocomplete provider, so the toggle applies
1174
+ // immediately — the provider itself can never be unregistered (pi's wrapper
1175
+ // list is append-only), it just delegates everything when this is off.
1176
+ let agentMentionMode: AgentMentionMode = "model";
1177
+ function getAgentMentionMode(): AgentMentionMode { return agentMentionMode; }
1178
+ function setAgentMentionMode(mode: AgentMentionMode): void { agentMentionMode = mode; }
1179
+ // `model` and `direct` differ only in who starts a not-yet-running agent, so
1180
+ // everything that just asks "are mentions live at all" — the suggestion list,
1181
+ // the steer and resume branches — reads this instead of the mode.
1182
+ function isAgentMentionsEnabled(): boolean { return agentMentionMode !== "off"; }
1183
+
1184
+ // Project/global default for writing the subagent .output transcript lives in
1185
+ // output-file.ts (both spawn paths read it). A custom agent's
1186
+ // `output_transcript` frontmatter overrides it per spawn; when the frontmatter
1187
+ // is silent, this default applies. Read live at spawn time.
666
1188
 
667
1189
  // ---- Join mode configuration ----
668
1190
  let defaultJoinMode: JoinMode = 'smart';
669
1191
  function getDefaultJoinMode(): JoinMode { return defaultJoinMode; }
670
1192
  function setDefaultJoinMode(mode: JoinMode) { defaultJoinMode = mode; }
671
1193
 
1194
+ // What an unqualified top-level spawn means. Defaults to background,
1195
+ // following Claude Code; `backgroundByDefault: false` restores the previous
1196
+ // foreground default. Nested spawns ignore this — see nested-tools.ts.
1197
+ let backgroundByDefault = true;
1198
+ function getBackgroundByDefault(): boolean { return backgroundByDefault; }
1199
+ function setBackgroundByDefault(b: boolean) { backgroundByDefault = b; }
1200
+
672
1201
  // Master switch for the schedule subagent feature. Defaults to enabled.
673
1202
  // Read once at extension init (before tool registration) so the Agent tool's
674
1203
  // param schema reflects the persisted setting. Runtime toggles via /agents
@@ -679,16 +1208,25 @@ export default function (pi: ExtensionAPI) {
679
1208
  function isSchedulingEnabled(): boolean { return schedulingEnabled; }
680
1209
  function setSchedulingEnabled(b: boolean) { schedulingEnabled = b; }
681
1210
 
682
- // ---- Scope models configuration ----
683
- // When enabled, subagent model choices are validated against `enabledModels`
684
- // from pi's settings — both global `<agentDir>/settings.json` and
685
- // project-local `<cwd>/.pi/settings.json` (project overrides global).
686
- // Off by default; opt-in via `/agents → Settings`. See docstring on
687
- // SubagentsSettings.scopeModels for the hard-error vs warn-and-proceed
688
- // policy and its rationale.
689
- let scopeModelsEnabled = false;
690
- function isScopeModelsEnabled(): boolean { return scopeModelsEnabled; }
691
- function setScopeModelsEnabled(enabled: boolean): void { scopeModelsEnabled = enabled; }
1211
+ // Master switch for scripted workflows. Defaults to ON. Off means the
1212
+ // `SubagentWorkflow` tool is never registered: the model is not told the
1213
+ // feature exists (zero context cost) and has nothing to call. The
1214
+ // `/agents → Workflows` view and `--subagents-workflow-file` are refused too, so
1215
+ // there is no second door into the same machinery.
1216
+ //
1217
+ // `workflowsPinned` records that the answer came from the user — a boolean in
1218
+ // subagents.json, or the settings toggle — rather than from this default. It
1219
+ // is what `resolveWorkflowCollisions` checks before yielding to another
1220
+ // extension's workflow tool: a default may be overridden by what else is
1221
+ // loaded, an explicit choice may not.
1222
+ let workflowsEnabled = true;
1223
+ let workflowsPinned = false;
1224
+ function isWorkflowsEnabled(): boolean { return workflowsEnabled; }
1225
+ function isWorkflowsPinned(): boolean { return workflowsPinned; }
1226
+ function setWorkflowsEnabled(b: boolean) {
1227
+ workflowsEnabled = b;
1228
+ workflowsPinned = true;
1229
+ }
692
1230
 
693
1231
  // ---- Disable default agents configuration ----
694
1232
  // When enabled, the three hardcoded default agents (general-purpose, Explore,
@@ -753,22 +1291,107 @@ export default function (pi: ExtensionAPI) {
753
1291
  }
754
1292
  }
755
1293
 
1294
+ /**
1295
+ * Launch a detached resume of an existing agent and wire everything a
1296
+ * re-running agent needs: transcript anchoring, activity tracking, join-mode
1297
+ * batching, the widget/fleet refresh, and the `subagents:created` event.
1298
+ *
1299
+ * Shared by the Agent tool's `resume` + `run_in_background` branch and the
1300
+ * `@handle message` prompt mention — they differ only in how they report the
1301
+ * outcome. Returns the record, or undefined when the manager refused because
1302
+ * the agent is still running (see AgentManager.resume).
1303
+ *
1304
+ * Callers must have already established that the record has a session.
1305
+ */
1306
+ async function startBackgroundResume(
1307
+ ctx: ExtensionContext,
1308
+ existing: AgentRecord,
1309
+ prompt: string,
1310
+ opts: { outputTranscript: boolean; maxTurns?: number; toolCallId?: string },
1311
+ ): Promise<AgentRecord | undefined> {
1312
+ const id = existing.id;
1313
+ const joinMode = resolveJoinMode(defaultJoinMode, true);
1314
+ // Assigned unconditionally: the completion notification carries this as
1315
+ // `<tool-use-id>`, so a mention-resume (which passes none) has to CLEAR the
1316
+ // id left by the spawn that created the record. Keeping it would point the
1317
+ // orchestrator's new result at a tool call that was answered runs ago.
1318
+ existing.toolCallId = opts.toolCallId;
1319
+ if (joinMode) existing.joinMode = joinMode;
1320
+ // Reuse the agent's transcript rather than starting a fresh one: the
1321
+ // path is deterministic per agent+session, so writing an initial entry
1322
+ // would truncate the previous run's turns (see ensureOutputFile).
1323
+ if (opts.outputTranscript) {
1324
+ existing.outputFile = createOutputFilePath(ctx.cwd, id, ctx.sessionManager.getSessionId());
1325
+ ensureOutputFile(existing.outputFile);
1326
+ }
1327
+ // Anchor streaming past the turns already on disk, captured BEFORE the
1328
+ // run starts. The resumed prompt lands as an ordinary user message at
1329
+ // this index, so it is written exactly once.
1330
+ const transcriptAnchor = existing.session?.messages.length ?? 0;
1331
+
1332
+ const { state: bgState, callbacks: bgCallbacks } = createActivityTracker(opts.maxTurns);
1333
+ // resumeAgent has no onSessionCreated — the session predates this run —
1334
+ // so seed it directly, or the widget shows no context % for the agent.
1335
+ bgState.session = existing.session;
1336
+
1337
+ // No `signal`: a background spawn deliberately omits it, and a detached
1338
+ // resume must behave the same. Passing it would abort this agent when
1339
+ // the parent turn is interrupted (user Esc), while agents started with
1340
+ // run_in_background in that same turn keep going.
1341
+ const record = await manager.resume(id, prompt, undefined, {
1342
+ isBackground: true,
1343
+ onToolActivity: bgCallbacks.onToolActivity,
1344
+ onAssistantUsage: bgCallbacks.onAssistantUsage,
1345
+ // Fires when the run actually starts — immediately, or on queue
1346
+ // drain. Wiring it here (rather than after resume() returns) means a
1347
+ // resume stopped while still queued never started streaming, so
1348
+ // there is no subscription left behind for a later run to trip over.
1349
+ onStarted: () => {
1350
+ const rec = manager.getRecord(id);
1351
+ if (rec?.session && rec.outputFile) {
1352
+ rec.outputCleanup = streamToOutputFile(rec.session, rec.outputFile, id, ctx.cwd, transcriptAnchor);
1353
+ }
1354
+ },
1355
+ });
1356
+ if (!record) return undefined;
1357
+
1358
+ if (joinMode != null && joinMode !== 'async') {
1359
+ currentBatchAgents.push({ id, joinMode });
1360
+ if (batchFinalizeTimer) clearTimeout(batchFinalizeTimer);
1361
+ batchFinalizeTimer = setTimeout(finalizeBatch, 100);
1362
+ }
1363
+
1364
+ agentActivity.set(id, bgState);
1365
+ // This agent already finished once, so the widget holds a finished-age
1366
+ // for it that is past the linger limit — without clearing it, the
1367
+ // resumed run's ✓/✗ line never renders and the agent just vanishes.
1368
+ widget.markRunning(id);
1369
+ widget.ensureTimer();
1370
+ widget.update();
1371
+ fleet.ensureTimer();
1372
+ fleet.update();
1373
+
1374
+ // Resume ignores subagent_type (the record keeps the type it was
1375
+ // spawned with), so report the record's own identity — a "created"
1376
+ // event carrying the caller's type would re-register the agent under
1377
+ // the wrong one in cross-extension mirrors keyed by id.
1378
+ pi.events.emit("subagents:created", {
1379
+ id,
1380
+ type: existing.type,
1381
+ description: existing.description,
1382
+ isBackground: true,
1383
+ });
1384
+
1385
+ return record;
1386
+ }
1387
+
756
1388
  // Grab UI context from first tool execution + clear lingering widget on new turn
757
1389
  pi.on("tool_execution_start", async (_event, ctx) => {
758
- widget.setUICtx(ctx.ui as UICtx);
1390
+ if (ctx.hasUI && (ctx.mode === undefined || ctx.mode === "tui")) widget.setUICtx(ctx.ui as UICtx);
1391
+ if (ctx.hasUI && ctx.mode === undefined) fleet.setUICtx(ctx.ui as unknown as FleetUICtx, true);
759
1392
  widget.onTurnStart();
760
1393
  });
761
1394
 
762
- /** Format an agent's tool scope: "*" when it has all built-ins, else a comma-separated list. */
763
- const formatToolsSuffix = (cfg: AgentConfig | undefined): string => {
764
- const tools = cfg?.builtinToolNames;
765
- if (!tools || tools.length === 0) return "*";
766
- const isFullSet =
767
- tools.length === BUILTIN_TOOL_NAMES.length
768
- && BUILTIN_TOOL_NAMES.every((t) => tools.includes(t));
769
- return isFullSet ? "*" : tools.join(", ");
770
- };
771
-
772
1395
  /** Build the full type list text dynamically from available agents only. */
773
1396
  const buildTypeListText = () => {
774
1397
  const available = getAvailableTypes();
@@ -808,15 +1431,29 @@ export default function (pi: ExtensionAPI) {
808
1431
  applyAndEmitLoaded(
809
1432
  {
810
1433
  setMaxConcurrent: (n) => manager.setMaxConcurrent(n),
1434
+ setMaxConcurrentForeground: (n) => manager.setMaxConcurrentForeground(n),
811
1435
  setDefaultMaxTurns,
812
1436
  setGraceTurns,
813
1437
  setDefaultJoinMode,
1438
+ setBackgroundByDefault,
814
1439
  setSchedulingEnabled,
815
1440
  setScopeModels: setScopeModelsEnabled,
1441
+ setStrictAgentFiles: (b) => { strictAgentFiles = b; },
816
1442
  setDisableDefaultAgents: setDisableDefaultAgents,
817
1443
  setToolDescriptionMode: setToolDescriptionMode,
1444
+ setFleetView: setFleetViewEnabled,
1445
+ setAgentMentions: setAgentMentionMode,
1446
+ setRememberAgents,
818
1447
  setWidgetMode: setWidgetMode,
819
- setOutputTranscript: setOutputTranscript,
1448
+ setOutputTranscript: setOutputTranscriptDefault,
1449
+ setWorktreeIsolation: setWorktreeIsolationEnabled,
1450
+ setWorkflowsEnabled: setWorkflowsEnabled,
1451
+ setMaxSubagentDepth: setMaxSubagentDepth,
1452
+ setFallbackSubagent: setFallbackSubagent,
1453
+ setReportUsage,
1454
+ setShowCost,
1455
+ setShowModel,
1456
+ setViewerMarkdown,
820
1457
  },
821
1458
  (event, payload) => pi.events.emit(event, payload),
822
1459
  );
@@ -845,6 +1482,21 @@ export default function (pi: ExtensionAPI) {
845
1482
  ? `\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.`
846
1483
  : "";
847
1484
 
1485
+ // Same trade as scheduleParam/scheduleGuideline above: `isolationParam` drops
1486
+ // the field from the schema when the project set `worktreeIsolation: false`,
1487
+ // so the prose has to go with it. Left in, it would teach the model to pass a
1488
+ // parameter that isn't declared — accepted (TypeBox sets no
1489
+ // `additionalProperties: false`) and then silently dropped by the resolver.
1490
+ // With no per-result note by design, the model would have every reason to go
1491
+ // on reporting a `pi-agent-*` branch that was never created.
1492
+ const isolationGuideline = isWorktreeIsolationEnabled()
1493
+ ? `\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.`
1494
+ : "";
1495
+
1496
+ const isolationCompactGuideline = isWorktreeIsolationEnabled()
1497
+ ? `\n- isolation: "worktree" gives the agent its own git worktree (removed on completion); changes land on a branch named in the result.`
1498
+ : "";
1499
+
848
1500
  // Compact Agent tool description (#91, `toolDescriptionMode: "compact"`) —
849
1501
  // the same load-bearing facts as the full version at ~75% fewer tokens, for
850
1502
  // small/local models. Per-option details live in the param descriptions.
@@ -855,10 +1507,10 @@ Custom agents: .pi/agents/<name>.md (project) or ${getAgentDir()}/agents/<name>.
855
1507
 
856
1508
  Notes:
857
1509
  - description: 3-5 words (shown in UI). Prompts must be self-contained — the agent has not seen this conversation.
858
- - Parallel work: one message, multiple Agent calls, run_in_background: true on each. You are notified when background agents finish — never poll or sleep.
1510
+ - Parallel work: one message, multiple Agent calls — they run concurrently.
1511
+ - 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.
859
1512
  - The result is not shown to the user — summarize it for them. Verify an agent's claimed code changes before reporting work done.
860
- - resume continues a previous agent by ID; steer_subagent messages a running one.
861
- - isolation: "worktree" runs the agent in an isolated git worktree; changes land on a branch.`;
1513
+ - resume continues a previous agent by ID; steer_subagent messages a running one.${isolationCompactGuideline}`;
862
1514
 
863
1515
  const fullAgentToolDescription = `Launch a new agent to handle complex, multi-step tasks autonomously. Each agent type has specific capabilities and tools available to it.
864
1516
 
@@ -876,23 +1528,23 @@ If the target is already known, use a direct tool — \`read\` for a known path,
876
1528
  ## Usage notes
877
1529
 
878
1530
  - Always include a short (3-5 word) description summarizing what the agent will do (shown in UI).
879
- - When you launch multiple agents for independent work, send them in a single message with multiple tool uses, with run_in_background: true on each, so they run concurrently. If the user specifies that they want agents run "in parallel", you MUST send a single message with multiple tool calls. Foreground calls run sequentially — only one executes at a time.
1531
+ - 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.
880
1532
  - 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.
881
- - 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 work as done.
882
- - Use run_in_background for work you don't need immediately. You will be notified when it completes — do NOT poll or sleep waiting for it. Continue with other work or respond to the user instead.
883
- - Foreground vs background: use foreground (default) when you need the agent's results before you can proceed. Use background when you have genuinely independent work to do in parallel.
1533
+ - 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.
1534
+ - 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.
1535
+ - **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.
1536
+ - **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.
884
1537
  - 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.
885
1538
  - Use steer_subagent to send mid-run messages to a running background agent.
886
1539
  - 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.
887
1540
  - If an agent's description says it should be used proactively, try to use it without the user having to ask for it first.
888
1541
  - Use model to specify a different model (as "provider/modelId", or fuzzy e.g. "haiku", "sonnet").
889
1542
  - Use thinking to control extended thinking level.
890
- - Use inherit_context if the agent needs the parent conversation history.
891
- - Use isolation: "worktree" to run the agent in an isolated git worktree (safe parallel file modifications). The worktree is automatically cleaned up if the agent makes no changes; otherwise the path and branch are returned in the result.${scheduleGuideline}
1543
+ - Use inherit_context if the agent needs the parent conversation history.${isolationGuideline}${scheduleGuideline}
892
1544
 
893
1545
  ## Writing the prompt
894
1546
 
895
- Provide clear, detailed prompts so the agent can work autonomously. Brief it 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.
1547
+ 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.
896
1548
  - Explain what you're trying to accomplish and why.
897
1549
  - Describe what you've already learned or ruled out.
898
1550
  - Give enough context about the surrounding problem that the agent can make judgment calls rather than just following a narrow instruction.
@@ -912,6 +1564,7 @@ Terse command-style prompts produce shallow, generic work.
912
1564
  typeList: buildTypeListText,
913
1565
  compactTypeList: buildCompactTypeListText,
914
1566
  agentDir: getAgentDir,
1567
+ isolationGuideline: () => isolationGuideline,
915
1568
  scheduleGuideline: () => scheduleGuideline,
916
1569
  };
917
1570
  // Replacement callback (not a string) — agent descriptions may contain `$&` etc.
@@ -950,7 +1603,10 @@ Terse command-style prompts produce shallow, generic work.
950
1603
  return fullAgentToolDescription;
951
1604
  })();
952
1605
 
953
- pi.registerTool(defineTool({
1606
+ // Held rather than registered inline: the mention clone reuses this exact
1607
+ // definition, so the agent it starts is an ordinary top-level spawn instead
1608
+ // of a second implementation that has to be kept in step with this one.
1609
+ const agentTool = defineTool({
954
1610
  name: SUBAGENT_TOOL_NAMES.AGENT,
955
1611
  label: "Agent",
956
1612
  description: agentToolDescription,
@@ -968,6 +1624,12 @@ Terse command-style prompts produce shallow, generic work.
968
1624
  description: Type.String({
969
1625
  description: "A short (3-5 word) description of the task (shown in UI).",
970
1626
  }),
1627
+ name: Type.Optional(
1628
+ Type.String({
1629
+ description:
1630
+ '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.',
1631
+ }),
1632
+ ),
971
1633
  subagent_type: Type.String({
972
1634
  description: `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.`,
973
1635
  }),
@@ -990,12 +1652,12 @@ Terse command-style prompts produce shallow, generic work.
990
1652
  ),
991
1653
  run_in_background: Type.Optional(
992
1654
  Type.Boolean({
993
- description: "Set to true to run in background. Returns agent ID immediately. You will be notified on completion.",
1655
+ 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.",
994
1656
  }),
995
1657
  ),
996
1658
  resume: Type.Optional(
997
1659
  Type.String({
998
- description: "Optional agent ID to resume from. Continues from previous context.",
1660
+ 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. An agent can only be resumed once its current run has finished — use steer_subagent to reach one mid-run.",
999
1661
  }),
1000
1662
  ),
1001
1663
  isolated: Type.Optional(
@@ -1008,26 +1670,40 @@ Terse command-style prompts produce shallow, generic work.
1008
1670
  description: "If true, fork parent conversation into the agent. Default: false (fresh context).",
1009
1671
  }),
1010
1672
  ),
1011
- isolation: Type.Optional(
1012
- Type.Literal("worktree", {
1013
- description: 'Set to "worktree" to run the agent in a temporary git worktree (isolated copy of the repo). Changes are saved to a branch on completion.',
1014
- }),
1015
- ),
1673
+ ...isolationParam(isWorktreeIsolationEnabled()),
1016
1674
  ...scheduleParam,
1017
1675
  }),
1018
1676
 
1019
1677
  // ---- Custom rendering: Claude Code style ----
1020
1678
 
1021
- renderCall(args, theme) {
1022
- const displayName = args.subagent_type ? getDisplayName(args.subagent_type) : "Agent";
1679
+ renderCall(args, theme, context) {
1680
+ // A badge closes its own background, which would clear the tool block's row tint
1681
+ // for the rest of the line, so the badge restores it. The tint is opened here too:
1682
+ // the TUI's Box paints it, but HTML export takes it from CSS, and restoring a
1683
+ // background the line never opened is what banded the export before. The line is
1684
+ // deliberately left open — Box.applyBackgroundToLine pads to width and *then*
1685
+ // wraps, so closing here would leave that padding untinted, and HTML export closes
1686
+ // any open span per line anyway. No badge means no tint, so an uncolored agent
1687
+ // renders exactly the line it always did.
1688
+ const rowBackground = hasAgentBadge(args.subagent_type)
1689
+ ? theme.getBgAnsi(context.isPartial ? "toolPendingBg" : context.isError ? "toolErrorBg" : "toolSuccessBg")
1690
+ : "";
1023
1691
  const desc = args.description ?? "";
1024
- return new Text("▸ " + theme.fg("toolTitle", theme.bold(displayName)) + (desc ? " " + theme.fg("muted", desc) : ""), 0, 0);
1692
+ const name = renderAgentName(args.subagent_type, theme, {
1693
+ fallbackColor: "toolTitle",
1694
+ restoreBackground: rowBackground,
1695
+ bold: true,
1696
+ });
1697
+ return new Text(rowBackground + "▸ " + name + (desc ? " " + theme.fg("muted", desc) : ""), 0, 0);
1025
1698
  },
1026
1699
 
1027
- renderResult(result, { expanded, isPartial }, theme) {
1700
+ renderResult(result, { expanded, isPartial }, theme, renderContext) {
1028
1701
  const details = result.details as AgentDetails | undefined;
1029
- if (!details) {
1030
- const text = result.content[0]?.type === "text" ? result.content[0].text : "";
1702
+ const text = result.content[0]?.type === "text" ? result.content[0].text : "";
1703
+ // Pi reports pre-execution failures (extension block, abort, argument
1704
+ // validation) as `{ content: [reason], details: {} }` with isError set —
1705
+ // no status to render, so show the reason instead of inventing one (#199).
1706
+ if (renderContext.isError || !details?.status) {
1031
1707
  return new Text(text, 0, 0);
1032
1708
  }
1033
1709
 
@@ -1041,6 +1717,10 @@ Terse command-style prompts produce shallow, generic work.
1041
1717
  }
1042
1718
  if (d.toolUses > 0) parts.push(`${d.toolUses} tool use${d.toolUses === 1 ? "" : "s"}`);
1043
1719
  if (d.tokens) parts.push(d.tokens);
1720
+ if (showCost) {
1721
+ const costText = formatCost(d.cost ?? 0);
1722
+ if (costText) parts.push(costText);
1723
+ }
1044
1724
  return parts.map(p => fgPreservingNestedStyles(theme, "dim", p)).join(" " + theme.fg("dim", "·") + " ");
1045
1725
  };
1046
1726
 
@@ -1091,6 +1771,12 @@ Terse command-style prompts produce shallow, generic work.
1091
1771
  return new Text(line, 0, 0);
1092
1772
  }
1093
1773
 
1774
+ // Anything left ("queued", or a status added later) has no rendering of
1775
+ // its own — the turn-limit wording below must not be the catch-all.
1776
+ if (details.status !== "error" && details.status !== "aborted") {
1777
+ return new Text(text, 0, 0);
1778
+ }
1779
+
1094
1780
  // ---- Error / Aborted (hard max_turns) ----
1095
1781
  const s = stats(details);
1096
1782
  let line = theme.fg("error", "✗") + (s ? " " + s : "");
@@ -1114,16 +1800,40 @@ Terse command-style prompts produce shallow, generic work.
1114
1800
  reloadCustomAgents();
1115
1801
 
1116
1802
  const rawType = params.subagent_type as SubagentType;
1117
- const resolved = resolveType(rawType);
1118
- const subagentType = resolved ?? "general-purpose";
1119
- const fellBack = resolved === undefined;
1803
+ // Single decision point for dispatch (#183): unknown, disabled and
1804
+ // case-ambiguous types are refused here, BEFORE anything spawns, so a
1805
+ // background or scheduled call can't start running the wrong agent while
1806
+ // the caller is still unaware. `fallbackSubagent` decides whether an
1807
+ // unresolvable type falls back or fails closed.
1808
+ const dispatch = resolveSpawnType(rawType);
1809
+ // `resume` replays a stored session and ignores `subagent_type` entirely,
1810
+ // but the parameter is required by the schema — so gating it here would
1811
+ // make a live agent unresumable the moment its type is deleted, disabled,
1812
+ // or gains a case-clashing sibling. Only a real spawn is gated.
1813
+ if (!dispatch.ok && !params.resume) return textResult(dispatch.message);
1814
+ const subagentType = dispatch.ok ? dispatch.type : rawType;
1815
+ // What the caller actually asked for, named once: `fellBackFrom` is "" for
1816
+ // a blank request, so reading it inline invites the `??`-vs-`||` slip that
1817
+ // once persisted an empty type into a scheduled job.
1818
+ const requestedType = (dispatch.ok && dispatch.fellBackFrom) || subagentType;
1819
+ // Computed at resolution rather than after the run, so the background and
1820
+ // schedule branches carry it too — previously it existed only on the
1821
+ // foreground path. Resume deliberately doesn't: it replays the stored
1822
+ // session and ignores `subagent_type` entirely, so a note about type
1823
+ // substitution would be describing something that didn't happen.
1824
+ const fallbackNote = dispatch.ok && dispatch.fellBackFrom !== undefined
1825
+ ? `Note: Unknown agent type "${dispatch.fellBackFrom}" — using ${resolveType(subagentType) ? subagentType : "the fallback agent config"}.\n\n`
1826
+ : "";
1120
1827
 
1121
1828
  const displayName = getDisplayName(subagentType);
1122
1829
 
1123
1830
  // Get agent config (if any)
1124
1831
  const customConfig = getAgentConfig(subagentType);
1125
1832
 
1126
- const resolvedConfig = resolveAgentInvocationConfig(customConfig, params);
1833
+ const resolvedConfig = resolveAgentInvocationConfig(customConfig, params, {
1834
+ worktreeAllowed: isWorktreeIsolationEnabled(),
1835
+ defaultRunInBackground: getBackgroundByDefault(),
1836
+ });
1127
1837
 
1128
1838
  // Resolve model from agent config first; tool-call params only fill gaps.
1129
1839
  let model = ctx.model;
@@ -1138,33 +1848,18 @@ Terse command-style prompts produce shallow, generic work.
1138
1848
  }
1139
1849
 
1140
1850
  // Scope validation: the effective resolved model is checked against the
1141
- // user's enabledModels list (read in `enabled-models.ts`).
1142
- //
1143
- // Design: scopeModels guards against *runtime* LLM choices, not user-level config.
1144
- // - Caller-supplied out-of-scope → hard error (the orchestrator made an explicit
1145
- // out-of-scope choice; surface it so it picks differently).
1146
- // - Frontmatter-pinned or parent-inherited out-of-scope → warn but proceed (the
1147
- // user authored/installed this agent or chose the parent's model; trust it).
1148
- // See SubagentsSettings.scopeModels docstring for the full policy.
1149
- if (isScopeModelsEnabled() && model) {
1150
- const allowed = resolveEnabledModels(readEnabledModels(ctx.cwd), ctx.modelRegistry, ctx.cwd);
1151
- if (allowed && !isModelInScope(model, allowed)) {
1152
- if (resolvedConfig.modelFromParams) {
1153
- const list = [...allowed].sort().map(m => ` ${m}`).join("\n");
1154
- return textResult(
1155
- `Model not in scope: "${resolvedConfig.modelInput}".\n\n` +
1156
- `Allowed models (from enabledModels):\n${list}`,
1157
- );
1158
- }
1159
- // Frontmatter-pinned or parent-inherited: warn + proceed.
1160
- const agentLabel = customConfig?.displayName ?? subagentType;
1161
- const modelLabel = resolvedConfig.modelInput ?? `${model.provider}/${model.id}`;
1162
- ctx.ui.notify(
1163
- `Agent "${agentLabel}" using out-of-scope model "${modelLabel}"`,
1164
- "warning",
1165
- );
1166
- }
1167
- }
1851
+ // user's enabledModels list. Policy (hard error vs warn-and-proceed) lives
1852
+ // in model-scope.ts so the nested delegation tools apply the same rule.
1853
+ const scopeVerdict = checkModelScope({
1854
+ model,
1855
+ cwd: ctx.cwd,
1856
+ modelRegistry: ctx.modelRegistry,
1857
+ callerSupplied: resolvedConfig.modelFromParams,
1858
+ agentLabel: customConfig?.displayName ?? subagentType,
1859
+ modelInput: resolvedConfig.modelInput,
1860
+ });
1861
+ if (scopeVerdict.kind === "error") return textResult(scopeVerdict.message);
1862
+ if (scopeVerdict.kind === "warn") ctx.ui.notify(scopeVerdict.message, "warning");
1168
1863
 
1169
1864
  const thinking = resolvedConfig.thinking;
1170
1865
  const inheritContext = resolvedConfig.inheritContext;
@@ -1181,33 +1876,34 @@ Terse command-style prompts produce shallow, generic work.
1181
1876
  if (!rec || !outputTranscript) return;
1182
1877
  rec.outputFile = createOutputFilePath(ctx.cwd, agentId, ctx.sessionManager.getSessionId());
1183
1878
  writeInitialEntry(rec.outputFile, agentId, params.prompt, ctx.cwd);
1184
-
1185
- try {
1186
- rec.historyFile = createAgentHistoryPath(ctx.cwd, agentId);
1187
- rec.transcriptPath = agentHistoryLocator(ctx.cwd, rec.historyFile);
1188
- writeInitialEntry(rec.historyFile, agentId, params.prompt, ctx.cwd);
1189
- manager.setTranscript(agentId, rec.historyFile, rec.transcriptPath, ctx.cwd);
1190
- } catch (err) {
1191
- rec.historyFile = undefined;
1192
- rec.transcriptPath = undefined;
1193
- ctx.ui.notify(
1194
- `Could not create durable transcript for agent ${agentId}: ${err instanceof Error ? err.message : String(err)}`,
1195
- "warning",
1196
- );
1197
- }
1198
1879
  };
1199
1880
 
1200
- const parentModelId = ctx.model?.id;
1201
- const effectiveModelId = model?.id;
1202
- const modelName = effectiveModelId && effectiveModelId !== parentModelId
1203
- ? (model?.name ?? effectiveModelId).replace(/^Claude\s+/i, "").toLowerCase()
1204
- : undefined;
1881
+ // Unconditional, not "only when it differs from the parent": a thinking
1882
+ // level reads as a property of a model, and an agent that inherited the
1883
+ // parent's model used to show the level with nothing to attach it to.
1884
+ // This is the pre-session snapshot — agent-manager overwrites it with the
1885
+ // effective values the moment a session reports them.
1886
+ const { modelName, modelId } = model ? describeModel(model) : { modelName: undefined, modelId: undefined };
1887
+ // What the caller SPELLED, kept only if it names a different model than the
1888
+ // one that won. Model input is fuzzy — `"haiku"` and
1889
+ // `"anthropic/claude-haiku-4-5"` are the same model — so comparing the two
1890
+ // strings would disclose an override that never happened. A spelling that
1891
+ // resolves to nothing is still worth disclosing: it cannot have taken effect.
1892
+ const askedModel = ((asked: string | undefined) => {
1893
+ if (!asked) return undefined;
1894
+ const resolvedAsked = resolveModel(asked, ctx.modelRegistry);
1895
+ if (typeof resolvedAsked === "string") return asked;
1896
+ return resolvedAsked.provider === model?.provider && resolvedAsked.id === model?.id ? undefined : asked;
1897
+ })(resolvedConfig.overridden?.model);
1205
1898
  const effectiveMaxTurns = normalizeMaxTurns(resolvedConfig.maxTurns ?? getDefaultMaxTurns());
1206
1899
  const agentInvocation: AgentInvocation = {
1207
1900
  modelName,
1208
- effectiveModelName: model?.name ?? model?.id ?? ctx.model?.name ?? ctx.model?.id,
1901
+ modelId,
1209
1902
  thinking,
1210
- effectiveThinking: thinking,
1903
+ // Only set where the agent file outranked the caller, so the surfaces can
1904
+ // disclose a parameter that was accepted but could not take effect (#182).
1905
+ requestedThinking: resolvedConfig.overridden?.thinking,
1906
+ requestedModel: askedModel,
1211
1907
  // Explicit value only — the default fallback would just add noise.
1212
1908
  // Normalize so `0` (unlimited) doesn't surface as a misleading "max turns: 0".
1213
1909
  maxTurns: normalizeMaxTurns(resolvedConfig.maxTurns),
@@ -1228,6 +1924,34 @@ Terse command-style prompts produce shallow, generic work.
1228
1924
  tags: agentTags.length > 0 ? agentTags : undefined,
1229
1925
  };
1230
1926
 
1927
+ /**
1928
+ * `detailBase` for a record that exists, which outranks it: the base is a
1929
+ * snapshot of what this call REQUESTED, and pi may have resolved a
1930
+ * different model or clamped the thinking level (agent-manager writes the
1931
+ * effective values back when the session reports them). Resume goes
1932
+ * further and ignores the model/thinking parameters outright — it runs on
1933
+ * the session it is reopening — so rendering the base there advertises
1934
+ * settings the run never used.
1935
+ *
1936
+ * The mode label is rebuilt rather than carried over: it hangs off the
1937
+ * agent TYPE, not the invocation, so tags taken straight from
1938
+ * buildInvocationTags would silently drop `twin`.
1939
+ */
1940
+ const detailBaseFor = (rec: AgentRecord | undefined): typeof detailBase => {
1941
+ if (!rec?.invocation) return detailBase;
1942
+ const type = rec.type;
1943
+ const { modelName: recModelName, tags } = buildInvocationTags(rec.invocation);
1944
+ const recModeLabel = getPromptModeLabel(type);
1945
+ const recTags = recModeLabel ? [recModeLabel, ...tags] : tags;
1946
+ return {
1947
+ displayName: getDisplayName(type),
1948
+ description: rec.description,
1949
+ subagentType: type,
1950
+ modelName: recModelName,
1951
+ tags: recTags.length > 0 ? recTags : undefined,
1952
+ };
1953
+ };
1954
+
1231
1955
  // ---- Schedule: register a job, don't spawn now ----
1232
1956
  if (params.schedule) {
1233
1957
  if (!isSchedulingEnabled()) {
@@ -1250,7 +1974,9 @@ Terse command-style prompts produce shallow, generic work.
1250
1974
  name: params.description as string,
1251
1975
  description: params.description as string,
1252
1976
  schedule: params.schedule as string,
1253
- subagent_type: subagentType,
1977
+ // The caller's own name, not the substitute — the scheduler re-resolves
1978
+ // at fire time, and the original is what a user edits.
1979
+ subagent_type: requestedType,
1254
1980
  prompt: params.prompt as string,
1255
1981
  model: params.model as string | undefined,
1256
1982
  thinking: thinking,
@@ -1260,7 +1986,7 @@ Terse command-style prompts produce shallow, generic work.
1260
1986
  });
1261
1987
  const next = scheduler.getNextRun(job.id);
1262
1988
  return textResult(
1263
- `Scheduled "${job.name}" (id: ${job.id}, type: ${job.scheduleType}). ` +
1989
+ `${fallbackNote}Scheduled "${job.name}" (id: ${job.id}, type: ${job.scheduleType}). ` +
1264
1990
  `Next run: ${next ?? "(unknown)"}. ` +
1265
1991
  `Manage via /agents → Scheduled jobs.`,
1266
1992
  );
@@ -1272,12 +1998,53 @@ Terse command-style prompts produce shallow, generic work.
1272
1998
  // Resume existing agent
1273
1999
  if (params.resume) {
1274
2000
  const existing = manager.getRecord(params.resume);
1275
- if (!existing) {
2001
+ if (!existing || !isTopLevelAgent(existing)) {
1276
2002
  return textResult(`Agent not found: "${params.resume}". It may have been cleaned up.`);
1277
2003
  }
1278
2004
  if (!existing.session) {
1279
2005
  return textResult(`Agent "${params.resume}" has no active session to resume.`);
1280
2006
  }
2007
+
2008
+ // Background resume: detached run that notifies on completion, mirroring
2009
+ // a background spawn. Previously run_in_background was silently ignored
2010
+ // on resume (this branch returned before the background branch below),
2011
+ // so a resumed agent always blocked the main loop until it finished.
2012
+ if (runInBackground) {
2013
+ const id = existing.id;
2014
+ // A detached resume hands control back while the record stays
2015
+ // "running", so nothing stops the model from resuming the same agent
2016
+ // again mid-run. manager.resume() refuses that (it would orphan the
2017
+ // live run's abort controller); say why here, where the model can act
2018
+ // on it, instead of letting it read as a generic failure.
2019
+ if (existing.status === "running" || existing.status === "queued") {
2020
+ return textResult(
2021
+ `Agent "${params.resume}" is still ${existing.status} — it can only be resumed once its current run finishes.\n` +
2022
+ `Use steer_subagent to send it a message mid-run, or get_subagent_result to wait for it.`,
2023
+ );
2024
+ }
2025
+
2026
+ const record = await startBackgroundResume(ctx, existing, params.prompt, {
2027
+ outputTranscript,
2028
+ maxTurns: effectiveMaxTurns,
2029
+ toolCallId,
2030
+ });
2031
+ if (!record) {
2032
+ return textResult(`Failed to resume agent "${params.resume}".`);
2033
+ }
2034
+
2035
+ const isQueued = record.status === "queued";
2036
+ return textResult(
2037
+ `Agent ${isQueued ? "queued" : "resumed"} in background.\n` +
2038
+ `Agent ID: ${id}\n` +
2039
+ `Type: ${existing.type}\n` +
2040
+ (record.outputFile ? `Output file: ${record.outputFile}\n` : "") +
2041
+ (isQueued ? `Position: queued (max ${manager.getMaxConcurrent()} concurrent)\n` : "") +
2042
+ `\nYou will be notified when this agent completes.\n` +
2043
+ `Use get_subagent_result to retrieve full results, or steer_subagent to send it messages.`,
2044
+ { ...detailBaseFor(record), toolUses: record.toolUses, tokens: "", durationMs: 0, status: "background" as const, agentId: id },
2045
+ );
2046
+ }
2047
+
1281
2048
  const record = await manager.resume(params.resume, params.prompt, signal);
1282
2049
  if (!record) {
1283
2050
  return textResult(`Failed to resume agent "${params.resume}".`);
@@ -1285,11 +2052,11 @@ Terse command-style prompts produce shallow, generic work.
1285
2052
  // A failed resume surfaces the error, plus any partial output THIS
1286
2053
  // resume produced (never the previous turn's answer, #144).
1287
2054
  if (record.status === "error") {
1288
- return textResult(`Agent failed: ${record.error}${partialOutputSuffix(record)}`, buildDetails(detailBase, record));
2055
+ return textResult(`Agent failed: ${record.error}${partialOutputSuffix(record)}`, buildDetails(detailBaseFor(record), record));
1289
2056
  }
1290
2057
  return textResult(
1291
2058
  record.result?.trim() || "No output.",
1292
- buildDetails(detailBase, record),
2059
+ buildDetails(detailBaseFor(record), record),
1293
2060
  );
1294
2061
  }
1295
2062
 
@@ -1298,47 +2065,53 @@ Terse command-style prompts produce shallow, generic work.
1298
2065
  const { state: bgState, callbacks: bgCallbacks } = createActivityTracker(effectiveMaxTurns);
1299
2066
 
1300
2067
  // Wrap onSessionCreated to wire output file streaming.
1301
- // The callback reads the transcript paths installed synchronously by
1302
- // onSpawned before the agent can queue or start.
2068
+ // The callback lazily reads record.outputFile (set right after spawn)
2069
+ // rather than closing over a value that doesn't exist yet.
1303
2070
  let id: string;
1304
- const joinMode = resolveJoinMode(defaultJoinMode, true);
1305
2071
  const origBgOnSession = bgCallbacks.onSessionCreated;
1306
2072
  bgCallbacks.onSessionCreated = (session: any) => {
1307
2073
  origBgOnSession(session);
1308
2074
  const rec = manager.getRecord(id);
1309
2075
  if (rec?.outputFile) {
1310
- rec.outputCleanup = streamToOutputFile(session, rec.outputFile, id, ctx.cwd, rec.historyFile);
2076
+ rec.outputCleanup = streamToOutputFile(session, rec.outputFile, id, ctx.cwd, undefined);
1311
2077
  }
1312
2078
  };
1313
2079
 
1314
- try {
1315
- id = manager.spawn(pi, ctx, subagentType, params.prompt, {
1316
- description: params.description,
1317
- model,
1318
- maxTurns: effectiveMaxTurns,
1319
- isolated,
1320
- inheritContext,
1321
- thinkingLevel: thinking,
1322
- isBackground: true,
1323
- isolation,
1324
- invocation: agentInvocation,
1325
- onSpawned: (spawnedId) => {
1326
- attachTranscript(manager.getRecord(spawnedId), spawnedId);
1327
- },
1328
- ...bgCallbacks,
1329
- });
1330
- } catch (err) {
1331
- return textResult(err instanceof Error ? err.message : String(err));
1332
- }
2080
+ // A throw here means the agent never started. Let it out: pi marks a
2081
+ // tool call failed only when execute throws, and a returned message
2082
+ // reads to the model as a subagent that ran and reported this (#179).
2083
+ id = manager.spawn(pi, ctx, subagentType, params.prompt, {
2084
+ description: params.description,
2085
+ name: params.name as string | undefined,
2086
+ model,
2087
+ maxTurns: effectiveMaxTurns,
2088
+ isolated,
2089
+ inheritContext,
2090
+ thinkingLevel: thinking,
2091
+ isBackground: true,
2092
+ isolation,
2093
+ invocation: agentInvocation,
2094
+ outputTranscript,
2095
+ rootSessionId: ctx.sessionManager.getSessionId(),
2096
+ ...bgCallbacks,
2097
+ });
1333
2098
 
1334
- // Set join metadata after spawn. Transcript metadata was installed by
1335
- // the manager's synchronous onSpawned callback before this point.
2099
+ // Set output file + join mode synchronously after spawn, before the
2100
+ // event loop yields — onSessionCreated is async so this is safe.
2101
+ const joinMode = resolveJoinMode(defaultJoinMode, true);
1336
2102
  const record = manager.getRecord(id);
1337
2103
  if (record && joinMode) {
1338
2104
  record.joinMode = joinMode;
1339
2105
  record.toolCallId = toolCallId;
2106
+ attachTranscript(record, id);
1340
2107
  }
1341
2108
 
2109
+ // With isolation: "worktree" the agent isn't running yet — the repo
2110
+ // copy is an awaited git call. Wait for it here, after the synchronous
2111
+ // wiring above, so a strict-isolation failure still fails THIS tool
2112
+ // call instead of being reported as a subagent that ran (#179).
2113
+ await manager.awaitStartup(id);
2114
+
1342
2115
  if (joinMode == null || joinMode === 'async') {
1343
2116
  // Foreground/no join mode or explicit async — not part of any batch
1344
2117
  } else {
@@ -1353,6 +2126,8 @@ Terse command-style prompts produce shallow, generic work.
1353
2126
  agentActivity.set(id, bgState);
1354
2127
  widget.ensureTimer();
1355
2128
  widget.update();
2129
+ fleet.ensureTimer();
2130
+ fleet.update();
1356
2131
 
1357
2132
  // Emit created event
1358
2133
  pi.events.emit("subagents:created", {
@@ -1364,7 +2139,7 @@ Terse command-style prompts produce shallow, generic work.
1364
2139
 
1365
2140
  const isQueued = record?.status === "queued";
1366
2141
  return textResult(
1367
- `Agent ${isQueued ? "queued" : "started"} in background.\n` +
2142
+ `${fallbackNote}Agent ${isQueued ? "queued" : "started"} in background.\n` +
1368
2143
  `Agent ID: ${id}\n` +
1369
2144
  `Type: ${displayName}\n` +
1370
2145
  `Description: ${params.description}\n` +
@@ -1373,7 +2148,7 @@ Terse command-style prompts produce shallow, generic work.
1373
2148
  `\nYou will be notified when this agent completes.\n` +
1374
2149
  `Use get_subagent_result to retrieve full results, or steer_subagent to send it messages.\n` +
1375
2150
  `Do not duplicate this agent's work.`,
1376
- { ...detailBase, toolUses: 0, tokens: "", durationMs: 0, status: "background" as const, agentId: id },
2151
+ { ...detailBaseFor(record), toolUses: 0, tokens: "", durationMs: 0, status: "background" as const, agentId: id },
1377
2152
  );
1378
2153
  }
1379
2154
 
@@ -1381,17 +2156,33 @@ Terse command-style prompts produce shallow, generic work.
1381
2156
  let spinnerFrame = 0;
1382
2157
  const startedAt = Date.now();
1383
2158
  let fgId: string | undefined;
2159
+ // Set only while the spawn is parked on a foreground concurrency slot
2160
+ // (maxConcurrentForeground); undefined the rest of the time, including
2161
+ // always when the limit is unset.
2162
+ let queuedAhead: number | undefined;
1384
2163
 
1385
2164
  const streamUpdate = () => {
2165
+ // Spend from the record, everything else from the live tracker. `fgId`
2166
+ // is set in onSessionCreated below, which fires before the first
2167
+ // assistant message — so nothing is spent while this reads zero.
2168
+ const fgRecord = fgId ? manager.getRecord(fgId) : undefined;
1386
2169
  const details: AgentDetails = {
1387
- ...detailBase,
2170
+ ...detailBaseFor(fgRecord),
1388
2171
  toolUses: fgState.toolUses,
1389
- tokens: formatLifetimeTokens(fgState),
2172
+ tokens: fgRecord ? formatLifetimeTokens(fgRecord) : "",
2173
+ cost: fgRecord ? getLifetimeCost(fgRecord.lifetimeUsage) : 0,
1390
2174
  turnCount: fgState.turnCount,
1391
2175
  maxTurns: fgState.maxTurns,
1392
2176
  durationMs: Date.now() - startedAt,
2177
+ // Deliberately still "running" while queued: the renderer routes any
2178
+ // status it doesn't know to raw text (see the catch-all below), which
2179
+ // would drop the spinner and read as hung. Only the activity line
2180
+ // changes — "thinking…" would be a lie for an agent that has not
2181
+ // started and may not for minutes.
1393
2182
  status: "running",
1394
- activity: describeActivity(fgState.activeTools, fgState.responseText),
2183
+ activity: queuedAhead === undefined
2184
+ ? describeActivity(fgState.activeTools, fgState.responseText)
2185
+ : `queued — waiting for a foreground slot${queuedAhead > 0 ? ` (${queuedAhead} ahead)` : ""}`,
1395
2186
  spinnerFrame: spinnerFrame % SPINNER.length,
1396
2187
  };
1397
2188
  onUpdate?.({
@@ -1408,12 +2199,20 @@ Terse command-style prompts produce shallow, generic work.
1408
2199
  const origOnSession = fgCallbacks.onSessionCreated;
1409
2200
  fgCallbacks.onSessionCreated = (session: any) => {
1410
2201
  origOnSession(session);
2202
+ // It really started — stop reporting it as queued, and repaint now
2203
+ // rather than leaving the stale line up for the next spinner tick.
2204
+ // Guarded, so a spawn that never queued emits no extra update.
2205
+ if (queuedAhead !== undefined) {
2206
+ queuedAhead = undefined;
2207
+ streamUpdate();
2208
+ }
1411
2209
  for (const a of manager.listAgents()) {
1412
2210
  if (a.session === session) {
1413
2211
  fgId = a.id;
1414
2212
  agentActivity.set(a.id, fgState);
1415
2213
  widget.ensureTimer();
1416
- widget.update();
2214
+ fleet.ensureTimer();
2215
+ fleet.update();
1417
2216
  break;
1418
2217
  }
1419
2218
  }
@@ -1421,7 +2220,7 @@ Terse command-style prompts produce shallow, generic work.
1421
2220
  if (fgId) {
1422
2221
  const rec = manager.getRecord(fgId);
1423
2222
  if (rec?.outputFile) {
1424
- rec.outputCleanup = streamToOutputFile(session, rec.outputFile, fgId, ctx.cwd, rec.historyFile);
2223
+ rec.outputCleanup = streamToOutputFile(session, rec.outputFile, fgId, ctx.cwd, undefined);
1425
2224
  }
1426
2225
  }
1427
2226
  };
@@ -1438,6 +2237,7 @@ Terse command-style prompts produce shallow, generic work.
1438
2237
  try {
1439
2238
  const fgResult = await manager.spawnAndWait(pi, ctx, subagentType, params.prompt, {
1440
2239
  description: params.description,
2240
+ name: params.name as string | undefined,
1441
2241
  model,
1442
2242
  maxTurns: effectiveMaxTurns,
1443
2243
  isolated,
@@ -1445,7 +2245,13 @@ Terse command-style prompts produce shallow, generic work.
1445
2245
  thinkingLevel: thinking,
1446
2246
  isolation,
1447
2247
  invocation: agentInvocation,
2248
+ outputTranscript,
1448
2249
  signal,
2250
+ rootSessionId: ctx.sessionManager.getSessionId(),
2251
+ // Deliberately does NOT set fgId: that drives agentActivity, the
2252
+ // widget and the `finally` cleanup below, none of which should see an
2253
+ // agent that has no session and may never get one.
2254
+ onQueued: (_id, ahead) => { queuedAhead = ahead; streamUpdate(); },
1449
2255
  ...fgCallbacks,
1450
2256
  }, (fgAgentId) => {
1451
2257
  // onSpawned: called synchronously after spawn, before onSessionCreated fires.
@@ -1454,29 +2260,23 @@ Terse command-style prompts produce shallow, generic work.
1454
2260
  attachTranscript(fgRec, fgAgentId);
1455
2261
  });
1456
2262
  record = fgResult.record;
1457
- } catch (err) {
2263
+ } finally {
2264
+ // Runs on both paths, so a startup throw — which now propagates, see
2265
+ // the background spawn above (#179) — no longer leaves the spinner
2266
+ // ticking or a finished agent on the widget.
1458
2267
  clearInterval(spinnerInterval);
1459
- return textResult(err instanceof Error ? err.message : String(err));
1460
- }
1461
-
1462
- clearInterval(spinnerInterval);
1463
-
1464
- // Clean up foreground agent from widget
1465
- if (fgId) {
1466
- agentActivity.delete(fgId);
1467
- widget.markFinished(fgId);
2268
+ if (fgId) {
2269
+ agentActivity.delete(fgId);
2270
+ widget.markFinished(fgId);
2271
+ fleet.onAgentFinished(fgId);
2272
+ }
1468
2273
  }
1469
2274
 
1470
- // Get final token count
1471
- const tokenText = formatLifetimeTokens(fgState);
2275
+ // Get final token count — from the record, like the cost below it, so the
2276
+ // two describe the same work when the agent delegated to nested children.
2277
+ const tokenText = formatLifetimeTokens(record);
1472
2278
 
1473
- const details = buildDetails(detailBase, record, fgState, { tokens: tokenText });
1474
-
1475
- // "general-purpose" may itself be unregistered (defaults disabled, no
1476
- // user override) — getConfig then uses the hardcoded fallback config.
1477
- const fallbackNote = fellBack
1478
- ? `Note: Unknown agent type "${rawType}" — using ${resolveType("general-purpose") ? "general-purpose" : "the fallback agent config"}.\n\n`
1479
- : "";
2279
+ const details = buildDetails(detailBaseFor(record), record, fgState, { tokens: tokenText });
1480
2280
 
1481
2281
  if (record.status === "error") {
1482
2282
  // Error headline + any partial output the run produced before failing.
@@ -1486,25 +2286,488 @@ Terse command-style prompts produce shallow, generic work.
1486
2286
  const durationMs = (record.completedAt ?? Date.now()) - record.startedAt;
1487
2287
  const statsParts = [`${record.toolUses} tool uses`];
1488
2288
  if (tokenText) statsParts.push(tokenText);
2289
+ if (showCost) {
2290
+ const costText = formatCost(getLifetimeCost(record.lifetimeUsage));
2291
+ if (costText) statsParts.push(costText);
2292
+ }
1489
2293
  return textResult(
1490
- `${fallbackNote}Agent completed in ${formatMs(durationMs)} (${statsParts.join(", ")})${getStatusNote(record.status)}.\n\n` +
2294
+ `${fallbackNote}Agent completed in ${formatMs(durationMs)} (${statsParts.join(", ")})${getForegroundOutcomeNote(record.status)}.\n\n` +
1491
2295
  (record.result?.trim() || "No output."),
1492
2296
  details,
1493
2297
  );
1494
2298
  },
1495
- }));
2299
+ });
2300
+ /**
2301
+ * Wrap a tool so its results carry back whatever subagent spend the parent
2302
+ * session has not been told about yet (see `PendingUsagePool`).
2303
+ *
2304
+ * Pi copies `AgentToolResult.usage` onto the persisted tool-result message and
2305
+ * folds it into `getSessionStats()`, which is what the footer, the statusline
2306
+ * and `/cost` read — so this is the whole of "report usage to the parent".
2307
+ *
2308
+ * Nothing is attached to a call with no tool-call id. That is the `@handle`
2309
+ * mention path (`mention-clone.ts`), which invokes this tool from a fork of the
2310
+ * conversation that is discarded moments later: the result never becomes a
2311
+ * message in the real session, so usage hung on it would be spend the user paid
2312
+ * for and nobody counted. Skipping leaves it pending for the next real result.
2313
+ */
2314
+ function withUsageReporting<T extends { execute: (...args: any[]) => any }>(tool: T): T {
2315
+ return {
2316
+ ...tool,
2317
+ execute: async (toolCallId: string | undefined, ...rest: any[]) => {
2318
+ const result = await tool.execute(toolCallId, ...rest);
2319
+ if (!reportUsage || !toolCallId) return result;
2320
+ const usage = pendingUsage.drain();
2321
+ return usage ? { ...result, usage } : result;
2322
+ },
2323
+ };
2324
+ }
2325
+ function registerToolReportingUsage(tool: any): void {
2326
+ pi.registerTool(withUsageReporting(tool));
2327
+ }
2328
+
2329
+ // The mention path is handed THIS object, not the bare `agentTool` — see the
2330
+ // mention-clone header on why the clone must call the registered tool.
2331
+ const registeredAgentTool = withUsageReporting(agentTool);
2332
+ pi.registerTool(registeredAgentTool);
2333
+
2334
+ // ---- Workflow tool ----
2335
+
2336
+ /**
2337
+ * Live runs, by task id. The tool returns before the run finishes, so its
2338
+ * result card looks the task up here on every render rather than freezing a
2339
+ * snapshot into `details` — that is what makes the inline card follow a
2340
+ * background run.
2341
+ */
2342
+ const workflowTasks = new Map<string, WorkflowTask>();
2343
+
2344
+ /**
2345
+ * Workflow runs as the fleet list wants them.
2346
+ *
2347
+ * Mapped here rather than handing `WorkflowTask` over the seam: the list is
2348
+ * deliberately ignorant of the workflow engine, and a run's counters live in
2349
+ * the progress log rather than on the record, so they are derived per call
2350
+ * the same way the card derives them.
2351
+ */
2352
+ function fleetWorkflows(): FleetWorkflow[] {
2353
+ // Cached counters only, no derivation: the fleet list calls this on a
2354
+ // 200ms tick and reads the roster several times per update, so walking a
2355
+ // run's progress log here would put O(log) work in the render loop.
2356
+ return [...workflowTasks.values()].map(task => ({
2357
+ id: task.id,
2358
+ name: task.meta?.name ?? task.workflowName ?? task.id,
2359
+ status: task.status,
2360
+ doneCount: task.doneCount,
2361
+ totalCount: task.agentCount,
2362
+ startedAt: task.startTime,
2363
+ ...(task.endTime !== undefined ? { completedAt: task.endTime } : {}),
2364
+ tokens: task.totalTokens,
2365
+ }));
2366
+ }
2367
+
2368
+ /**
2369
+ * Run a task to completion against the real manager, settling the record
2370
+ * either way. Never rejects: a run that cannot start (bad `meta`, oversized
2371
+ * source, non-JSON `args`) is a failed workflow, and both callers here are
2372
+ * detached — a rejection would surface as an unhandled one.
2373
+ */
2374
+ async function runWorkflowTask(ctx: ExtensionContext, task: WorkflowTask): Promise<void> {
2375
+ try {
2376
+ const result = await runWorkflow({
2377
+ script: task.script,
2378
+ args: task.args,
2379
+ signal: task.abortController.signal,
2380
+ host: createWorkflowHost({
2381
+ pi,
2382
+ ctx,
2383
+ manager,
2384
+ signal: task.abortController.signal,
2385
+ rootSessionId: ctx.sessionManager.getSessionId(),
2386
+ workflowId: task.id,
2387
+ }),
2388
+ onProgress: entries => updateWorkflowProgressBatch(task, entries),
2389
+ // The dialog's pause / skip / retry keys run through this; it is dropped
2390
+ // again when the task settles.
2391
+ onControl: control => { task.control = control; },
2392
+ journal: {
2393
+ ...(task.replay !== undefined ? { entries: task.replay } : {}),
2394
+ ...(task.journalPath !== undefined
2395
+ ? { append: (entry: WorkflowJournalEntry) => appendJournal(task.journalPath!, entry) }
2396
+ : {}),
2397
+ },
2398
+ });
2399
+ completeWorkflowTask(task, result);
2400
+ } catch (err) {
2401
+ failWorkflowTask(task, err instanceof Error ? err.message : String(err));
2402
+ }
2403
+ }
2404
+
2405
+ /**
2406
+ * Hand a finished run back to the model through the SAME channel a background
2407
+ * agent uses — held briefly by `scheduleNudge`, delivered as a follow-up that
2408
+ * triggers a turn, rendered by the existing `subagent-notification` renderer.
2409
+ */
2410
+ function notifyWorkflowFinished(task: WorkflowTask) {
2411
+ widget.update();
2412
+ fleet.update();
2413
+ const result = workflowResultText(task);
2414
+ scheduleNudge(task.id, () => {
2415
+ pi.sendMessage<NotificationDetails>({
2416
+ customType: "subagent-notification",
2417
+ content: formatWorkflowNotification(task),
2418
+ display: true,
2419
+ details: {
2420
+ id: task.id,
2421
+ description: `Workflow ${task.workflowName ?? task.id}`,
2422
+ status: task.status === "completed" ? "completed" : task.status === "killed" ? "stopped" : "error",
2423
+ toolUses: task.totalToolCalls,
2424
+ // A workflow has agents, not turns; rendering "↻0" would be noise.
2425
+ turnCount: 0,
2426
+ totalTokens: task.totalTokens,
2427
+ durationMs: elapsedMs(task, Date.now()),
2428
+ error: task.error,
2429
+ resultPreview: result.length > 500 ? `${result.slice(0, 500)}…` : result,
2430
+ },
2431
+ }, { deliverAs: "followUp", triggerTurn: true });
2432
+ });
2433
+ }
2434
+
2435
+ // Defined unconditionally, registered only when the feature is on — the same
2436
+ // shape the Agent tool uses. Keeping the definition out of the `if` means the
2437
+ // switch changes exactly one thing: whether pi is ever told about the tool.
2438
+ const workflowTool = defineTool({
2439
+ name: SUBAGENT_TOOL_NAMES.WORKFLOW,
2440
+ label: "SubagentWorkflow",
2441
+ description: renderToolDescriptionTemplate(fullWorkflowToolDescription),
2442
+ promptSnippet: "Run a deterministic script that orchestrates many subagents",
2443
+ promptGuidelines: [
2444
+ "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.",
2445
+ "Prefer `pipeline` over `parallel` — a barrier costs wall-clock whenever the stages are unevenly sized.",
2446
+ "A workflow runs in the background and notifies you when it finishes — do not poll or sleep waiting for it.",
2447
+ ],
2448
+ parameters: Type.Object({
2449
+ script: Type.Optional(
2450
+ Type.String({
2451
+ maxLength: 524288,
2452
+ description: "Inline workflow source. Must begin with `export const meta = { name, description }`.",
2453
+ }),
2454
+ ),
2455
+ scriptPath: Type.Optional(
2456
+ Type.String({
2457
+ description:
2458
+ "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.",
2459
+ }),
2460
+ ),
2461
+ name: Type.Optional(
2462
+ Type.String({
2463
+ description:
2464
+ "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.",
2465
+ }),
2466
+ ),
2467
+ args: Type.Optional(
2468
+ Type.Any({
2469
+ description: "Exposed to the script as the global `args`, verbatim. Must be JSON-shaped.",
2470
+ }),
2471
+ ),
2472
+ resumeFromRunId: Type.Optional(
2473
+ Type.String({
2474
+ pattern: "^wf_[a-z0-9-]{6,}$",
2475
+ description:
2476
+ "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.",
2477
+ }),
2478
+ ),
2479
+ // Accepted and ignored, as in Claude Code. Models reach for them because
2480
+ // every other tool has them, and a hard schema rejection would cost a
2481
+ // whole turn to re-emit a script that was already correct. The `meta`
2482
+ // block is the one place a workflow is named.
2483
+ title: Type.Optional(
2484
+ Type.String({ description: "Ignored — set the workflow title in the script's `meta` block." }),
2485
+ ),
2486
+ description: Type.Optional(
2487
+ Type.String({ description: "Ignored — set the workflow description in the script's `meta` block." }),
2488
+ ),
2489
+ }),
2490
+
2491
+ renderCall(args, theme) {
2492
+ return new Text(
2493
+ `${theme.fg("toolTitle", "▸ ")}${theme.bold(theme.fg("toolTitle", "SubagentWorkflow"))} ${theme.fg("muted", workflowCallName(args))}`,
2494
+ 0,
2495
+ 0,
2496
+ );
2497
+ },
2498
+
2499
+ renderResult(result, _options, theme, renderContext) {
2500
+ const text = result.content[0]?.type === "text" ? result.content[0].text : "";
2501
+ const taskId = (result.details as { taskId?: string } | undefined)?.taskId;
2502
+ const task = taskId !== undefined ? workflowTasks.get(taskId) : undefined;
2503
+ // No task means the run predates this session (a reloaded transcript) or
2504
+ // the call never started one — show what `execute` said instead.
2505
+ if (renderContext.isError || !task) return new Text(text, 0, 0);
2506
+ return renderWorkflowCard(
2507
+ {
2508
+ progress: task.workflowProgress,
2509
+ task: {
2510
+ status: task.status,
2511
+ workflowName: task.workflowName,
2512
+ startTime: task.startTime,
2513
+ endTime: task.endTime,
2514
+ totalPausedMs: task.totalPausedMs,
2515
+ },
2516
+ meta: task.meta,
2517
+ agentCount: task.agentCount,
2518
+ totalTokens: task.totalTokens,
2519
+ },
2520
+ theme,
2521
+ );
2522
+ },
2523
+
2524
+ execute: async (toolCallId, params, _signal, _onUpdate, ctx) => {
2525
+ const resumeFrom = resolveResumeTarget(params.resumeFromRunId, workflowTasks);
2526
+ if (resumeFrom !== undefined && !resumeFrom.ok) return textResult(resumeFrom.message);
2527
+
2528
+ // A resume with no source of its own re-runs what that run ran. The
2529
+ // common case is an edited script, but "run that again, cheaply" should
2530
+ // not require repeating a path the run already knows.
2531
+ const resolved = resolveWorkflowScript(
2532
+ params.script === undefined && params.scriptPath === undefined && params.name === undefined
2533
+ && resumeFrom !== undefined
2534
+ ? { scriptPath: resumeFrom.scriptPath }
2535
+ : params,
2536
+ ctx.cwd,
2537
+ );
2538
+ if (!resolved.ok) return textResult(resolved.message);
2539
+
2540
+ // Parsed before anything is scheduled: a bad `meta` is an authoring error
2541
+ // the model can fix immediately, and reporting it as a background run
2542
+ // that failed a second later would just cost a turn.
2543
+ let meta: WorkflowMeta;
2544
+ try {
2545
+ meta = extractMeta(resolved.script).meta;
2546
+ } catch (err) {
2547
+ return textResult(err instanceof Error ? err.message : String(err));
2548
+ }
2549
+
2550
+ const runId = workflowRunId();
2551
+ // Every invocation lands on disk next to the agent transcripts, so
2552
+ // iterating is edit-the-file-then-rerun-with-scriptPath rather than
2553
+ // re-emitting the whole source. The journal sits beside it under the same
2554
+ // id, which is what makes a run id enough to resume from.
2555
+ let savedPath: string | undefined;
2556
+ let journalPath: string | undefined;
2557
+ try {
2558
+ const dir = sessionTaskDir(ctx.cwd, ctx.sessionManager.getSessionId());
2559
+ savedPath = join(dir, `${runId}.workflow.js`);
2560
+ writeFileSync(savedPath, resolved.script, "utf-8");
2561
+ journalPath = join(dir, `${runId}.workflow.jsonl`);
2562
+ } catch (err) {
2563
+ savedPath = undefined;
2564
+ journalPath = undefined;
2565
+ console.warn(`[pi-subagents] could not persist workflow script: ${err instanceof Error ? err.message : String(err)}`);
2566
+ }
2567
+
2568
+ const replay = resumeFrom !== undefined ? readJournal(resumeFrom.journalPath) : undefined;
2569
+
2570
+ const task = createWorkflowTask({
2571
+ id: runId,
2572
+ script: resolved.script,
2573
+ scriptPath: resolved.scriptPath ?? savedPath,
2574
+ args: params.args,
2575
+ meta,
2576
+ toolCallId,
2577
+ ...(journalPath !== undefined ? { journalPath } : {}),
2578
+ ...(replay !== undefined && replay.length > 0 ? { replay, resumedFrom: resumeFrom!.runId } : {}),
2579
+ });
2580
+ workflowTasks.set(runId, task);
2581
+ // The run's own row has to appear now, not when it settles. Its agents
2582
+ // are owned by it, so their lifecycle callbacks no longer refresh these
2583
+ // surfaces — nothing else would register the widget for a run whose
2584
+ // first agent has not started yet.
2585
+ widget.update();
2586
+ fleet.update();
2587
+
2588
+ // Background, like Claude Code: the id comes back now and the run keeps
2589
+ // going without the tool call.
2590
+ void runWorkflowTask(ctx, task).then(() => notifyWorkflowFinished(task));
2591
+
2592
+ return {
2593
+ content: [{
2594
+ type: "text" as const,
2595
+ text:
2596
+ `Workflow "${meta.name}" started in the background.\n` +
2597
+ `Task ID: ${runId}\n` +
2598
+ (task.scriptPath ? `Script: ${task.scriptPath}\n` : "") +
2599
+ (task.resumedFrom !== undefined
2600
+ ? `Resuming ${task.resumedFrom}: ${task.replay?.length ?? 0} recorded call(s) available to replay.\n`
2601
+ : params.resumeFromRunId !== undefined
2602
+ ? `Nothing to replay from ${params.resumeFromRunId} — every agent runs live.\n`
2603
+ : "") +
2604
+ `\nYou will be notified when it finishes — do NOT poll or sleep waiting for it.\n` +
2605
+ `To iterate, edit the script file and call SubagentWorkflow again with scriptPath.`,
2606
+ }],
2607
+ details: { taskId: runId },
2608
+ };
2609
+ },
2610
+ });
2611
+
2612
+ if (isWorkflowsEnabled()) pi.registerTool(workflowTool);
2613
+
2614
+ /**
2615
+ * Act on {@link decideWorkflowCollision} — the half that needs the host.
2616
+ *
2617
+ * The policy (what counts as a conflict, what a pin changes, whether there is
2618
+ * anything left to withdraw) lives in `workflow/collisions.ts`; this is the
2619
+ * host-facing shell around it: read the registry, warn, and take our tool out
2620
+ * of the active set.
2621
+ *
2622
+ * ## Why this can only happen at session_start
2623
+ *
2624
+ * `getAllTools` throws during extension loading ("Action methods cannot be
2625
+ * called during extension loading"), and load order means a check at
2626
+ * registration time could not see an extension that has not loaded yet. So
2627
+ * the decision cannot gate `registerTool`; it has to undo it. `setActiveTools`
2628
+ * is what makes that real rather than cosmetic — pi rebuilds the system
2629
+ * prompt from the new set, and `session_start` runs before any turn, so the
2630
+ * model never sees a spec we withdrew. A later `_refreshToolRegistry` keeps
2631
+ * the active set it had and only adds names new to the registry, so ours does
2632
+ * not creep back.
2633
+ *
2634
+ * Best-effort and swallowed. A diagnostic that took the session down would be
2635
+ * worse than the collision it reports.
2636
+ */
2637
+ let collisionsChecked = false;
2638
+ function resolveWorkflowCollisions(ctx: ExtensionContext): void {
2639
+ if (collisionsChecked) return;
2640
+ collisionsChecked = true;
2641
+
2642
+ const warn = (message: string) => {
2643
+ if (ctx.hasUI) ctx.ui.notify(message, "warning");
2644
+ else console.warn(`[pi-subagents] ${message}`);
2645
+ };
2646
+
2647
+ try {
2648
+ if (!isWorkflowsEnabled()) return;
2649
+
2650
+ const verdict = decideWorkflowCollision({
2651
+ tools: pi.getAllTools(),
2652
+ // Identifies our own registration: this extension does not know its
2653
+ // install path, and the description is the one field certainly ours.
2654
+ ownDescription: workflowTool.description,
2655
+ pinned: isWorkflowsPinned(),
2656
+ });
2657
+ if (verdict.kind === "none") return;
2658
+ if (verdict.kind === "report") {
2659
+ warn(verdict.message);
2660
+ return;
2661
+ }
2662
+
2663
+ workflowsEnabled = false; // not setWorkflowsEnabled: this is not the user pinning it
2664
+ widget.update();
2665
+ fleet.update();
2666
+ warn(verdict.message);
2667
+
2668
+ if (!verdict.withdraw) return;
2669
+ const active = pi.getActiveTools();
2670
+ if (active.includes(SUBAGENT_TOOL_NAMES.WORKFLOW)) {
2671
+ pi.setActiveTools(active.filter(name => name !== SUBAGENT_TOOL_NAMES.WORKFLOW));
2672
+ }
2673
+ } catch {
2674
+ // getAllTools/setActiveTools are unavailable in some hosts (print mode,
2675
+ // RPC). Not being able to check is not a reason to fail the session.
2676
+ }
2677
+ }
2678
+
2679
+ /**
2680
+ * `--subagents-workflow-file=<path>` — run a script at startup, with no LLM
2681
+ * round-trip deciding whether to call the tool.
2682
+ *
2683
+ * Read here rather than at activation because that is the only place the real
2684
+ * value exists: the host activates extensions first and applies collected CLI
2685
+ * flags second, so `getFlag` during activation returns the registered default
2686
+ * and nothing else. `examples/extensions/ssh.ts` reads its flag from
2687
+ * session_start for exactly this reason.
2688
+ */
2689
+ let workflowFlagHandled = false;
2690
+ function runWorkflowFlag(ctx: ExtensionContext): void {
2691
+ if (workflowFlagHandled) return;
2692
+ const flag = typeof pi.getFlag === "function" ? pi.getFlag(WORKFLOW_FILE_FLAG) : undefined;
2693
+ if (flag === undefined || flag === false) return;
2694
+ workflowFlagHandled = true;
2695
+
2696
+ const report = (message: string, level: "info" | "warning") => {
2697
+ if (ctx.hasUI) ctx.ui.notify(message, level);
2698
+ else console.warn(`[pi-subagents] ${message}`);
2699
+ };
2700
+
2701
+ // The flag is the same machinery by another door, so the master switch has
2702
+ // to close it too — silently ignoring a flag the user typed would be worse
2703
+ // than saying why nothing ran.
2704
+ if (!isWorkflowsEnabled()) {
2705
+ report(
2706
+ `--${WORKFLOW_FILE_FLAG} ignored: workflows are off. Turn them on in /agents → Settings → Workflows, ` +
2707
+ 'or set `"workflowsEnabled": true` in .pi/subagents.json.',
2708
+ "warning",
2709
+ );
2710
+ return;
2711
+ }
2712
+
2713
+ // A bare `--subagents-workflow-file` parses to boolean `true`. Say what was
2714
+ // missing rather than reading a file called "true".
2715
+ if (typeof flag !== "string" || flag.trim() === "") {
2716
+ report(`--${WORKFLOW_FILE_FLAG} needs a path: --${WORKFLOW_FILE_FLAG}=<path>`, "warning");
2717
+ return;
2718
+ }
2719
+
2720
+ const path = isAbsolute(flag.trim()) ? flag.trim() : join(ctx.cwd, flag.trim());
2721
+ let script: string;
2722
+ try {
2723
+ script = readFileSync(path, "utf-8");
2724
+ } catch (err) {
2725
+ report(`Could not read ${path}: ${err instanceof Error ? err.message : String(err)}`, "warning");
2726
+ return;
2727
+ }
2728
+
2729
+ let meta: WorkflowMeta | undefined;
2730
+ try {
2731
+ meta = extractMeta(script).meta;
2732
+ } catch (err) {
2733
+ report(err instanceof Error ? err.message : String(err), "warning");
2734
+ return;
2735
+ }
2736
+
2737
+ const task = createWorkflowTask({ id: workflowRunId(), script, scriptPath: path, meta });
2738
+ workflowTasks.set(task.id, task);
2739
+ widget.update();
2740
+ fleet.update();
2741
+ report(`Running workflow ${meta.name}…`, "info");
2742
+
2743
+ // Detached: session_start is awaited by the host, and a workflow can run for
2744
+ // minutes — blocking here would hold the whole session's startup.
2745
+ void runWorkflowTask(ctx, task).then(() => {
2746
+ // No tool call to attach a result card to, so the card becomes a session
2747
+ // entry (same layout), and the outcome is handed to the model as context
2748
+ // for its next turn rather than forcing one.
2749
+ pi.appendEntry<WorkflowEntryData>(WORKFLOW_ENTRY_TYPE, workflowEntryData(task));
2750
+ pi.sendMessage({
2751
+ customType: "workflow-result",
2752
+ content: formatWorkflowNotification(task),
2753
+ display: false,
2754
+ }, { deliverAs: "nextTurn" });
2755
+ widget.update();
2756
+ fleet.update();
2757
+ });
2758
+ }
1496
2759
 
1497
2760
  // ---- get_subagent_result tool ----
1498
2761
 
1499
- pi.registerTool(defineTool({
2762
+ registerToolReportingUsage(defineTool({
1500
2763
  name: SUBAGENT_TOOL_NAMES.GET_RESULT,
1501
2764
  label: "Get Agent Result",
1502
2765
  description:
1503
- "Check status and retrieve results from a background agent. Use the agent ID returned by Agent with run_in_background.",
2766
+ "Check status and retrieve a background agent's full result — its completion notification carries only a preview. Use the agent ID returned by Agent.",
1504
2767
  promptSnippet: "Check status and retrieve results from a background agent",
1505
2768
  parameters: Type.Object({
1506
2769
  agent_id: Type.String({
1507
- description: "The agent ID to check.",
2770
+ 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`).",
1508
2771
  }),
1509
2772
  wait: Type.Optional(
1510
2773
  Type.Boolean({
@@ -1518,8 +2781,8 @@ Terse command-style prompts produce shallow, generic work.
1518
2781
  ),
1519
2782
  }),
1520
2783
  execute: async (_toolCallId, params, signal, _onUpdate, _ctx) => {
1521
- const record = manager.getRecord(params.agent_id);
1522
- if (!record) {
2784
+ const record = resolveAgentRef(params.agent_id);
2785
+ if (!record || !isTopLevelAgent(record)) {
1523
2786
  return textResult(`Agent not found: "${params.agent_id}". It may have been cleaned up.`);
1524
2787
  }
1525
2788
 
@@ -1538,15 +2801,16 @@ Terse command-style prompts produce shallow, generic work.
1538
2801
  if (record.promise) await abortable(record.promise, signal);
1539
2802
  }
1540
2803
 
1541
- const durableResult = !record.result?.trim() && record.transcriptPath && currentCtx?.cwd
1542
- ? readAgentHistoryResult(currentCtx.cwd, record.transcriptPath)
1543
- : undefined;
1544
2804
  const displayName = getDisplayName(record.type);
1545
2805
  const duration = formatDuration(record.startedAt, record.completedAt);
1546
2806
  const tokens = formatLifetimeTokens(record);
1547
2807
  const contextPercent = getSessionContextPercent(record.session);
1548
2808
  const statsParts = [`Tool uses: ${record.toolUses}`];
1549
2809
  if (tokens) statsParts.push(tokens);
2810
+ if (showCost) {
2811
+ const costText = formatCost(getLifetimeCost(record.lifetimeUsage));
2812
+ if (costText) statsParts.push(`Cost: ${costText}`);
2813
+ }
1550
2814
  if (contextPercent !== null) statsParts.push(`Context: ${Math.round(contextPercent)}%`);
1551
2815
  if (record.compactionCount) statsParts.push(`Compactions: ${record.compactionCount}`);
1552
2816
  statsParts.push(`Duration: ${duration}`);
@@ -1559,9 +2823,9 @@ Terse command-style prompts produce shallow, generic work.
1559
2823
  if (record.status === "running") {
1560
2824
  output += "Agent is still running. Use wait: true or check back later.";
1561
2825
  } else if (record.status === "error") {
1562
- output += `Error: ${record.error}${partialOutputSuffix(record, durableResult)}`;
2826
+ output += `Error: ${record.error}${partialOutputSuffix(record)}`;
1563
2827
  } else {
1564
- output += durableResult || record.result?.trim() || "No output.";
2828
+ output += record.result?.trim() || "No output.";
1565
2829
  }
1566
2830
 
1567
2831
  // Mark result as consumed — suppresses the completion notification
@@ -1584,7 +2848,7 @@ Terse command-style prompts produce shallow, generic work.
1584
2848
 
1585
2849
  // ---- steer_subagent tool ----
1586
2850
 
1587
- pi.registerTool(defineTool({
2851
+ registerToolReportingUsage(defineTool({
1588
2852
  name: SUBAGENT_TOOL_NAMES.STEER,
1589
2853
  label: "Steer Agent",
1590
2854
  description:
@@ -1593,15 +2857,15 @@ Terse command-style prompts produce shallow, generic work.
1593
2857
  promptSnippet: "Send a steering message to redirect a running background agent",
1594
2858
  parameters: Type.Object({
1595
2859
  agent_id: Type.String({
1596
- description: "The agent ID to steer (must be currently running).",
2860
+ description: "The agent ID to steer (must be currently running). The agent's handle also works — its `name` if you gave it one, otherwise its type (`explore`, `explore-2`).",
1597
2861
  }),
1598
2862
  message: Type.String({
1599
2863
  description: "The steering message to send. This will appear as a user message in the agent's conversation.",
1600
2864
  }),
1601
2865
  }),
1602
2866
  execute: async (_toolCallId, params, _signal, _onUpdate, _ctx) => {
1603
- const record = manager.getRecord(params.agent_id);
1604
- if (!record) {
2867
+ const record = resolveAgentRef(params.agent_id);
2868
+ if (!record || !isTopLevelAgent(record)) {
1605
2869
  return textResult(`Agent not found: "${params.agent_id}". It may have been cleaned up.`);
1606
2870
  }
1607
2871
  if (record.status !== "running") {
@@ -1622,6 +2886,10 @@ Terse command-style prompts produce shallow, generic work.
1622
2886
  const contextPercent = getSessionContextPercent(record.session);
1623
2887
  const stateParts: string[] = [];
1624
2888
  if (tokens) stateParts.push(tokens);
2889
+ if (showCost) {
2890
+ const costText = formatCost(getLifetimeCost(record.lifetimeUsage));
2891
+ if (costText) stateParts.push(costText);
2892
+ }
1625
2893
  stateParts.push(`${record.toolUses} tool ${record.toolUses === 1 ? "use" : "uses"}`);
1626
2894
  if (contextPercent !== null) stateParts.push(`context ${Math.round(contextPercent)}% full`);
1627
2895
  if (record.compactionCount) stateParts.push(`${record.compactionCount} compaction${record.compactionCount === 1 ? "" : "s"}`);
@@ -1637,20 +2905,9 @@ Terse command-style prompts produce shallow, generic work.
1637
2905
 
1638
2906
  // ---- /agents interactive menu ----
1639
2907
 
1640
- const projectAgentsDir = () => join(process.cwd(), ".pi", "agents");
1641
- const workspaceAgentsDir = () => join(process.cwd(), ".agents", "agents");
1642
- const personalAgentsDir = () => join(getAgentDir(), "agents");
1643
-
1644
- /** Find the file path of a custom agent by name, in discovery-precedence order (project, workspace, then global). */
1645
- function findAgentFile(name: string): { path: string; location: "project" | "workspace" | "personal" } | undefined {
1646
- const projectPath = join(projectAgentsDir(), `${name}.md`);
1647
- if (existsSync(projectPath)) return { path: projectPath, location: "project" };
1648
- const workspacePath = join(workspaceAgentsDir(), `${name}.md`);
1649
- if (existsSync(workspacePath)) return { path: workspacePath, location: "workspace" };
1650
- const personalPath = join(personalAgentsDir(), `${name}.md`);
1651
- if (existsSync(personalPath)) return { path: personalPath, location: "personal" };
1652
- return undefined;
1653
- }
2908
+ // Directory resolution and the frontmatter edits live in agent-file-toggle.ts
2909
+ // so they are reachable from tests — this command handler is only registered
2910
+ // through `registerCommand`, which every test mocks.
1654
2911
 
1655
2912
  function getModelLabel(type: string, registry?: ModelRegistry): string {
1656
2913
  const cfg = getAgentConfig(type);
@@ -1677,10 +2934,15 @@ Terse command-style prompts produce shallow, generic work.
1677
2934
  // Build select options
1678
2935
  const options: string[] = [];
1679
2936
 
1680
- // Keep active agents and terminal history in separate menu entries.
1681
- const records = manager.listAgents();
1682
- const { active, history } = splitAgentRecords(records, ctx.cwd);
1683
- options.push(...buildAgentStatusMenuEntries(records, ctx.cwd));
2937
+ // Keep active sessions and durable terminal history as separate menu rows.
2938
+ const agents = manager.listAgents().filter(isTopLevelAgent);
2939
+ const { active, history } = splitAgentRecords(agents, ctx.cwd);
2940
+ if (active.length > 0) {
2941
+ const running = active.filter(a => a.status === "running").length;
2942
+ const queued = active.filter(a => a.status === "queued").length;
2943
+ options.push(`Running agents (${active.length}) — ${running} running, ${queued} queued`);
2944
+ }
2945
+ if (history.length > 0) options.push(`Agent history (${history.length})`);
1684
2946
 
1685
2947
  // Agent types list
1686
2948
  if (allNames.length > 0) {
@@ -1693,11 +2955,17 @@ Terse command-style prompts produce shallow, generic work.
1693
2955
  options.push(`Scheduled jobs (${jobCount})`);
1694
2956
  }
1695
2957
 
2958
+ // Workflow runs, on the same terms as scheduled jobs: shown only when the
2959
+ // feature is on, so the menu never advertises something switched off.
2960
+ if (isWorkflowsEnabled()) {
2961
+ options.push(`Workflows (${workflowTasks.size})`);
2962
+ }
2963
+
1696
2964
  // Actions
1697
2965
  options.push("Create new agent");
1698
2966
  options.push("Settings");
1699
2967
 
1700
- const noAgentsMsg = allNames.length === 0 && active.length === 0 && history.length === 0
2968
+ const noAgentsMsg = allNames.length === 0 && agents.length === 0
1701
2969
  ? "No agents found. Create specialized subagents that can be delegated to.\n\n" +
1702
2970
  "Each subagent has its own context window, custom system prompt, and specific tools.\n\n" +
1703
2971
  "Try creating: Code Reviewer, Security Auditor, Test Writer, or Documentation Writer.\n\n"
@@ -1722,6 +2990,9 @@ Terse command-style prompts produce shallow, generic work.
1722
2990
  } else if (choice.startsWith("Scheduled jobs (")) {
1723
2991
  await showSchedulesMenu(ctx, scheduler);
1724
2992
  await showAgentsMenu(ctx);
2993
+ } else if (choice.startsWith("Workflows (")) {
2994
+ await showWorkflowsMenu(ctx, workflowMenuDeps);
2995
+ await showAgentsMenu(ctx);
1725
2996
  } else if (choice === "Create new agent") {
1726
2997
  await showCreateWizard(ctx);
1727
2998
  } else if (choice === "Settings") {
@@ -1798,164 +3069,78 @@ Terse command-style prompts produce shallow, generic work.
1798
3069
  }
1799
3070
  }
1800
3071
 
1801
- function makeUniqueAgentOptionLabels(pairs: Array<{ record: AgentRecord; label: string }>): string[] {
1802
- const counts = new Map<string, number>();
1803
- for (const pair of pairs) counts.set(pair.label, (counts.get(pair.label) ?? 0) + 1);
1804
- const used = new Set<string>();
1805
- return pairs.map((pair) => {
1806
- const { record, label } = pair;
1807
- if ((counts.get(label) ?? 0) === 1) {
1808
- used.add(label);
1809
- return label;
1810
- }
1811
- const suffix = ` · #${record.id.slice(-8)}`;
1812
- let candidate = `${label}${suffix}`;
1813
- let n = 2;
1814
- while (used.has(candidate)) candidate = `${label}${suffix}-${n++}`;
1815
- used.add(candidate);
1816
- pair.label = candidate;
1817
- return candidate;
1818
- });
1819
- }
1820
-
1821
- async function selectAgentFromReadOnlyList(
1822
- ctx: ExtensionCommandContext,
1823
- title: string,
1824
- pairs: Array<{ record: AgentRecord; label: string }>,
1825
- selection: AgentMenuSelection,
1826
- ): Promise<AgentRecord | undefined> {
1827
- const options = pairs.map(({ record, label }) => ({ value: record.id, label }));
1828
- const rememberedIndex = selection.id
1829
- ? pairs.findIndex(({ record }) => record.id === selection.id)
1830
- : -1;
1831
- const initialIndex = rememberedIndex >= 0
1832
- ? rememberedIndex
1833
- : Math.max(0, Math.min(selection.index, pairs.length - 1));
1834
-
1835
- const remember = (id: string) => {
1836
- const index = pairs.findIndex(({ record }) => record.id === id);
1837
- if (index >= 0) {
1838
- selection.id = id;
1839
- selection.index = index;
1840
- }
1841
- };
1842
-
1843
- const choice = await ctx.ui.custom<string | undefined>((_tui, _theme, _kb, done) => {
1844
- const list = new SelectList(
1845
- options,
1846
- Math.min(options.length, 10),
1847
- getSelectListTheme(),
1848
- );
1849
- list.setSelectedIndex(initialIndex);
1850
- const initialItem = options[initialIndex];
1851
- if (initialItem) remember(initialItem.value);
1852
- list.onSelectionChange = item => remember(item.value);
1853
- list.onSelect = item => {
1854
- remember(item.value);
1855
- done(item.value);
1856
- };
1857
- list.onCancel = () => done(undefined);
1858
-
1859
- const container = new Container();
1860
- container.addChild(new Text(title, 0, 0));
1861
- container.addChild(new Spacer(1));
1862
- container.addChild(list);
1863
- return {
1864
- render: (w: number) => container.render(w),
1865
- invalidate: () => container.invalidate(),
1866
- handleInput: (data: string) => list.handleInput(data),
1867
- };
1868
- });
1869
-
1870
- if (!choice) return undefined;
1871
- return pairs.find(({ record }) => record.id === choice)?.record;
1872
- }
1873
-
1874
3072
  async function showRunningAgents(ctx: ExtensionCommandContext) {
1875
- const { active: agents } = splitAgentRecords(manager.listAgents(), ctx.cwd);
3073
+ const agents = manager.listAgents().filter(record => isTopLevelAgent(record) && (record.status === "running" || record.status === "queued"));
1876
3074
  if (agents.length === 0) {
1877
3075
  ctx.ui.notify("No agents.", "info");
1878
3076
  return;
1879
3077
  }
1880
-
1881
- const pairs = agents.map((record) => {
1882
- const dn = getDisplayName(record.type);
1883
- const dur = formatDuration(record.startedAt, record.completedAt);
1884
- return { record, label: `${dn} (${record.description}) · ${record.toolUses} tools · ${record.status} · ${dur}` };
3078
+ const record = await ctx.ui.custom<AgentRecord | undefined>((_tui, _theme, _keys, done) => {
3079
+ let index = Math.min(runningSelectionIndex, agents.length - 1);
3080
+ return {
3081
+ render: (width: number) => agents.map((agent, row) => `${row === index ? "→" : " "} ${agent.description}`.slice(0, width)),
3082
+ invalidate() {},
3083
+ handleInput(data: string) {
3084
+ if (data === "\u001b[B") index = Math.min(agents.length - 1, index + 1);
3085
+ else if (data === "\u001b[A") index = Math.max(0, index - 1);
3086
+ else if (data === "\r" || data === "\n") { runningSelectionIndex = index; done(agents[index]); }
3087
+ else if (data === "\u001b") { runningSelectionIndex = index; done(undefined); }
3088
+ },
3089
+ };
1885
3090
  });
1886
- makeUniqueAgentOptionLabels(pairs);
1887
-
1888
- const record = await selectAgentFromReadOnlyList(ctx, "Running agents", pairs, runningAgentSelection);
1889
3091
  if (!record) return;
1890
-
1891
- await viewAgentConversation(ctx, record, "live");
1892
- // Back-navigation: re-show the list at the previously selected agent.
3092
+ await viewAgentConversation(ctx, record);
1893
3093
  await showRunningAgents(ctx);
1894
3094
  }
1895
3095
 
1896
- async function showAgentHistory(ctx: ExtensionCommandContext) {
1897
- const { history } = splitAgentRecords(manager.listAgents(), ctx.cwd);
1898
- if (history.length === 0) {
1899
- ctx.ui.notify("No agent history.", "info");
1900
- return;
3096
+ async function showAgentHistory(ctx: ExtensionCommandContext): Promise<void> {
3097
+ const history = manager.listAgents().filter(record => isTopLevelAgent(record) && canOpenAgentHistory(record, ctx.cwd));
3098
+ if (history.length === 0) return;
3099
+ const selected = await ctx.ui.custom<AgentRecord | undefined>((_tui, _theme, _keys, done) => {
3100
+ let index = Math.min(historySelectionIndex, history.length - 1);
3101
+ return {
3102
+ render: (width: number) => history.map((record, row) => `${row === index ? "→" : " "} ${record.description}`.slice(0, width)),
3103
+ invalidate() {},
3104
+ handleInput(data: string) {
3105
+ if (data === "\u001b[B") index = Math.min(history.length - 1, index + 1);
3106
+ else if (data === "\u001b[A") index = Math.max(0, index - 1);
3107
+ else if (data === "\r" || data === "\n") { historySelectionIndex = index; done(history[index]); }
3108
+ else if (data === "\u001b") done(undefined);
3109
+ },
3110
+ };
3111
+ });
3112
+ if (selected) {
3113
+ await viewAgentConversation(ctx, selected);
3114
+ await showAgentHistory(ctx);
1901
3115
  }
1902
-
1903
- const pairs = history.map((record) => ({ record, label: formatAgentHistoryOption(record, Date.now()) }));
1904
- makeUniqueAgentOptionLabels(pairs);
1905
- const record = await selectAgentFromReadOnlyList(ctx, "Agent history", pairs, historyAgentSelection);
1906
- if (!record) return;
1907
-
1908
- await viewAgentConversation(ctx, record, "history");
1909
- // Back-navigation: re-show the list at the previously selected agent.
1910
- await showAgentHistory(ctx);
1911
3116
  }
1912
3117
 
1913
- async function viewAgentConversation(
1914
- ctx: ExtensionCommandContext,
1915
- record: AgentRecord,
1916
- mode: "live" | "history",
1917
- ) {
1918
- if (mode === "live" && !canOpenActiveAgent(record)) {
1919
- ctx.ui.notify(`Agent is ${record.status === "queued" ? "queued" : "expired"} — no history available.`, "info");
1920
- return;
1921
- }
1922
- if (mode === "history" && !canOpenAgentHistory(record, ctx.cwd)) {
1923
- ctx.ui.notify("No agent history.", "info");
1924
- return;
1925
- }
1926
-
3118
+ async function viewAgentConversation(ctx: ExtensionCommandContext, record: AgentRecord) {
1927
3119
  const { ConversationViewer, VIEWPORT_HEIGHT_PCT, createStaticConversationSource } = await import("./ui/conversation-viewer.js");
1928
- const session = mode === "live"
1929
- ? record.session
1930
- : (() => {
1931
- const messages = record.transcriptPath
1932
- ? readAgentHistory(ctx.cwd, record.transcriptPath)
1933
- : undefined;
1934
- return messages
1935
- ? createStaticConversationSource(messages)
1936
- : record.session
1937
- ? createStaticConversationSource(record.session.messages)
1938
- : undefined;
1939
- })();
3120
+ const messages = record.transcriptPath ? readAgentHistory(ctx.cwd, record.transcriptPath) : undefined;
3121
+ const session = record.session ?? (messages ? createStaticConversationSource(messages) : undefined);
1940
3122
  if (!session) {
1941
- ctx.ui.notify("No agent history.", "info");
3123
+ ctx.ui.notify(`Agent is ${record.status === "queued" ? "queued" : "expired"} — no session available.`, "info");
1942
3124
  return;
1943
3125
  }
1944
-
3126
+ const isHistory = record.session === undefined;
1945
3127
  const activity = agentActivity.get(record.id);
1946
- const isLive = mode === "live";
3128
+
1947
3129
  await ctx.ui.custom<undefined>(
1948
- (tui, theme, keybindings, done) => {
1949
- return new ConversationViewer(tui, session, record, activity, theme, done,
1950
- isLive ? () => {
1951
- if (manager.abort(record.id)) {
1952
- ctx.ui.notify(`Stopped "${record.description}".`, "info");
1953
- }
1954
- } : undefined,
1955
- keybindings,
1956
- isLive ? (message: string) => manager.steer(record.id, message) : undefined,
1957
- mode === "history" ? { pi, ctx, readOnly: true } : { pi, ctx });
1958
- },
3130
+ (tui, theme, keybindings, done) => new ConversationViewer(
3131
+ tui,
3132
+ session,
3133
+ record,
3134
+ activity,
3135
+ theme,
3136
+ done,
3137
+ isHistory ? undefined : () => {
3138
+ if (manager.abort(record.id)) ctx.ui.notify(`Stopped "${record.description}".`, "info");
3139
+ },
3140
+ keybindings,
3141
+ isHistory ? undefined : (message: string) => manager.steer(record.id, message),
3142
+ { pi, ctx, readOnly: isHistory },
3143
+ ),
1959
3144
  {
1960
3145
  overlay: true,
1961
3146
  overlayOptions: { anchor: "center", width: "90%", maxHeight: `${VIEWPORT_HEIGHT_PCT}%` },
@@ -1970,7 +3155,7 @@ Terse command-style prompts produce shallow, generic work.
1970
3155
  return;
1971
3156
  }
1972
3157
 
1973
- const file = findAgentFile(name);
3158
+ const file = locateAgentFile(name, cfg.sourcePath);
1974
3159
  const isDefault = cfg.isDefault === true;
1975
3160
  const disabled = cfg.enabled === false;
1976
3161
 
@@ -2045,29 +3230,7 @@ Terse command-style prompts produce shallow, generic work.
2045
3230
  if (!overwrite) return;
2046
3231
  }
2047
3232
 
2048
- // Build the .md file content
2049
- const fmFields: string[] = [];
2050
- fmFields.push(`description: ${JSON.stringify(cfg.description)}`);
2051
- if (cfg.displayName) fmFields.push(`display_name: ${cfg.displayName}`);
2052
- fmFields.push(`tools: ${cfg.builtinToolNames?.join(", ") || "all"}`);
2053
- if (cfg.model) fmFields.push(`model: ${cfg.model}`);
2054
- if (cfg.thinking) fmFields.push(`thinking: ${cfg.thinking}`);
2055
- if (cfg.maxTurns) fmFields.push(`max_turns: ${cfg.maxTurns}`);
2056
- fmFields.push(`prompt_mode: ${cfg.promptMode}`);
2057
- if (cfg.extensions === false) fmFields.push("extensions: false");
2058
- else if (Array.isArray(cfg.extensions)) fmFields.push(`extensions: ${cfg.extensions.join(", ")}`);
2059
- if (cfg.excludeExtensions?.length) fmFields.push(`exclude_extensions: ${cfg.excludeExtensions.join(", ")}`);
2060
- if (cfg.skills === false) fmFields.push("skills: false");
2061
- else if (Array.isArray(cfg.skills)) fmFields.push(`skills: ${cfg.skills.join(", ")}`);
2062
- if (cfg.disallowedTools?.length) fmFields.push(`disallowed_tools: ${cfg.disallowedTools.join(", ")}`);
2063
- if (cfg.inheritContext) fmFields.push("inherit_context: true");
2064
- if (cfg.runInBackground) fmFields.push("run_in_background: true");
2065
- if (cfg.outputTranscript === false) fmFields.push("output_transcript: false");
2066
- if (cfg.isolated) fmFields.push("isolated: true");
2067
- if (cfg.memory) fmFields.push(`memory: ${cfg.memory}`);
2068
- if (cfg.isolation) fmFields.push(`isolation: ${cfg.isolation}`);
2069
-
2070
- const content = `---\n${fmFields.join("\n")}\n---\n\n${cfg.systemPrompt}\n`;
3233
+ const content = serializeAgentFile(cfg);
2071
3234
 
2072
3235
  const { writeFileSync } = await import("node:fs");
2073
3236
  writeFileSync(targetPath, content, "utf-8");
@@ -2077,15 +3240,21 @@ Terse command-style prompts produce shallow, generic work.
2077
3240
 
2078
3241
  /** Disable an agent: set enabled: false in its .md file, or create a stub for built-in defaults. */
2079
3242
  async function disableAgent(ctx: ExtensionCommandContext, name: string) {
2080
- const file = findAgentFile(name);
3243
+ const file = locateAgentFile(name, getAgentConfig(name)?.sourcePath);
2081
3244
  if (file) {
2082
3245
  // Existing file — set enabled: false in frontmatter (idempotent)
2083
3246
  const content = readFileSync(file.path, "utf-8");
2084
- if (content.includes("\nenabled: false\n")) {
3247
+ const { content: updated, outcome } = disableInContent(content);
3248
+ if (outcome === "already-disabled") {
2085
3249
  ctx.ui.notify(`${name} is already disabled.`, "info");
2086
3250
  return;
2087
3251
  }
2088
- const updated = content.replace(/^---\n/, "---\nenabled: false\n");
3252
+ if (outcome === "no-frontmatter") {
3253
+ // Nothing to edit — say so rather than rewriting the file unchanged and
3254
+ // reporting success for a change that never happened.
3255
+ ctx.ui.notify(`Cannot disable ${name}: ${file.path} has no frontmatter block.`, "error");
3256
+ return;
3257
+ }
2089
3258
  const { writeFileSync } = await import("node:fs");
2090
3259
  writeFileSync(file.path, updated, "utf-8");
2091
3260
  reloadCustomAgents();
@@ -2112,15 +3281,21 @@ Terse command-style prompts produce shallow, generic work.
2112
3281
 
2113
3282
  /** Enable a disabled agent by removing enabled: false from its frontmatter. */
2114
3283
  async function enableAgent(ctx: ExtensionCommandContext, name: string) {
2115
- const file = findAgentFile(name);
3284
+ const file = locateAgentFile(name, getAgentConfig(name)?.sourcePath);
2116
3285
  if (!file) return;
2117
3286
 
2118
3287
  const content = readFileSync(file.path, "utf-8");
2119
- const updated = content.replace(/^(---\n)enabled: false\n/, "$1");
3288
+ const { content: updated, changed } = enableInContent(content);
3289
+ if (!changed && !isEmptyStub(updated)) {
3290
+ // The file carries no `enabled: false` to remove, so it was never disabled
3291
+ // by us — reporting success here would hide a no-op.
3292
+ ctx.ui.notify(`${name} is not disabled in ${file.path}.`, "info");
3293
+ return;
3294
+ }
2120
3295
  const { writeFileSync } = await import("node:fs");
2121
3296
 
2122
3297
  // If the file was just a stub ("---\n---\n"), delete it to restore the built-in default
2123
- if (updated.trim() === "---\n---" || updated.trim() === "---\n---\n") {
3298
+ if (isEmptyStub(updated)) {
2124
3299
  unlinkSync(file.path);
2125
3300
  reloadCustomAgents();
2126
3301
  ctx.ui.notify(`Enabled ${name} (removed ${file.path})`, "info");
@@ -2179,6 +3354,7 @@ The file format is a markdown file with YAML frontmatter and a system prompt bod
2179
3354
  \`\`\`markdown
2180
3355
  ---
2181
3356
  description: <one-line description shown in UI>
3357
+ color: <optional agent name badge color: red, blue, green, yellow, purple, orange, pink, cyan, an Agency Agents alias, or quoted "#RRGGBB">
2182
3358
  tools: <comma-separated built-in tools: read, bash, edit, write, grep, find, ls. Use "none" for no tools. Omit for all tools>
2183
3359
  model: <optional model as "provider/modelId", e.g. "anthropic/claude-haiku-4-5". Omit to inherit parent model>
2184
3360
  thinking: <optional thinking level: ${THINKING_LEVELS.join(", ")}. Omit to inherit>
@@ -2188,11 +3364,18 @@ extensions: <true (inherit all MCP/extension tools), false (none), or comma-sepa
2188
3364
  skills: <true (inherit all), false (none), or comma-separated skill names to preload into prompt. Default: true>
2189
3365
  disallowed_tools: <comma-separated tool names to block, even if otherwise available. Omit for none>
2190
3366
  inherit_context: <true to fork parent conversation into agent so it sees chat history. Default: false>
2191
- run_in_background: <true to run in background by default. Default: false>
3367
+ run_in_background: <pin this agent to background (true) or foreground (false). Omit to follow the backgroundByDefault setting, which is background>
2192
3368
  output_transcript: <false to write no transcript file or path for this agent. Independent of persist_session. Default: true>
2193
3369
  isolated: <true for no extension/MCP tools, only built-in tools. Default: false>
2194
- memory: <"user" (global), "project" (per-project), or "local" (gitignored per-project) for persistent memory. Omit for none>
2195
- isolation: <"worktree" to run in isolated git worktree. Omit for normal>
3370
+ memory: <"user" (global), "project" (per-project), or "local" (gitignored per-project) for persistent memory. Omit for none>${
3371
+ // Offering the field on a project that turned worktrees off would bake a
3372
+ // request that is refused at spawn time into a file that outlives the
3373
+ // session — the #231 pathology (models fill the fields they are shown)
3374
+ // one layer up. Built per invocation, so this read is live.
3375
+ isWorktreeIsolationEnabled()
3376
+ ? `\nisolation: <"worktree" to run in isolated git worktree; "off" to refuse one even when the caller asks. Omit for normal>`
3377
+ : ""
3378
+ }
2196
3379
  ---
2197
3380
 
2198
3381
  <system prompt body — instructions for the agent>
@@ -2213,6 +3396,12 @@ Write the file using the write tool. Only write the file, nothing else.`;
2213
3396
  const { record } = await manager.spawnAndWait(pi, ctx, "general-purpose", generatePrompt, {
2214
3397
  description: `Generate ${name} agent`,
2215
3398
  maxTurns: 5,
3399
+ // Exempt from maxConcurrentForeground. This runs from a modal wizard, not
3400
+ // a tool call: it passes no signal, and Esc in `ctx.ui` never reaches the
3401
+ // manager — so a user waiting behind a full pool would have no way to
3402
+ // cancel at all. It is also one human action that cannot fan out, which
3403
+ // is what the limit exists to bound. It still counts once started.
3404
+ bypassQueue: true,
2216
3405
  });
2217
3406
 
2218
3407
  if (record.status === "error") {
@@ -2265,13 +3454,12 @@ Write the file using the write tool. Only write the file, nothing else.`;
2265
3454
  ]);
2266
3455
  if (!modelChoice) return;
2267
3456
 
2268
- let modelLine = "";
2269
- if (modelChoice === "haiku") modelLine = "\nmodel: anthropic/claude-haiku-4-5";
2270
- else if (modelChoice === "sonnet") modelLine = "\nmodel: anthropic/claude-sonnet-4-6";
2271
- else if (modelChoice === "opus") modelLine = "\nmodel: anthropic/claude-opus-4-6";
3457
+ let model: string | undefined;
3458
+ if (modelChoice === "haiku") model = "anthropic/claude-haiku-4-5";
3459
+ else if (modelChoice === "sonnet") model = "anthropic/claude-sonnet-4-6";
3460
+ else if (modelChoice === "opus") model = "anthropic/claude-opus-4-6";
2272
3461
  else if (modelChoice === "custom...") {
2273
- const customModel = await ctx.ui.input("Model (provider/modelId)");
2274
- if (customModel) modelLine = `\nmodel: ${customModel}`;
3462
+ model = (await ctx.ui.input("Model (provider/modelId)")) || undefined;
2275
3463
  }
2276
3464
 
2277
3465
  // 5. Thinking
@@ -2279,22 +3467,17 @@ Write the file using the write tool. Only write the file, nothing else.`;
2279
3467
  const thinkingChoice = await ctx.ui.select("Thinking level", ["inherit", ...THINKING_LEVELS]);
2280
3468
  if (!thinkingChoice) return;
2281
3469
 
2282
- let thinkingLine = "";
2283
- if (thinkingChoice !== "inherit") thinkingLine = `\nthinking: ${thinkingChoice}`;
2284
-
2285
3470
  // 6. System prompt
2286
3471
  const systemPrompt = await ctx.ui.editor("System prompt", "");
2287
3472
  if (systemPrompt === undefined) return;
2288
3473
 
2289
- // Build the file
2290
- const content = `---
2291
- description: ${description}
2292
- tools: ${tools}${modelLine}${thinkingLine}
2293
- prompt_mode: replace
2294
- ---
2295
-
2296
- ${systemPrompt}
2297
- `;
3474
+ const content = buildNewAgentFile({
3475
+ description,
3476
+ tools,
3477
+ model,
3478
+ thinking: thinkingChoice === "inherit" ? undefined : thinkingChoice,
3479
+ systemPrompt,
3480
+ });
2298
3481
 
2299
3482
  mkdirSync(targetDir, { recursive: true });
2300
3483
  const targetPath = join(targetDir, `${name}.md`);
@@ -2310,30 +3493,87 @@ ${systemPrompt}
2310
3493
  ctx.ui.notify(`Created ${targetPath}`, "info");
2311
3494
  }
2312
3495
 
2313
- function snapshotSettings(): SubagentsSettings {
3496
+ /**
3497
+ * Every settings mutation writes this WHOLE object back to disk, so a field
3498
+ * missing here is erased from the user's subagents.json the next time they
3499
+ * toggle something unrelated. `SubagentsSettings` has every field optional,
3500
+ * so a `: SubagentsSettings` return annotation would let a newly-added setting
3501
+ * be forgotten here and still type-check. `satisfies` instead: it still checks
3502
+ * each value's type and rejects a mistyped key, but leaves the return type
3503
+ * inferred so `_NoMissingSettingsKeys` below can check completeness.
3504
+ */
3505
+ function snapshotSettings() {
2314
3506
  return {
2315
3507
  maxConcurrent: manager.getMaxConcurrent(),
3508
+ // 0 = unlimited, and the default — see SubagentsSettings.
3509
+ maxConcurrentForeground: manager.getMaxConcurrentForeground(),
2316
3510
  // 0 = unlimited — per SubagentsSettings.defaultMaxTurns docstring and
2317
3511
  // normalizeMaxTurns() in agent-runner.ts (which maps 0 → undefined).
2318
3512
  defaultMaxTurns: getDefaultMaxTurns() ?? 0,
2319
3513
  graceTurns: getGraceTurns(),
2320
3514
  defaultJoinMode: getDefaultJoinMode(),
3515
+ backgroundByDefault: getBackgroundByDefault(),
2321
3516
  schedulingEnabled: isSchedulingEnabled(),
2322
3517
  scopeModels: isScopeModelsEnabled(),
3518
+ strictAgentFiles,
2323
3519
  disableDefaultAgents: isDefaultsDisabled(),
2324
3520
  toolDescriptionMode: getToolDescriptionMode(),
3521
+ fleetView: isFleetViewEnabled(),
3522
+ agentMentions: getAgentMentionMode(),
3523
+ rememberAgents: getRememberAgents(),
2325
3524
  widgetMode: getWidgetMode(),
2326
3525
  outputTranscript: getOutputTranscriptDefault(),
2327
- };
3526
+ worktreeIsolation: isWorktreeIsolationEnabled(),
3527
+ // The user's answer, not the effective one. A stand-down for another
3528
+ // extension's workflow tool is scoped to the session it was detected in;
3529
+ // writing it here would let an unrelated settings change three menus away
3530
+ // freeze it into the file as an explicit `false`, which then survives
3531
+ // uninstalling the extension it was deferring to. undefined is dropped by
3532
+ // JSON.stringify, so unset stays unset — same reasoning as
3533
+ // `fallbackSubagent` below.
3534
+ workflowsEnabled: isWorkflowsPinned() ? isWorkflowsEnabled() : undefined,
3535
+ maxSubagentDepth: getMaxSubagentDepth(),
3536
+ // Deliberately NOT `?? "general-purpose"`: every settings change writes the
3537
+ // whole snapshot, and materializing the implicit default would turn it into
3538
+ // explicit configuration — which then fails loudly if general-purpose later
3539
+ // goes away. undefined is dropped by JSON.stringify.
3540
+ fallbackSubagent: getFallbackSubagent(),
3541
+ reportUsage: isReportUsageEnabled(),
3542
+ showCost: isShowCostEnabled(),
3543
+ showModel: isShowModelEnabled(),
3544
+ viewerMarkdown: getViewerMarkdown(),
3545
+ } satisfies SubagentsSettings;
2328
3546
  }
2329
3547
 
2330
- const NUMERIC_IDS = new Set(["maxConcurrent", "defaultMaxTurns", "graceTurns"]);
3548
+ // Compile-time completeness guard for snapshotSettings(). If a field is added
3549
+ // to SubagentsSettings and not mirrored above, this Exclude is non-empty and
3550
+ // fails to satisfy `never` — turning a silent settings-erasure bug into a
3551
+ // typecheck error. `npm run typecheck` runs in CI.
3552
+ type _NoMissingSettingsKeys =
3553
+ Exclude<keyof SubagentsSettings, keyof ReturnType<typeof snapshotSettings>> extends never
3554
+ ? true
3555
+ : ["snapshotSettings() is missing a SubagentsSettings key"];
3556
+ const _settingsSnapshotIsComplete: _NoMissingSettingsKeys = true;
3557
+ void _settingsSnapshotIsComplete;
3558
+
3559
+ const NUMERIC_IDS = new Set([
3560
+ "maxConcurrent", "maxConcurrentForeground", "defaultMaxTurns", "graceTurns", "maxSubagentDepth",
3561
+ ]);
2331
3562
 
2332
3563
  async function showSettings(ctx: ExtensionCommandContext) {
2333
3564
  function buildItems(): SettingItem[] {
2334
3565
  const mc = manager.getMaxConcurrent();
3566
+ const mcf = manager.getMaxConcurrentForeground();
2335
3567
  const dmt = getDefaultMaxTurns() ?? 0;
2336
3568
  const gt = getGraceTurns();
3569
+ const msd = getMaxSubagentDepth();
3570
+ // Label what unset actually does — it targets general-purpose even when
3571
+ // that is unregistered (the permissive hardcoded tier), so showing "none"
3572
+ // there would advertise strict dispatch for the most permissive state.
3573
+ // `values` still offers only resolvable targets, so the user cannot
3574
+ // persist a fallback that would hard-error on every dispatch.
3575
+ const fallbackValue = getFallbackSubagent() ?? "general-purpose";
3576
+ const fallbackValues = [...new Set([...getAvailableTypes(), NO_FALLBACK])];
2337
3577
 
2338
3578
  return [
2339
3579
  {
@@ -2343,6 +3583,13 @@ ${systemPrompt}
2343
3583
  currentValue: String(mc),
2344
3584
  values: [String(mc)],
2345
3585
  },
3586
+ {
3587
+ id: "maxConcurrentForeground",
3588
+ label: "Max foreground concurrency",
3589
+ description: "Max concurrent foreground (blocking) agents (0 = unlimited, Enter to type)",
3590
+ currentValue: String(mcf),
3591
+ values: [String(mcf)],
3592
+ },
2346
3593
  {
2347
3594
  id: "defaultMaxTurns",
2348
3595
  label: "Default max turns",
@@ -2357,6 +3604,13 @@ ${systemPrompt}
2357
3604
  currentValue: String(gt),
2358
3605
  values: [String(gt)],
2359
3606
  },
3607
+ {
3608
+ id: "maxSubagentDepth",
3609
+ label: "Nested depth",
3610
+ description: "Hard cap on nested delegation — main is 0, its subagents 1 (0/1 = nesting off, Enter to type)",
3611
+ currentValue: String(msd),
3612
+ values: [String(msd)],
3613
+ },
2360
3614
  {
2361
3615
  id: "joinMode",
2362
3616
  label: "Join mode",
@@ -2364,6 +3618,13 @@ ${systemPrompt}
2364
3618
  currentValue: getDefaultJoinMode(),
2365
3619
  values: ["smart", "async", "group"],
2366
3620
  },
3621
+ {
3622
+ id: "backgroundByDefault",
3623
+ label: "Background by default",
3624
+ description: "An Agent call that doesn't say runs detached (off = blocks the turn and returns inline)",
3625
+ currentValue: getBackgroundByDefault() ? "on" : "off",
3626
+ values: ["on", "off"],
3627
+ },
2367
3628
  {
2368
3629
  id: "schedulingEnabled",
2369
3630
  label: "Scheduling",
@@ -2371,6 +3632,15 @@ ${systemPrompt}
2371
3632
  currentValue: isSchedulingEnabled() ? "on" : "off",
2372
3633
  values: ["on", "off"],
2373
3634
  },
3635
+ {
3636
+ id: "workflowsEnabled",
3637
+ label: "Workflows",
3638
+ description:
3639
+ "Scripted workflows, on unless another extension provides a workflow tool "
3640
+ + "(off keeps the SubagentWorkflow tool out of the tool spec; applies on next pi session)",
3641
+ currentValue: isWorkflowsEnabled() ? "on" : "off",
3642
+ values: ["on", "off"],
3643
+ },
2374
3644
  {
2375
3645
  id: "scopeModels",
2376
3646
  label: "Scope models",
@@ -2378,6 +3648,13 @@ ${systemPrompt}
2378
3648
  currentValue: isScopeModelsEnabled() ? "on" : "off",
2379
3649
  values: ["on", "off"],
2380
3650
  },
3651
+ {
3652
+ id: "strictAgentFiles",
3653
+ label: "Strict agent files",
3654
+ description: "Fail startup on an unreadable/unparseable agent .md instead of skipping it with a warning",
3655
+ currentValue: strictAgentFiles ? "on" : "off",
3656
+ values: ["on", "off"],
3657
+ },
2381
3658
  {
2382
3659
  id: "disableDefaultAgents",
2383
3660
  label: "Disable defaults",
@@ -2385,6 +3662,13 @@ ${systemPrompt}
2385
3662
  currentValue: isDefaultsDisabled() ? "on" : "off",
2386
3663
  values: ["on", "off"],
2387
3664
  },
3665
+ {
3666
+ id: "fallbackSubagent",
3667
+ label: "Fallback agent",
3668
+ description: `Agent used when subagent_type is unknown, disabled, or ambiguous; "${NO_FALLBACK}" rejects the call instead (strict dispatch)`,
3669
+ currentValue: fallbackValue,
3670
+ values: fallbackValues,
3671
+ },
2388
3672
  {
2389
3673
  id: "outputTranscript",
2390
3674
  label: "Output transcript",
@@ -2392,6 +3676,67 @@ ${systemPrompt}
2392
3676
  currentValue: getOutputTranscriptDefault() ? "on" : "off",
2393
3677
  values: ["on", "off"],
2394
3678
  },
3679
+ {
3680
+ id: "worktreeIsolation",
3681
+ label: "Worktree isolation",
3682
+ description:
3683
+ "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.",
3684
+ currentValue: isWorktreeIsolationEnabled() ? "on" : "off",
3685
+ values: ["on", "off"],
3686
+ },
3687
+ {
3688
+ id: "reportUsage",
3689
+ label: "Report usage to session",
3690
+ description:
3691
+ "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.",
3692
+ currentValue: isReportUsageEnabled() ? "on" : "off",
3693
+ values: ["on", "off"],
3694
+ },
3695
+ {
3696
+ id: "showCost",
3697
+ label: "Show cost",
3698
+ description:
3699
+ "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.",
3700
+ currentValue: isShowCostEnabled() ? "on" : "off",
3701
+ values: ["on", "off"],
3702
+ },
3703
+ {
3704
+ id: "showModel",
3705
+ label: "Show model",
3706
+ description:
3707
+ "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.",
3708
+ currentValue: isShowModelEnabled() ? "on" : "off",
3709
+ values: ["on", "off"],
3710
+ },
3711
+ {
3712
+ id: "viewerMarkdown",
3713
+ label: "Viewer markdown",
3714
+ description:
3715
+ "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+).",
3716
+ currentValue: getViewerMarkdown(),
3717
+ values: ["off", "assistant", "all"],
3718
+ },
3719
+ {
3720
+ id: "fleetView",
3721
+ label: "Fleet view",
3722
+ description: "Claude Code-style main+subagents list below the editor (↓/← to navigate, Enter to view)",
3723
+ currentValue: isFleetViewEnabled() ? "on" : "off",
3724
+ values: ["on", "off"],
3725
+ },
3726
+ {
3727
+ id: "agentMentions",
3728
+ label: "Agent mentions",
3729
+ 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.",
3730
+ currentValue: getAgentMentionMode(),
3731
+ values: ["model", "direct", "off"],
3732
+ },
3733
+ {
3734
+ id: "rememberAgents",
3735
+ label: "Remember agents",
3736
+ description: "Persist subagent sessions so `@handle` can resume one long after it finished (they also appear in /resume)",
3737
+ currentValue: getRememberAgents() ? "on" : "off",
3738
+ values: ["on", "off"],
3739
+ },
2395
3740
  {
2396
3741
  id: "widgetMode",
2397
3742
  label: "Widget",
@@ -2416,6 +3761,15 @@ ${systemPrompt}
2416
3761
  manager.setMaxConcurrent(n);
2417
3762
  notifyApplied(ctx, `Max concurrency set to ${n}`);
2418
3763
  }
3764
+ } else if (id === "maxConcurrentForeground") {
3765
+ // 0 is meaningful here, unlike maxConcurrent above: it means unlimited.
3766
+ const n = parseInt(value, 10);
3767
+ if (n >= 0) {
3768
+ manager.setMaxConcurrentForeground(n);
3769
+ notifyApplied(ctx, n === 0
3770
+ ? "Max foreground concurrency set to unlimited"
3771
+ : `Max foreground concurrency set to ${n}`);
3772
+ }
2419
3773
  } else if (id === "defaultMaxTurns") {
2420
3774
  const n = parseInt(value, 10);
2421
3775
  if (n === 0) {
@@ -2431,9 +3785,29 @@ ${systemPrompt}
2431
3785
  setGraceTurns(n);
2432
3786
  notifyApplied(ctx, `Grace turns set to ${n}`);
2433
3787
  }
3788
+ } else if (id === "maxSubagentDepth") {
3789
+ const n = parseInt(value, 10);
3790
+ if (n >= 0) {
3791
+ setMaxSubagentDepth(n);
3792
+ notifyApplied(
3793
+ ctx,
3794
+ n <= 1
3795
+ ? "Nested delegation disabled"
3796
+ : `Nested depth set to ${n}. Applies to agents started from now on.`,
3797
+ );
3798
+ }
2434
3799
  } else if (id === "joinMode") {
2435
3800
  setDefaultJoinMode(value as JoinMode);
2436
3801
  notifyApplied(ctx, `Default join mode set to ${value}`);
3802
+ } else if (id === "backgroundByDefault") {
3803
+ const enabled = value === "on";
3804
+ setBackgroundByDefault(enabled);
3805
+ notifyApplied(
3806
+ ctx,
3807
+ enabled
3808
+ ? "Agent calls run in the background unless they pass run_in_background: false"
3809
+ : "Agent calls block and return inline unless they pass run_in_background: true",
3810
+ );
2437
3811
  } else if (id === "schedulingEnabled") {
2438
3812
  const enabled = value === "on";
2439
3813
  if (enabled === isSchedulingEnabled()) {
@@ -2446,21 +3820,95 @@ ${systemPrompt}
2446
3820
  `Scheduling ${enabled ? "enabled" : "disabled"}. Tool spec change takes effect on next pi session.`,
2447
3821
  );
2448
3822
  }
3823
+ } else if (id === "workflowsEnabled") {
3824
+ const enabled = value === "on";
3825
+ if (enabled === isWorkflowsEnabled()) {
3826
+ ctx.ui.notify(`Workflows already ${enabled ? "enabled" : "disabled"}.`, "info");
3827
+ } else {
3828
+ setWorkflowsEnabled(enabled);
3829
+ // Runs already in flight keep going: the switch governs whether the
3830
+ // tool is offered, and killing live agents on a settings toggle would
3831
+ // lose work the user never asked to discard.
3832
+ notifyApplied(
3833
+ ctx,
3834
+ `Workflows ${enabled ? "enabled" : "disabled"}. Tool spec change takes effect on next pi session.`,
3835
+ );
3836
+ }
2449
3837
  } else if (id === "scopeModels") {
2450
3838
  const enabled = value === "on";
2451
3839
  setScopeModelsEnabled(enabled);
2452
3840
  notifyApplied(ctx, `Scope models ${enabled ? "enabled" : "disabled"}`);
3841
+ } else if (id === "strictAgentFiles") {
3842
+ const enabled = value === "on";
3843
+ strictAgentFiles = enabled;
3844
+ notifyApplied(ctx, `Strict agent files ${enabled ? "enabled" : "disabled"}. Takes effect on next pi session.`);
2453
3845
  } else if (id === "disableDefaultAgents") {
2454
3846
  const enabled = value === "on";
2455
3847
  setDisableDefaultAgents(enabled);
2456
3848
  notifyApplied(ctx, `Default agents ${enabled ? "disabled" : "enabled"}. Tool spec change takes effect on next pi session.`);
3849
+ } else if (id === "fallbackSubagent") {
3850
+ setFallbackSubagent(value);
3851
+ notifyApplied(
3852
+ ctx,
3853
+ value === NO_FALLBACK
3854
+ ? "Unknown or disabled agent types will now be rejected"
3855
+ : `Unknown agent types will fall back to ${value}`,
3856
+ );
2457
3857
  } else if (id === "outputTranscript") {
2458
3858
  const enabled = value === "on";
2459
- setOutputTranscript(enabled);
3859
+ setOutputTranscriptDefault(enabled);
2460
3860
  notifyApplied(ctx, `Output transcript ${enabled ? "enabled" : "disabled"} by default`);
3861
+ } else if (id === "worktreeIsolation") {
3862
+ const enabled = value === "on";
3863
+ setWorktreeIsolationEnabled(enabled);
3864
+ // The refusal is live, but the tool schema is built at registration, so
3865
+ // the isolation parameter only appears/disappears next session.
3866
+ notifyApplied(
3867
+ ctx,
3868
+ `Worktree isolation ${enabled ? "enabled" : "disabled"}. Tool parameter updates on next pi session.`,
3869
+ );
2461
3870
  } else if (id === "toolDescriptionMode") {
2462
3871
  setToolDescriptionMode(value as ToolDescriptionMode);
2463
3872
  notifyApplied(ctx, `Tool description set to ${value}. Takes effect on next pi session.`);
3873
+ } else if (id === "reportUsage") {
3874
+ const enabled = value === "on";
3875
+ setReportUsage(enabled);
3876
+ notifyApplied(
3877
+ ctx,
3878
+ enabled
3879
+ ? "Subagent usage now counted in this session's totals"
3880
+ : "Subagent usage no longer counted in this session's totals",
3881
+ );
3882
+ } else if (id === "showCost") {
3883
+ const enabled = value === "on";
3884
+ setShowCost(enabled);
3885
+ notifyApplied(ctx, `Cost display ${enabled ? "enabled" : "disabled"}`);
3886
+ } else if (id === "showModel") {
3887
+ const enabled = value === "on";
3888
+ setShowModel(enabled);
3889
+ notifyApplied(ctx, `Model display ${enabled ? "enabled" : "disabled"}`);
3890
+ } else if (id === "viewerMarkdown") {
3891
+ setViewerMarkdown(value as ViewerMarkdownMode);
3892
+ notifyApplied(ctx, `Viewer markdown set to ${value}`);
3893
+ } else if (id === "fleetView") {
3894
+ const enabled = value === "on";
3895
+ setFleetViewEnabled(enabled);
3896
+ notifyApplied(ctx, `Fleet view ${enabled ? "enabled" : "disabled"}`);
3897
+ } else if (id === "agentMentions") {
3898
+ const mode = value as AgentMentionMode;
3899
+ setAgentMentionMode(mode);
3900
+ notifyApplied(
3901
+ ctx,
3902
+ mode === "off"
3903
+ ? "Agent mentions disabled"
3904
+ : mode === "model"
3905
+ ? "Agent mentions on — a conversation clone starts a mentioned agent off-screen"
3906
+ : "Agent mentions on — a mentioned agent starts here, with no model call",
3907
+ );
3908
+ } else if (id === "rememberAgents") {
3909
+ const enabled = value === "on";
3910
+ setRememberAgents(enabled);
3911
+ notifyApplied(ctx, `Remember agents ${enabled ? "enabled" : "disabled"}`);
2464
3912
  } else if (id === "widgetMode") {
2465
3913
  setWidgetMode(value as WidgetMode);
2466
3914
  notifyApplied(ctx, `Widget set to ${value}`);
@@ -2515,15 +3963,23 @@ ${systemPrompt}
2515
3963
  if (result && NUMERIC_IDS.has(result)) {
2516
3964
  const current = result === "maxConcurrent"
2517
3965
  ? String(manager.getMaxConcurrent())
2518
- : result === "defaultMaxTurns"
2519
- ? String(getDefaultMaxTurns() ?? 0)
2520
- : String(getGraceTurns());
3966
+ : result === "maxConcurrentForeground"
3967
+ ? String(manager.getMaxConcurrentForeground())
3968
+ : result === "defaultMaxTurns"
3969
+ ? String(getDefaultMaxTurns() ?? 0)
3970
+ : result === "maxSubagentDepth"
3971
+ ? String(getMaxSubagentDepth())
3972
+ : String(getGraceTurns());
2521
3973
 
2522
3974
  const label = result === "maxConcurrent"
2523
3975
  ? "Max concurrency (1+)"
2524
- : result === "defaultMaxTurns"
2525
- ? "Default max turns (0 = unlimited)"
2526
- : "Grace turns (1+)";
3976
+ : result === "maxConcurrentForeground"
3977
+ ? "Max foreground concurrency (0 = unlimited)"
3978
+ : result === "defaultMaxTurns"
3979
+ ? "Default max turns (0 = unlimited)"
3980
+ : result === "maxSubagentDepth"
3981
+ ? "Nested depth (0/1 = nesting off)"
3982
+ : "Grace turns (1+)";
2527
3983
 
2528
3984
  // Loop until user enters a valid integer or cancels (Esc / null).
2529
3985
  // Silently trims whitespace; rejects non-numeric input by re-prompting.
@@ -2546,6 +4002,27 @@ ${systemPrompt}
2546
4002
  // the right toast. Successful saves show info; persistence failures downgrade
2547
4003
  // to warning so users aren't silently reverted on restart. Event fires regardless
2548
4004
  // of outcome so listeners see the in-memory change.
4005
+ /**
4006
+ * Persist + broadcast the settings, silent on success — for a change whose
4007
+ * feedback is the UI it just changed: the viewer's `m` key, where a
4008
+ * notification per press would talk over the overlay it is describing.
4009
+ *
4010
+ * A *failed* write still speaks. Every other settings path warns when the
4011
+ * value is session-only, and swallowing it here would leave a preference
4012
+ * looking persisted when the next session will not have it.
4013
+ */
4014
+ function persistSettings(ctx: ExtensionCommandContext | undefined, changeMsg: string): void {
4015
+ const { message, level } = saveAndEmitChanged(
4016
+ snapshotSettings(),
4017
+ changeMsg,
4018
+ (event, payload) => pi.events.emit(event, payload),
4019
+ );
4020
+ // `ctx` is absent only on the fleet path between sessions, where
4021
+ // `currentCtx` has been cleared and there is no UI to carry the warning to.
4022
+ // The write still happens.
4023
+ if (level === "warning") ctx?.ui.notify(message, level);
4024
+ }
4025
+
2549
4026
  function notifyApplied(ctx: ExtensionCommandContext, successMsg: string) {
2550
4027
  const { message, level } = saveAndEmitChanged(
2551
4028
  snapshotSettings(),
@@ -2559,4 +4036,20 @@ ${systemPrompt}
2559
4036
  description: "Manage agents",
2560
4037
  handler: async (_args, ctx) => { await showAgentsMenu(ctx); },
2561
4038
  });
4039
+
4040
+ /**
4041
+ * What `/agents → Workflows` and the fleet list's `workflow` rows need from
4042
+ * here. One object, built once: both entry points open the same inspector,
4043
+ * and handing them different views of the session would let the two drift.
4044
+ */
4045
+ const workflowMenuDeps: WorkflowMenuDeps = {
4046
+ tasks: workflowTasks,
4047
+ getRecord: id => manager.getRecord(id),
4048
+ viewAgentConversation,
4049
+ // Read lazily: `currentCtx` is rebound on every session_start, and the
4050
+ // fleet list may act between sessions, when there is none.
4051
+ getCtx: () => currentCtx as unknown as ExtensionCommandContext | undefined,
4052
+ };
4053
+
4054
+ fleet.setWorkflowSource(fleetWorkflows, id => openWorkflowFromFleet(id, workflowMenuDeps));
2562
4055
  }