@esso0428/pi-subagents 0.17.6 → 0.17.7

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 +9 -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 +1908 -495
  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 +96 -75
  101. package/dist/ui/agent-widget.d.ts.map +1 -1
  102. package/dist/ui/agent-widget.js +397 -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 +2024 -537
  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 +389 -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,71 @@ 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
- },
653
- },
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; }
1153
+ const widget = new AgentWidget(manager, agentActivity, getWidgetMode, isShowCostEnabled, isShowModelEnabled);
1154
+ function setWidgetMode(m: WidgetMode): void { widgetMode = m; widget.update(); }
1155
+
1156
+ // Claude Code-style FleetView: navigable list of main + subagents below the editor.
1157
+ // The last two arguments keep a conversation overlay opened here identical to
1158
+ // one opened from `/agents`: same setting on the way in, same persist out.
1159
+ const fleet = new FleetList(manager, agentActivity, isShowCostEnabled, getViewerMarkdown,
1160
+ (mode) => chooseViewerMarkdown(mode, currentCtx as unknown as ExtensionCommandContext | undefined),
1161
+ process.cwd());
1162
+ let fleetViewEnabled = true;
1163
+ function isFleetViewEnabled(): boolean { return fleetViewEnabled; }
1164
+ function setFleetViewEnabled(b: boolean): void { fleetViewEnabled = b; fleet.setEnabled(b); }
1165
+
1166
+ // Claude Code-style `@handle message` prompt mentions. Read live by both the
1167
+ // `input` hook and the stacked autocomplete provider, so the toggle applies
1168
+ // immediately — the provider itself can never be unregistered (pi's wrapper
1169
+ // list is append-only), it just delegates everything when this is off.
1170
+ let agentMentionMode: AgentMentionMode = "model";
1171
+ function getAgentMentionMode(): AgentMentionMode { return agentMentionMode; }
1172
+ function setAgentMentionMode(mode: AgentMentionMode): void { agentMentionMode = mode; }
1173
+ // `model` and `direct` differ only in who starts a not-yet-running agent, so
1174
+ // everything that just asks "are mentions live at all" — the suggestion list,
1175
+ // the steer and resume branches — reads this instead of the mode.
1176
+ function isAgentMentionsEnabled(): boolean { return agentMentionMode !== "off"; }
1177
+
1178
+ // Project/global default for writing the subagent .output transcript lives in
1179
+ // output-file.ts (both spawn paths read it). A custom agent's
1180
+ // `output_transcript` frontmatter overrides it per spawn; when the frontmatter
1181
+ // is silent, this default applies. Read live at spawn time.
666
1182
 
667
1183
  // ---- Join mode configuration ----
668
1184
  let defaultJoinMode: JoinMode = 'smart';
669
1185
  function getDefaultJoinMode(): JoinMode { return defaultJoinMode; }
670
1186
  function setDefaultJoinMode(mode: JoinMode) { defaultJoinMode = mode; }
671
1187
 
1188
+ // What an unqualified top-level spawn means. Defaults to background,
1189
+ // following Claude Code; `backgroundByDefault: false` restores the previous
1190
+ // foreground default. Nested spawns ignore this — see nested-tools.ts.
1191
+ let backgroundByDefault = true;
1192
+ function getBackgroundByDefault(): boolean { return backgroundByDefault; }
1193
+ function setBackgroundByDefault(b: boolean) { backgroundByDefault = b; }
1194
+
672
1195
  // Master switch for the schedule subagent feature. Defaults to enabled.
673
1196
  // Read once at extension init (before tool registration) so the Agent tool's
674
1197
  // param schema reflects the persisted setting. Runtime toggles via /agents
@@ -679,16 +1202,25 @@ export default function (pi: ExtensionAPI) {
679
1202
  function isSchedulingEnabled(): boolean { return schedulingEnabled; }
680
1203
  function setSchedulingEnabled(b: boolean) { schedulingEnabled = b; }
681
1204
 
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; }
1205
+ // Master switch for scripted workflows. Defaults to ON. Off means the
1206
+ // `SubagentWorkflow` tool is never registered: the model is not told the
1207
+ // feature exists (zero context cost) and has nothing to call. The
1208
+ // `/agents → Workflows` view and `--subagents-workflow-file` are refused too, so
1209
+ // there is no second door into the same machinery.
1210
+ //
1211
+ // `workflowsPinned` records that the answer came from the user — a boolean in
1212
+ // subagents.json, or the settings toggle — rather than from this default. It
1213
+ // is what `resolveWorkflowCollisions` checks before yielding to another
1214
+ // extension's workflow tool: a default may be overridden by what else is
1215
+ // loaded, an explicit choice may not.
1216
+ let workflowsEnabled = true;
1217
+ let workflowsPinned = false;
1218
+ function isWorkflowsEnabled(): boolean { return workflowsEnabled; }
1219
+ function isWorkflowsPinned(): boolean { return workflowsPinned; }
1220
+ function setWorkflowsEnabled(b: boolean) {
1221
+ workflowsEnabled = b;
1222
+ workflowsPinned = true;
1223
+ }
692
1224
 
693
1225
  // ---- Disable default agents configuration ----
694
1226
  // When enabled, the three hardcoded default agents (general-purpose, Explore,
@@ -753,22 +1285,107 @@ export default function (pi: ExtensionAPI) {
753
1285
  }
754
1286
  }
755
1287
 
1288
+ /**
1289
+ * Launch a detached resume of an existing agent and wire everything a
1290
+ * re-running agent needs: transcript anchoring, activity tracking, join-mode
1291
+ * batching, the widget/fleet refresh, and the `subagents:created` event.
1292
+ *
1293
+ * Shared by the Agent tool's `resume` + `run_in_background` branch and the
1294
+ * `@handle message` prompt mention — they differ only in how they report the
1295
+ * outcome. Returns the record, or undefined when the manager refused because
1296
+ * the agent is still running (see AgentManager.resume).
1297
+ *
1298
+ * Callers must have already established that the record has a session.
1299
+ */
1300
+ async function startBackgroundResume(
1301
+ ctx: ExtensionContext,
1302
+ existing: AgentRecord,
1303
+ prompt: string,
1304
+ opts: { outputTranscript: boolean; maxTurns?: number; toolCallId?: string },
1305
+ ): Promise<AgentRecord | undefined> {
1306
+ const id = existing.id;
1307
+ const joinMode = resolveJoinMode(defaultJoinMode, true);
1308
+ // Assigned unconditionally: the completion notification carries this as
1309
+ // `<tool-use-id>`, so a mention-resume (which passes none) has to CLEAR the
1310
+ // id left by the spawn that created the record. Keeping it would point the
1311
+ // orchestrator's new result at a tool call that was answered runs ago.
1312
+ existing.toolCallId = opts.toolCallId;
1313
+ if (joinMode) existing.joinMode = joinMode;
1314
+ // Reuse the agent's transcript rather than starting a fresh one: the
1315
+ // path is deterministic per agent+session, so writing an initial entry
1316
+ // would truncate the previous run's turns (see ensureOutputFile).
1317
+ if (opts.outputTranscript) {
1318
+ existing.outputFile = createOutputFilePath(ctx.cwd, id, ctx.sessionManager.getSessionId());
1319
+ ensureOutputFile(existing.outputFile);
1320
+ }
1321
+ // Anchor streaming past the turns already on disk, captured BEFORE the
1322
+ // run starts. The resumed prompt lands as an ordinary user message at
1323
+ // this index, so it is written exactly once.
1324
+ const transcriptAnchor = existing.session?.messages.length ?? 0;
1325
+
1326
+ const { state: bgState, callbacks: bgCallbacks } = createActivityTracker(opts.maxTurns);
1327
+ // resumeAgent has no onSessionCreated — the session predates this run —
1328
+ // so seed it directly, or the widget shows no context % for the agent.
1329
+ bgState.session = existing.session;
1330
+
1331
+ // No `signal`: a background spawn deliberately omits it, and a detached
1332
+ // resume must behave the same. Passing it would abort this agent when
1333
+ // the parent turn is interrupted (user Esc), while agents started with
1334
+ // run_in_background in that same turn keep going.
1335
+ const record = await manager.resume(id, prompt, undefined, {
1336
+ isBackground: true,
1337
+ onToolActivity: bgCallbacks.onToolActivity,
1338
+ onAssistantUsage: bgCallbacks.onAssistantUsage,
1339
+ // Fires when the run actually starts — immediately, or on queue
1340
+ // drain. Wiring it here (rather than after resume() returns) means a
1341
+ // resume stopped while still queued never started streaming, so
1342
+ // there is no subscription left behind for a later run to trip over.
1343
+ onStarted: () => {
1344
+ const rec = manager.getRecord(id);
1345
+ if (rec?.session && rec.outputFile) {
1346
+ rec.outputCleanup = streamToOutputFile(rec.session, rec.outputFile, id, ctx.cwd, transcriptAnchor);
1347
+ }
1348
+ },
1349
+ });
1350
+ if (!record) return undefined;
1351
+
1352
+ if (joinMode != null && joinMode !== 'async') {
1353
+ currentBatchAgents.push({ id, joinMode });
1354
+ if (batchFinalizeTimer) clearTimeout(batchFinalizeTimer);
1355
+ batchFinalizeTimer = setTimeout(finalizeBatch, 100);
1356
+ }
1357
+
1358
+ agentActivity.set(id, bgState);
1359
+ // This agent already finished once, so the widget holds a finished-age
1360
+ // for it that is past the linger limit — without clearing it, the
1361
+ // resumed run's ✓/✗ line never renders and the agent just vanishes.
1362
+ widget.markRunning(id);
1363
+ widget.ensureTimer();
1364
+ widget.update();
1365
+ fleet.ensureTimer();
1366
+ fleet.update();
1367
+
1368
+ // Resume ignores subagent_type (the record keeps the type it was
1369
+ // spawned with), so report the record's own identity — a "created"
1370
+ // event carrying the caller's type would re-register the agent under
1371
+ // the wrong one in cross-extension mirrors keyed by id.
1372
+ pi.events.emit("subagents:created", {
1373
+ id,
1374
+ type: existing.type,
1375
+ description: existing.description,
1376
+ isBackground: true,
1377
+ });
1378
+
1379
+ return record;
1380
+ }
1381
+
756
1382
  // Grab UI context from first tool execution + clear lingering widget on new turn
757
1383
  pi.on("tool_execution_start", async (_event, ctx) => {
758
- widget.setUICtx(ctx.ui as UICtx);
1384
+ if (ctx.hasUI && (ctx.mode === undefined || ctx.mode === "tui")) widget.setUICtx(ctx.ui as UICtx);
1385
+ if (ctx.hasUI && ctx.mode === undefined) fleet.setUICtx(ctx.ui as unknown as FleetUICtx, true);
759
1386
  widget.onTurnStart();
760
1387
  });
761
1388
 
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
1389
  /** Build the full type list text dynamically from available agents only. */
773
1390
  const buildTypeListText = () => {
774
1391
  const available = getAvailableTypes();
@@ -808,15 +1425,29 @@ export default function (pi: ExtensionAPI) {
808
1425
  applyAndEmitLoaded(
809
1426
  {
810
1427
  setMaxConcurrent: (n) => manager.setMaxConcurrent(n),
1428
+ setMaxConcurrentForeground: (n) => manager.setMaxConcurrentForeground(n),
811
1429
  setDefaultMaxTurns,
812
1430
  setGraceTurns,
813
1431
  setDefaultJoinMode,
1432
+ setBackgroundByDefault,
814
1433
  setSchedulingEnabled,
815
1434
  setScopeModels: setScopeModelsEnabled,
1435
+ setStrictAgentFiles: (b) => { strictAgentFiles = b; },
816
1436
  setDisableDefaultAgents: setDisableDefaultAgents,
817
1437
  setToolDescriptionMode: setToolDescriptionMode,
1438
+ setFleetView: setFleetViewEnabled,
1439
+ setAgentMentions: setAgentMentionMode,
1440
+ setRememberAgents,
818
1441
  setWidgetMode: setWidgetMode,
819
- setOutputTranscript: setOutputTranscript,
1442
+ setOutputTranscript: setOutputTranscriptDefault,
1443
+ setWorktreeIsolation: setWorktreeIsolationEnabled,
1444
+ setWorkflowsEnabled: setWorkflowsEnabled,
1445
+ setMaxSubagentDepth: setMaxSubagentDepth,
1446
+ setFallbackSubagent: setFallbackSubagent,
1447
+ setReportUsage,
1448
+ setShowCost,
1449
+ setShowModel,
1450
+ setViewerMarkdown,
820
1451
  },
821
1452
  (event, payload) => pi.events.emit(event, payload),
822
1453
  );
@@ -845,6 +1476,21 @@ export default function (pi: ExtensionAPI) {
845
1476
  ? `\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
1477
  : "";
847
1478
 
1479
+ // Same trade as scheduleParam/scheduleGuideline above: `isolationParam` drops
1480
+ // the field from the schema when the project set `worktreeIsolation: false`,
1481
+ // so the prose has to go with it. Left in, it would teach the model to pass a
1482
+ // parameter that isn't declared — accepted (TypeBox sets no
1483
+ // `additionalProperties: false`) and then silently dropped by the resolver.
1484
+ // With no per-result note by design, the model would have every reason to go
1485
+ // on reporting a `pi-agent-*` branch that was never created.
1486
+ const isolationGuideline = isWorktreeIsolationEnabled()
1487
+ ? `\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.`
1488
+ : "";
1489
+
1490
+ const isolationCompactGuideline = isWorktreeIsolationEnabled()
1491
+ ? `\n- isolation: "worktree" gives the agent its own git worktree (removed on completion); changes land on a branch named in the result.`
1492
+ : "";
1493
+
848
1494
  // Compact Agent tool description (#91, `toolDescriptionMode: "compact"`) —
849
1495
  // the same load-bearing facts as the full version at ~75% fewer tokens, for
850
1496
  // small/local models. Per-option details live in the param descriptions.
@@ -855,10 +1501,10 @@ Custom agents: .pi/agents/<name>.md (project) or ${getAgentDir()}/agents/<name>.
855
1501
 
856
1502
  Notes:
857
1503
  - 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.
1504
+ - Parallel work: one message, multiple Agent calls — they run concurrently.
1505
+ - 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
1506
  - 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.`;
1507
+ - resume continues a previous agent by ID; steer_subagent messages a running one.${isolationCompactGuideline}`;
862
1508
 
863
1509
  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
1510
 
@@ -876,23 +1522,23 @@ If the target is already known, use a direct tool — \`read\` for a known path,
876
1522
  ## Usage notes
877
1523
 
878
1524
  - 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.
1525
+ - 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
1526
  - 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.
1527
+ - 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.
1528
+ - 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.
1529
+ - **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.
1530
+ - **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
1531
  - 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
1532
  - Use steer_subagent to send mid-run messages to a running background agent.
886
1533
  - 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
1534
  - 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
1535
  - Use model to specify a different model (as "provider/modelId", or fuzzy e.g. "haiku", "sonnet").
889
1536
  - 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}
1537
+ - Use inherit_context if the agent needs the parent conversation history.${isolationGuideline}${scheduleGuideline}
892
1538
 
893
1539
  ## Writing the prompt
894
1540
 
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.
1541
+ 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
1542
  - Explain what you're trying to accomplish and why.
897
1543
  - Describe what you've already learned or ruled out.
898
1544
  - Give enough context about the surrounding problem that the agent can make judgment calls rather than just following a narrow instruction.
@@ -912,6 +1558,7 @@ Terse command-style prompts produce shallow, generic work.
912
1558
  typeList: buildTypeListText,
913
1559
  compactTypeList: buildCompactTypeListText,
914
1560
  agentDir: getAgentDir,
1561
+ isolationGuideline: () => isolationGuideline,
915
1562
  scheduleGuideline: () => scheduleGuideline,
916
1563
  };
917
1564
  // Replacement callback (not a string) — agent descriptions may contain `$&` etc.
@@ -950,7 +1597,10 @@ Terse command-style prompts produce shallow, generic work.
950
1597
  return fullAgentToolDescription;
951
1598
  })();
952
1599
 
953
- pi.registerTool(defineTool({
1600
+ // Held rather than registered inline: the mention clone reuses this exact
1601
+ // definition, so the agent it starts is an ordinary top-level spawn instead
1602
+ // of a second implementation that has to be kept in step with this one.
1603
+ const agentTool = defineTool({
954
1604
  name: SUBAGENT_TOOL_NAMES.AGENT,
955
1605
  label: "Agent",
956
1606
  description: agentToolDescription,
@@ -968,6 +1618,12 @@ Terse command-style prompts produce shallow, generic work.
968
1618
  description: Type.String({
969
1619
  description: "A short (3-5 word) description of the task (shown in UI).",
970
1620
  }),
1621
+ name: Type.Optional(
1622
+ Type.String({
1623
+ description:
1624
+ '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.',
1625
+ }),
1626
+ ),
971
1627
  subagent_type: Type.String({
972
1628
  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
1629
  }),
@@ -990,12 +1646,12 @@ Terse command-style prompts produce shallow, generic work.
990
1646
  ),
991
1647
  run_in_background: Type.Optional(
992
1648
  Type.Boolean({
993
- description: "Set to true to run in background. Returns agent ID immediately. You will be notified on completion.",
1649
+ 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
1650
  }),
995
1651
  ),
996
1652
  resume: Type.Optional(
997
1653
  Type.String({
998
- description: "Optional agent ID to resume from. Continues from previous context.",
1654
+ 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
1655
  }),
1000
1656
  ),
1001
1657
  isolated: Type.Optional(
@@ -1008,26 +1664,40 @@ Terse command-style prompts produce shallow, generic work.
1008
1664
  description: "If true, fork parent conversation into the agent. Default: false (fresh context).",
1009
1665
  }),
1010
1666
  ),
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
- ),
1667
+ ...isolationParam(isWorktreeIsolationEnabled()),
1016
1668
  ...scheduleParam,
1017
1669
  }),
1018
1670
 
1019
1671
  // ---- Custom rendering: Claude Code style ----
1020
1672
 
1021
- renderCall(args, theme) {
1022
- const displayName = args.subagent_type ? getDisplayName(args.subagent_type) : "Agent";
1673
+ renderCall(args, theme, context) {
1674
+ // A badge closes its own background, which would clear the tool block's row tint
1675
+ // for the rest of the line, so the badge restores it. The tint is opened here too:
1676
+ // the TUI's Box paints it, but HTML export takes it from CSS, and restoring a
1677
+ // background the line never opened is what banded the export before. The line is
1678
+ // deliberately left open — Box.applyBackgroundToLine pads to width and *then*
1679
+ // wraps, so closing here would leave that padding untinted, and HTML export closes
1680
+ // any open span per line anyway. No badge means no tint, so an uncolored agent
1681
+ // renders exactly the line it always did.
1682
+ const rowBackground = hasAgentBadge(args.subagent_type)
1683
+ ? theme.getBgAnsi(context.isPartial ? "toolPendingBg" : context.isError ? "toolErrorBg" : "toolSuccessBg")
1684
+ : "";
1023
1685
  const desc = args.description ?? "";
1024
- return new Text("▸ " + theme.fg("toolTitle", theme.bold(displayName)) + (desc ? " " + theme.fg("muted", desc) : ""), 0, 0);
1686
+ const name = renderAgentName(args.subagent_type, theme, {
1687
+ fallbackColor: "toolTitle",
1688
+ restoreBackground: rowBackground,
1689
+ bold: true,
1690
+ });
1691
+ return new Text(rowBackground + "▸ " + name + (desc ? " " + theme.fg("muted", desc) : ""), 0, 0);
1025
1692
  },
1026
1693
 
1027
- renderResult(result, { expanded, isPartial }, theme) {
1694
+ renderResult(result, { expanded, isPartial }, theme, renderContext) {
1028
1695
  const details = result.details as AgentDetails | undefined;
1029
- if (!details) {
1030
- const text = result.content[0]?.type === "text" ? result.content[0].text : "";
1696
+ const text = result.content[0]?.type === "text" ? result.content[0].text : "";
1697
+ // Pi reports pre-execution failures (extension block, abort, argument
1698
+ // validation) as `{ content: [reason], details: {} }` with isError set —
1699
+ // no status to render, so show the reason instead of inventing one (#199).
1700
+ if (renderContext.isError || !details?.status) {
1031
1701
  return new Text(text, 0, 0);
1032
1702
  }
1033
1703
 
@@ -1041,6 +1711,10 @@ Terse command-style prompts produce shallow, generic work.
1041
1711
  }
1042
1712
  if (d.toolUses > 0) parts.push(`${d.toolUses} tool use${d.toolUses === 1 ? "" : "s"}`);
1043
1713
  if (d.tokens) parts.push(d.tokens);
1714
+ if (showCost) {
1715
+ const costText = formatCost(d.cost ?? 0);
1716
+ if (costText) parts.push(costText);
1717
+ }
1044
1718
  return parts.map(p => fgPreservingNestedStyles(theme, "dim", p)).join(" " + theme.fg("dim", "·") + " ");
1045
1719
  };
1046
1720
 
@@ -1091,6 +1765,12 @@ Terse command-style prompts produce shallow, generic work.
1091
1765
  return new Text(line, 0, 0);
1092
1766
  }
1093
1767
 
1768
+ // Anything left ("queued", or a status added later) has no rendering of
1769
+ // its own — the turn-limit wording below must not be the catch-all.
1770
+ if (details.status !== "error" && details.status !== "aborted") {
1771
+ return new Text(text, 0, 0);
1772
+ }
1773
+
1094
1774
  // ---- Error / Aborted (hard max_turns) ----
1095
1775
  const s = stats(details);
1096
1776
  let line = theme.fg("error", "✗") + (s ? " " + s : "");
@@ -1114,16 +1794,40 @@ Terse command-style prompts produce shallow, generic work.
1114
1794
  reloadCustomAgents();
1115
1795
 
1116
1796
  const rawType = params.subagent_type as SubagentType;
1117
- const resolved = resolveType(rawType);
1118
- const subagentType = resolved ?? "general-purpose";
1119
- const fellBack = resolved === undefined;
1797
+ // Single decision point for dispatch (#183): unknown, disabled and
1798
+ // case-ambiguous types are refused here, BEFORE anything spawns, so a
1799
+ // background or scheduled call can't start running the wrong agent while
1800
+ // the caller is still unaware. `fallbackSubagent` decides whether an
1801
+ // unresolvable type falls back or fails closed.
1802
+ const dispatch = resolveSpawnType(rawType);
1803
+ // `resume` replays a stored session and ignores `subagent_type` entirely,
1804
+ // but the parameter is required by the schema — so gating it here would
1805
+ // make a live agent unresumable the moment its type is deleted, disabled,
1806
+ // or gains a case-clashing sibling. Only a real spawn is gated.
1807
+ if (!dispatch.ok && !params.resume) return textResult(dispatch.message);
1808
+ const subagentType = dispatch.ok ? dispatch.type : rawType;
1809
+ // What the caller actually asked for, named once: `fellBackFrom` is "" for
1810
+ // a blank request, so reading it inline invites the `??`-vs-`||` slip that
1811
+ // once persisted an empty type into a scheduled job.
1812
+ const requestedType = (dispatch.ok && dispatch.fellBackFrom) || subagentType;
1813
+ // Computed at resolution rather than after the run, so the background and
1814
+ // schedule branches carry it too — previously it existed only on the
1815
+ // foreground path. Resume deliberately doesn't: it replays the stored
1816
+ // session and ignores `subagent_type` entirely, so a note about type
1817
+ // substitution would be describing something that didn't happen.
1818
+ const fallbackNote = dispatch.ok && dispatch.fellBackFrom !== undefined
1819
+ ? `Note: Unknown agent type "${dispatch.fellBackFrom}" — using ${resolveType(subagentType) ? subagentType : "the fallback agent config"}.\n\n`
1820
+ : "";
1120
1821
 
1121
1822
  const displayName = getDisplayName(subagentType);
1122
1823
 
1123
1824
  // Get agent config (if any)
1124
1825
  const customConfig = getAgentConfig(subagentType);
1125
1826
 
1126
- const resolvedConfig = resolveAgentInvocationConfig(customConfig, params);
1827
+ const resolvedConfig = resolveAgentInvocationConfig(customConfig, params, {
1828
+ worktreeAllowed: isWorktreeIsolationEnabled(),
1829
+ defaultRunInBackground: getBackgroundByDefault(),
1830
+ });
1127
1831
 
1128
1832
  // Resolve model from agent config first; tool-call params only fill gaps.
1129
1833
  let model = ctx.model;
@@ -1138,33 +1842,18 @@ Terse command-style prompts produce shallow, generic work.
1138
1842
  }
1139
1843
 
1140
1844
  // 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
- }
1845
+ // user's enabledModels list. Policy (hard error vs warn-and-proceed) lives
1846
+ // in model-scope.ts so the nested delegation tools apply the same rule.
1847
+ const scopeVerdict = checkModelScope({
1848
+ model,
1849
+ cwd: ctx.cwd,
1850
+ modelRegistry: ctx.modelRegistry,
1851
+ callerSupplied: resolvedConfig.modelFromParams,
1852
+ agentLabel: customConfig?.displayName ?? subagentType,
1853
+ modelInput: resolvedConfig.modelInput,
1854
+ });
1855
+ if (scopeVerdict.kind === "error") return textResult(scopeVerdict.message);
1856
+ if (scopeVerdict.kind === "warn") ctx.ui.notify(scopeVerdict.message, "warning");
1168
1857
 
1169
1858
  const thinking = resolvedConfig.thinking;
1170
1859
  const inheritContext = resolvedConfig.inheritContext;
@@ -1181,33 +1870,34 @@ Terse command-style prompts produce shallow, generic work.
1181
1870
  if (!rec || !outputTranscript) return;
1182
1871
  rec.outputFile = createOutputFilePath(ctx.cwd, agentId, ctx.sessionManager.getSessionId());
1183
1872
  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
1873
  };
1199
1874
 
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;
1875
+ // Unconditional, not "only when it differs from the parent": a thinking
1876
+ // level reads as a property of a model, and an agent that inherited the
1877
+ // parent's model used to show the level with nothing to attach it to.
1878
+ // This is the pre-session snapshot — agent-manager overwrites it with the
1879
+ // effective values the moment a session reports them.
1880
+ const { modelName, modelId } = model ? describeModel(model) : { modelName: undefined, modelId: undefined };
1881
+ // What the caller SPELLED, kept only if it names a different model than the
1882
+ // one that won. Model input is fuzzy — `"haiku"` and
1883
+ // `"anthropic/claude-haiku-4-5"` are the same model — so comparing the two
1884
+ // strings would disclose an override that never happened. A spelling that
1885
+ // resolves to nothing is still worth disclosing: it cannot have taken effect.
1886
+ const askedModel = ((asked: string | undefined) => {
1887
+ if (!asked) return undefined;
1888
+ const resolvedAsked = resolveModel(asked, ctx.modelRegistry);
1889
+ if (typeof resolvedAsked === "string") return asked;
1890
+ return resolvedAsked.provider === model?.provider && resolvedAsked.id === model?.id ? undefined : asked;
1891
+ })(resolvedConfig.overridden?.model);
1205
1892
  const effectiveMaxTurns = normalizeMaxTurns(resolvedConfig.maxTurns ?? getDefaultMaxTurns());
1206
1893
  const agentInvocation: AgentInvocation = {
1207
1894
  modelName,
1208
- effectiveModelName: model?.name ?? model?.id ?? ctx.model?.name ?? ctx.model?.id,
1895
+ modelId,
1209
1896
  thinking,
1210
- effectiveThinking: thinking,
1897
+ // Only set where the agent file outranked the caller, so the surfaces can
1898
+ // disclose a parameter that was accepted but could not take effect (#182).
1899
+ requestedThinking: resolvedConfig.overridden?.thinking,
1900
+ requestedModel: askedModel,
1211
1901
  // Explicit value only — the default fallback would just add noise.
1212
1902
  // Normalize so `0` (unlimited) doesn't surface as a misleading "max turns: 0".
1213
1903
  maxTurns: normalizeMaxTurns(resolvedConfig.maxTurns),
@@ -1228,6 +1918,34 @@ Terse command-style prompts produce shallow, generic work.
1228
1918
  tags: agentTags.length > 0 ? agentTags : undefined,
1229
1919
  };
1230
1920
 
1921
+ /**
1922
+ * `detailBase` for a record that exists, which outranks it: the base is a
1923
+ * snapshot of what this call REQUESTED, and pi may have resolved a
1924
+ * different model or clamped the thinking level (agent-manager writes the
1925
+ * effective values back when the session reports them). Resume goes
1926
+ * further and ignores the model/thinking parameters outright — it runs on
1927
+ * the session it is reopening — so rendering the base there advertises
1928
+ * settings the run never used.
1929
+ *
1930
+ * The mode label is rebuilt rather than carried over: it hangs off the
1931
+ * agent TYPE, not the invocation, so tags taken straight from
1932
+ * buildInvocationTags would silently drop `twin`.
1933
+ */
1934
+ const detailBaseFor = (rec: AgentRecord | undefined): typeof detailBase => {
1935
+ if (!rec?.invocation) return detailBase;
1936
+ const type = rec.type;
1937
+ const { modelName: recModelName, tags } = buildInvocationTags(rec.invocation);
1938
+ const recModeLabel = getPromptModeLabel(type);
1939
+ const recTags = recModeLabel ? [recModeLabel, ...tags] : tags;
1940
+ return {
1941
+ displayName: getDisplayName(type),
1942
+ description: rec.description,
1943
+ subagentType: type,
1944
+ modelName: recModelName,
1945
+ tags: recTags.length > 0 ? recTags : undefined,
1946
+ };
1947
+ };
1948
+
1231
1949
  // ---- Schedule: register a job, don't spawn now ----
1232
1950
  if (params.schedule) {
1233
1951
  if (!isSchedulingEnabled()) {
@@ -1250,7 +1968,9 @@ Terse command-style prompts produce shallow, generic work.
1250
1968
  name: params.description as string,
1251
1969
  description: params.description as string,
1252
1970
  schedule: params.schedule as string,
1253
- subagent_type: subagentType,
1971
+ // The caller's own name, not the substitute — the scheduler re-resolves
1972
+ // at fire time, and the original is what a user edits.
1973
+ subagent_type: requestedType,
1254
1974
  prompt: params.prompt as string,
1255
1975
  model: params.model as string | undefined,
1256
1976
  thinking: thinking,
@@ -1260,7 +1980,7 @@ Terse command-style prompts produce shallow, generic work.
1260
1980
  });
1261
1981
  const next = scheduler.getNextRun(job.id);
1262
1982
  return textResult(
1263
- `Scheduled "${job.name}" (id: ${job.id}, type: ${job.scheduleType}). ` +
1983
+ `${fallbackNote}Scheduled "${job.name}" (id: ${job.id}, type: ${job.scheduleType}). ` +
1264
1984
  `Next run: ${next ?? "(unknown)"}. ` +
1265
1985
  `Manage via /agents → Scheduled jobs.`,
1266
1986
  );
@@ -1272,12 +1992,53 @@ Terse command-style prompts produce shallow, generic work.
1272
1992
  // Resume existing agent
1273
1993
  if (params.resume) {
1274
1994
  const existing = manager.getRecord(params.resume);
1275
- if (!existing) {
1995
+ if (!existing || !isTopLevelAgent(existing)) {
1276
1996
  return textResult(`Agent not found: "${params.resume}". It may have been cleaned up.`);
1277
1997
  }
1278
1998
  if (!existing.session) {
1279
1999
  return textResult(`Agent "${params.resume}" has no active session to resume.`);
1280
2000
  }
2001
+
2002
+ // Background resume: detached run that notifies on completion, mirroring
2003
+ // a background spawn. Previously run_in_background was silently ignored
2004
+ // on resume (this branch returned before the background branch below),
2005
+ // so a resumed agent always blocked the main loop until it finished.
2006
+ if (runInBackground) {
2007
+ const id = existing.id;
2008
+ // A detached resume hands control back while the record stays
2009
+ // "running", so nothing stops the model from resuming the same agent
2010
+ // again mid-run. manager.resume() refuses that (it would orphan the
2011
+ // live run's abort controller); say why here, where the model can act
2012
+ // on it, instead of letting it read as a generic failure.
2013
+ if (existing.status === "running" || existing.status === "queued") {
2014
+ return textResult(
2015
+ `Agent "${params.resume}" is still ${existing.status} — it can only be resumed once its current run finishes.\n` +
2016
+ `Use steer_subagent to send it a message mid-run, or get_subagent_result to wait for it.`,
2017
+ );
2018
+ }
2019
+
2020
+ const record = await startBackgroundResume(ctx, existing, params.prompt, {
2021
+ outputTranscript,
2022
+ maxTurns: effectiveMaxTurns,
2023
+ toolCallId,
2024
+ });
2025
+ if (!record) {
2026
+ return textResult(`Failed to resume agent "${params.resume}".`);
2027
+ }
2028
+
2029
+ const isQueued = record.status === "queued";
2030
+ return textResult(
2031
+ `Agent ${isQueued ? "queued" : "resumed"} in background.\n` +
2032
+ `Agent ID: ${id}\n` +
2033
+ `Type: ${existing.type}\n` +
2034
+ (record.outputFile ? `Output file: ${record.outputFile}\n` : "") +
2035
+ (isQueued ? `Position: queued (max ${manager.getMaxConcurrent()} concurrent)\n` : "") +
2036
+ `\nYou will be notified when this agent completes.\n` +
2037
+ `Use get_subagent_result to retrieve full results, or steer_subagent to send it messages.`,
2038
+ { ...detailBaseFor(record), toolUses: record.toolUses, tokens: "", durationMs: 0, status: "background" as const, agentId: id },
2039
+ );
2040
+ }
2041
+
1281
2042
  const record = await manager.resume(params.resume, params.prompt, signal);
1282
2043
  if (!record) {
1283
2044
  return textResult(`Failed to resume agent "${params.resume}".`);
@@ -1285,11 +2046,11 @@ Terse command-style prompts produce shallow, generic work.
1285
2046
  // A failed resume surfaces the error, plus any partial output THIS
1286
2047
  // resume produced (never the previous turn's answer, #144).
1287
2048
  if (record.status === "error") {
1288
- return textResult(`Agent failed: ${record.error}${partialOutputSuffix(record)}`, buildDetails(detailBase, record));
2049
+ return textResult(`Agent failed: ${record.error}${partialOutputSuffix(record)}`, buildDetails(detailBaseFor(record), record));
1289
2050
  }
1290
2051
  return textResult(
1291
2052
  record.result?.trim() || "No output.",
1292
- buildDetails(detailBase, record),
2053
+ buildDetails(detailBaseFor(record), record),
1293
2054
  );
1294
2055
  }
1295
2056
 
@@ -1298,47 +2059,53 @@ Terse command-style prompts produce shallow, generic work.
1298
2059
  const { state: bgState, callbacks: bgCallbacks } = createActivityTracker(effectiveMaxTurns);
1299
2060
 
1300
2061
  // 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.
2062
+ // The callback lazily reads record.outputFile (set right after spawn)
2063
+ // rather than closing over a value that doesn't exist yet.
1303
2064
  let id: string;
1304
- const joinMode = resolveJoinMode(defaultJoinMode, true);
1305
2065
  const origBgOnSession = bgCallbacks.onSessionCreated;
1306
2066
  bgCallbacks.onSessionCreated = (session: any) => {
1307
2067
  origBgOnSession(session);
1308
2068
  const rec = manager.getRecord(id);
1309
2069
  if (rec?.outputFile) {
1310
- rec.outputCleanup = streamToOutputFile(session, rec.outputFile, id, ctx.cwd, rec.historyFile);
2070
+ rec.outputCleanup = streamToOutputFile(session, rec.outputFile, id, ctx.cwd, undefined);
1311
2071
  }
1312
2072
  };
1313
2073
 
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
- }
2074
+ // A throw here means the agent never started. Let it out: pi marks a
2075
+ // tool call failed only when execute throws, and a returned message
2076
+ // reads to the model as a subagent that ran and reported this (#179).
2077
+ id = manager.spawn(pi, ctx, subagentType, params.prompt, {
2078
+ description: params.description,
2079
+ name: params.name as string | undefined,
2080
+ model,
2081
+ maxTurns: effectiveMaxTurns,
2082
+ isolated,
2083
+ inheritContext,
2084
+ thinkingLevel: thinking,
2085
+ isBackground: true,
2086
+ isolation,
2087
+ invocation: agentInvocation,
2088
+ outputTranscript,
2089
+ rootSessionId: ctx.sessionManager.getSessionId(),
2090
+ ...bgCallbacks,
2091
+ });
1333
2092
 
1334
- // Set join metadata after spawn. Transcript metadata was installed by
1335
- // the manager's synchronous onSpawned callback before this point.
2093
+ // Set output file + join mode synchronously after spawn, before the
2094
+ // event loop yields — onSessionCreated is async so this is safe.
2095
+ const joinMode = resolveJoinMode(defaultJoinMode, true);
1336
2096
  const record = manager.getRecord(id);
1337
2097
  if (record && joinMode) {
1338
2098
  record.joinMode = joinMode;
1339
2099
  record.toolCallId = toolCallId;
2100
+ attachTranscript(record, id);
1340
2101
  }
1341
2102
 
2103
+ // With isolation: "worktree" the agent isn't running yet — the repo
2104
+ // copy is an awaited git call. Wait for it here, after the synchronous
2105
+ // wiring above, so a strict-isolation failure still fails THIS tool
2106
+ // call instead of being reported as a subagent that ran (#179).
2107
+ await manager.awaitStartup(id);
2108
+
1342
2109
  if (joinMode == null || joinMode === 'async') {
1343
2110
  // Foreground/no join mode or explicit async — not part of any batch
1344
2111
  } else {
@@ -1353,6 +2120,8 @@ Terse command-style prompts produce shallow, generic work.
1353
2120
  agentActivity.set(id, bgState);
1354
2121
  widget.ensureTimer();
1355
2122
  widget.update();
2123
+ fleet.ensureTimer();
2124
+ fleet.update();
1356
2125
 
1357
2126
  // Emit created event
1358
2127
  pi.events.emit("subagents:created", {
@@ -1364,7 +2133,7 @@ Terse command-style prompts produce shallow, generic work.
1364
2133
 
1365
2134
  const isQueued = record?.status === "queued";
1366
2135
  return textResult(
1367
- `Agent ${isQueued ? "queued" : "started"} in background.\n` +
2136
+ `${fallbackNote}Agent ${isQueued ? "queued" : "started"} in background.\n` +
1368
2137
  `Agent ID: ${id}\n` +
1369
2138
  `Type: ${displayName}\n` +
1370
2139
  `Description: ${params.description}\n` +
@@ -1373,7 +2142,7 @@ Terse command-style prompts produce shallow, generic work.
1373
2142
  `\nYou will be notified when this agent completes.\n` +
1374
2143
  `Use get_subagent_result to retrieve full results, or steer_subagent to send it messages.\n` +
1375
2144
  `Do not duplicate this agent's work.`,
1376
- { ...detailBase, toolUses: 0, tokens: "", durationMs: 0, status: "background" as const, agentId: id },
2145
+ { ...detailBaseFor(record), toolUses: 0, tokens: "", durationMs: 0, status: "background" as const, agentId: id },
1377
2146
  );
1378
2147
  }
1379
2148
 
@@ -1381,17 +2150,33 @@ Terse command-style prompts produce shallow, generic work.
1381
2150
  let spinnerFrame = 0;
1382
2151
  const startedAt = Date.now();
1383
2152
  let fgId: string | undefined;
2153
+ // Set only while the spawn is parked on a foreground concurrency slot
2154
+ // (maxConcurrentForeground); undefined the rest of the time, including
2155
+ // always when the limit is unset.
2156
+ let queuedAhead: number | undefined;
1384
2157
 
1385
2158
  const streamUpdate = () => {
2159
+ // Spend from the record, everything else from the live tracker. `fgId`
2160
+ // is set in onSessionCreated below, which fires before the first
2161
+ // assistant message — so nothing is spent while this reads zero.
2162
+ const fgRecord = fgId ? manager.getRecord(fgId) : undefined;
1386
2163
  const details: AgentDetails = {
1387
- ...detailBase,
2164
+ ...detailBaseFor(fgRecord),
1388
2165
  toolUses: fgState.toolUses,
1389
- tokens: formatLifetimeTokens(fgState),
2166
+ tokens: fgRecord ? formatLifetimeTokens(fgRecord) : "",
2167
+ cost: fgRecord ? getLifetimeCost(fgRecord.lifetimeUsage) : 0,
1390
2168
  turnCount: fgState.turnCount,
1391
2169
  maxTurns: fgState.maxTurns,
1392
2170
  durationMs: Date.now() - startedAt,
2171
+ // Deliberately still "running" while queued: the renderer routes any
2172
+ // status it doesn't know to raw text (see the catch-all below), which
2173
+ // would drop the spinner and read as hung. Only the activity line
2174
+ // changes — "thinking…" would be a lie for an agent that has not
2175
+ // started and may not for minutes.
1393
2176
  status: "running",
1394
- activity: describeActivity(fgState.activeTools, fgState.responseText),
2177
+ activity: queuedAhead === undefined
2178
+ ? describeActivity(fgState.activeTools, fgState.responseText)
2179
+ : `queued — waiting for a foreground slot${queuedAhead > 0 ? ` (${queuedAhead} ahead)` : ""}`,
1395
2180
  spinnerFrame: spinnerFrame % SPINNER.length,
1396
2181
  };
1397
2182
  onUpdate?.({
@@ -1408,12 +2193,20 @@ Terse command-style prompts produce shallow, generic work.
1408
2193
  const origOnSession = fgCallbacks.onSessionCreated;
1409
2194
  fgCallbacks.onSessionCreated = (session: any) => {
1410
2195
  origOnSession(session);
2196
+ // It really started — stop reporting it as queued, and repaint now
2197
+ // rather than leaving the stale line up for the next spinner tick.
2198
+ // Guarded, so a spawn that never queued emits no extra update.
2199
+ if (queuedAhead !== undefined) {
2200
+ queuedAhead = undefined;
2201
+ streamUpdate();
2202
+ }
1411
2203
  for (const a of manager.listAgents()) {
1412
2204
  if (a.session === session) {
1413
2205
  fgId = a.id;
1414
2206
  agentActivity.set(a.id, fgState);
1415
2207
  widget.ensureTimer();
1416
- widget.update();
2208
+ fleet.ensureTimer();
2209
+ fleet.update();
1417
2210
  break;
1418
2211
  }
1419
2212
  }
@@ -1421,7 +2214,7 @@ Terse command-style prompts produce shallow, generic work.
1421
2214
  if (fgId) {
1422
2215
  const rec = manager.getRecord(fgId);
1423
2216
  if (rec?.outputFile) {
1424
- rec.outputCleanup = streamToOutputFile(session, rec.outputFile, fgId, ctx.cwd, rec.historyFile);
2217
+ rec.outputCleanup = streamToOutputFile(session, rec.outputFile, fgId, ctx.cwd, undefined);
1425
2218
  }
1426
2219
  }
1427
2220
  };
@@ -1438,6 +2231,7 @@ Terse command-style prompts produce shallow, generic work.
1438
2231
  try {
1439
2232
  const fgResult = await manager.spawnAndWait(pi, ctx, subagentType, params.prompt, {
1440
2233
  description: params.description,
2234
+ name: params.name as string | undefined,
1441
2235
  model,
1442
2236
  maxTurns: effectiveMaxTurns,
1443
2237
  isolated,
@@ -1445,7 +2239,13 @@ Terse command-style prompts produce shallow, generic work.
1445
2239
  thinkingLevel: thinking,
1446
2240
  isolation,
1447
2241
  invocation: agentInvocation,
2242
+ outputTranscript,
1448
2243
  signal,
2244
+ rootSessionId: ctx.sessionManager.getSessionId(),
2245
+ // Deliberately does NOT set fgId: that drives agentActivity, the
2246
+ // widget and the `finally` cleanup below, none of which should see an
2247
+ // agent that has no session and may never get one.
2248
+ onQueued: (_id, ahead) => { queuedAhead = ahead; streamUpdate(); },
1449
2249
  ...fgCallbacks,
1450
2250
  }, (fgAgentId) => {
1451
2251
  // onSpawned: called synchronously after spawn, before onSessionCreated fires.
@@ -1454,29 +2254,23 @@ Terse command-style prompts produce shallow, generic work.
1454
2254
  attachTranscript(fgRec, fgAgentId);
1455
2255
  });
1456
2256
  record = fgResult.record;
1457
- } catch (err) {
2257
+ } finally {
2258
+ // Runs on both paths, so a startup throw — which now propagates, see
2259
+ // the background spawn above (#179) — no longer leaves the spinner
2260
+ // ticking or a finished agent on the widget.
1458
2261
  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);
2262
+ if (fgId) {
2263
+ agentActivity.delete(fgId);
2264
+ widget.markFinished(fgId);
2265
+ fleet.onAgentFinished(fgId);
2266
+ }
1468
2267
  }
1469
2268
 
1470
- // Get final token count
1471
- const tokenText = formatLifetimeTokens(fgState);
2269
+ // Get final token count — from the record, like the cost below it, so the
2270
+ // two describe the same work when the agent delegated to nested children.
2271
+ const tokenText = formatLifetimeTokens(record);
1472
2272
 
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
- : "";
2273
+ const details = buildDetails(detailBaseFor(record), record, fgState, { tokens: tokenText });
1480
2274
 
1481
2275
  if (record.status === "error") {
1482
2276
  // Error headline + any partial output the run produced before failing.
@@ -1486,25 +2280,488 @@ Terse command-style prompts produce shallow, generic work.
1486
2280
  const durationMs = (record.completedAt ?? Date.now()) - record.startedAt;
1487
2281
  const statsParts = [`${record.toolUses} tool uses`];
1488
2282
  if (tokenText) statsParts.push(tokenText);
2283
+ if (showCost) {
2284
+ const costText = formatCost(getLifetimeCost(record.lifetimeUsage));
2285
+ if (costText) statsParts.push(costText);
2286
+ }
1489
2287
  return textResult(
1490
- `${fallbackNote}Agent completed in ${formatMs(durationMs)} (${statsParts.join(", ")})${getStatusNote(record.status)}.\n\n` +
2288
+ `${fallbackNote}Agent completed in ${formatMs(durationMs)} (${statsParts.join(", ")})${getForegroundOutcomeNote(record.status)}.\n\n` +
1491
2289
  (record.result?.trim() || "No output."),
1492
2290
  details,
1493
2291
  );
1494
2292
  },
1495
- }));
2293
+ });
2294
+ /**
2295
+ * Wrap a tool so its results carry back whatever subagent spend the parent
2296
+ * session has not been told about yet (see `PendingUsagePool`).
2297
+ *
2298
+ * Pi copies `AgentToolResult.usage` onto the persisted tool-result message and
2299
+ * folds it into `getSessionStats()`, which is what the footer, the statusline
2300
+ * and `/cost` read — so this is the whole of "report usage to the parent".
2301
+ *
2302
+ * Nothing is attached to a call with no tool-call id. That is the `@handle`
2303
+ * mention path (`mention-clone.ts`), which invokes this tool from a fork of the
2304
+ * conversation that is discarded moments later: the result never becomes a
2305
+ * message in the real session, so usage hung on it would be spend the user paid
2306
+ * for and nobody counted. Skipping leaves it pending for the next real result.
2307
+ */
2308
+ function withUsageReporting<T extends { execute: (...args: any[]) => any }>(tool: T): T {
2309
+ return {
2310
+ ...tool,
2311
+ execute: async (toolCallId: string | undefined, ...rest: any[]) => {
2312
+ const result = await tool.execute(toolCallId, ...rest);
2313
+ if (!reportUsage || !toolCallId) return result;
2314
+ const usage = pendingUsage.drain();
2315
+ return usage ? { ...result, usage } : result;
2316
+ },
2317
+ };
2318
+ }
2319
+ function registerToolReportingUsage(tool: any): void {
2320
+ pi.registerTool(withUsageReporting(tool));
2321
+ }
2322
+
2323
+ // The mention path is handed THIS object, not the bare `agentTool` — see the
2324
+ // mention-clone header on why the clone must call the registered tool.
2325
+ const registeredAgentTool = withUsageReporting(agentTool);
2326
+ pi.registerTool(registeredAgentTool);
2327
+
2328
+ // ---- Workflow tool ----
2329
+
2330
+ /**
2331
+ * Live runs, by task id. The tool returns before the run finishes, so its
2332
+ * result card looks the task up here on every render rather than freezing a
2333
+ * snapshot into `details` — that is what makes the inline card follow a
2334
+ * background run.
2335
+ */
2336
+ const workflowTasks = new Map<string, WorkflowTask>();
2337
+
2338
+ /**
2339
+ * Workflow runs as the fleet list wants them.
2340
+ *
2341
+ * Mapped here rather than handing `WorkflowTask` over the seam: the list is
2342
+ * deliberately ignorant of the workflow engine, and a run's counters live in
2343
+ * the progress log rather than on the record, so they are derived per call
2344
+ * the same way the card derives them.
2345
+ */
2346
+ function fleetWorkflows(): FleetWorkflow[] {
2347
+ // Cached counters only, no derivation: the fleet list calls this on a
2348
+ // 200ms tick and reads the roster several times per update, so walking a
2349
+ // run's progress log here would put O(log) work in the render loop.
2350
+ return [...workflowTasks.values()].map(task => ({
2351
+ id: task.id,
2352
+ name: task.meta?.name ?? task.workflowName ?? task.id,
2353
+ status: task.status,
2354
+ doneCount: task.doneCount,
2355
+ totalCount: task.agentCount,
2356
+ startedAt: task.startTime,
2357
+ ...(task.endTime !== undefined ? { completedAt: task.endTime } : {}),
2358
+ tokens: task.totalTokens,
2359
+ }));
2360
+ }
2361
+
2362
+ /**
2363
+ * Run a task to completion against the real manager, settling the record
2364
+ * either way. Never rejects: a run that cannot start (bad `meta`, oversized
2365
+ * source, non-JSON `args`) is a failed workflow, and both callers here are
2366
+ * detached — a rejection would surface as an unhandled one.
2367
+ */
2368
+ async function runWorkflowTask(ctx: ExtensionContext, task: WorkflowTask): Promise<void> {
2369
+ try {
2370
+ const result = await runWorkflow({
2371
+ script: task.script,
2372
+ args: task.args,
2373
+ signal: task.abortController.signal,
2374
+ host: createWorkflowHost({
2375
+ pi,
2376
+ ctx,
2377
+ manager,
2378
+ signal: task.abortController.signal,
2379
+ rootSessionId: ctx.sessionManager.getSessionId(),
2380
+ workflowId: task.id,
2381
+ }),
2382
+ onProgress: entries => updateWorkflowProgressBatch(task, entries),
2383
+ // The dialog's pause / skip / retry keys run through this; it is dropped
2384
+ // again when the task settles.
2385
+ onControl: control => { task.control = control; },
2386
+ journal: {
2387
+ ...(task.replay !== undefined ? { entries: task.replay } : {}),
2388
+ ...(task.journalPath !== undefined
2389
+ ? { append: (entry: WorkflowJournalEntry) => appendJournal(task.journalPath!, entry) }
2390
+ : {}),
2391
+ },
2392
+ });
2393
+ completeWorkflowTask(task, result);
2394
+ } catch (err) {
2395
+ failWorkflowTask(task, err instanceof Error ? err.message : String(err));
2396
+ }
2397
+ }
2398
+
2399
+ /**
2400
+ * Hand a finished run back to the model through the SAME channel a background
2401
+ * agent uses — held briefly by `scheduleNudge`, delivered as a follow-up that
2402
+ * triggers a turn, rendered by the existing `subagent-notification` renderer.
2403
+ */
2404
+ function notifyWorkflowFinished(task: WorkflowTask) {
2405
+ widget.update();
2406
+ fleet.update();
2407
+ const result = workflowResultText(task);
2408
+ scheduleNudge(task.id, () => {
2409
+ pi.sendMessage<NotificationDetails>({
2410
+ customType: "subagent-notification",
2411
+ content: formatWorkflowNotification(task),
2412
+ display: true,
2413
+ details: {
2414
+ id: task.id,
2415
+ description: `Workflow ${task.workflowName ?? task.id}`,
2416
+ status: task.status === "completed" ? "completed" : task.status === "killed" ? "stopped" : "error",
2417
+ toolUses: task.totalToolCalls,
2418
+ // A workflow has agents, not turns; rendering "↻0" would be noise.
2419
+ turnCount: 0,
2420
+ totalTokens: task.totalTokens,
2421
+ durationMs: elapsedMs(task, Date.now()),
2422
+ error: task.error,
2423
+ resultPreview: result.length > 500 ? `${result.slice(0, 500)}…` : result,
2424
+ },
2425
+ }, { deliverAs: "followUp", triggerTurn: true });
2426
+ });
2427
+ }
2428
+
2429
+ // Defined unconditionally, registered only when the feature is on — the same
2430
+ // shape the Agent tool uses. Keeping the definition out of the `if` means the
2431
+ // switch changes exactly one thing: whether pi is ever told about the tool.
2432
+ const workflowTool = defineTool({
2433
+ name: SUBAGENT_TOOL_NAMES.WORKFLOW,
2434
+ label: "SubagentWorkflow",
2435
+ description: renderToolDescriptionTemplate(fullWorkflowToolDescription),
2436
+ promptSnippet: "Run a deterministic script that orchestrates many subagents",
2437
+ promptGuidelines: [
2438
+ "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.",
2439
+ "Prefer `pipeline` over `parallel` — a barrier costs wall-clock whenever the stages are unevenly sized.",
2440
+ "A workflow runs in the background and notifies you when it finishes — do not poll or sleep waiting for it.",
2441
+ ],
2442
+ parameters: Type.Object({
2443
+ script: Type.Optional(
2444
+ Type.String({
2445
+ maxLength: 524288,
2446
+ description: "Inline workflow source. Must begin with `export const meta = { name, description }`.",
2447
+ }),
2448
+ ),
2449
+ scriptPath: Type.Optional(
2450
+ Type.String({
2451
+ description:
2452
+ "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.",
2453
+ }),
2454
+ ),
2455
+ name: Type.Optional(
2456
+ Type.String({
2457
+ description:
2458
+ "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.",
2459
+ }),
2460
+ ),
2461
+ args: Type.Optional(
2462
+ Type.Any({
2463
+ description: "Exposed to the script as the global `args`, verbatim. Must be JSON-shaped.",
2464
+ }),
2465
+ ),
2466
+ resumeFromRunId: Type.Optional(
2467
+ Type.String({
2468
+ pattern: "^wf_[a-z0-9-]{6,}$",
2469
+ description:
2470
+ "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.",
2471
+ }),
2472
+ ),
2473
+ // Accepted and ignored, as in Claude Code. Models reach for them because
2474
+ // every other tool has them, and a hard schema rejection would cost a
2475
+ // whole turn to re-emit a script that was already correct. The `meta`
2476
+ // block is the one place a workflow is named.
2477
+ title: Type.Optional(
2478
+ Type.String({ description: "Ignored — set the workflow title in the script's `meta` block." }),
2479
+ ),
2480
+ description: Type.Optional(
2481
+ Type.String({ description: "Ignored — set the workflow description in the script's `meta` block." }),
2482
+ ),
2483
+ }),
2484
+
2485
+ renderCall(args, theme) {
2486
+ return new Text(
2487
+ `${theme.fg("toolTitle", "▸ ")}${theme.bold(theme.fg("toolTitle", "SubagentWorkflow"))} ${theme.fg("muted", workflowCallName(args))}`,
2488
+ 0,
2489
+ 0,
2490
+ );
2491
+ },
2492
+
2493
+ renderResult(result, _options, theme, renderContext) {
2494
+ const text = result.content[0]?.type === "text" ? result.content[0].text : "";
2495
+ const taskId = (result.details as { taskId?: string } | undefined)?.taskId;
2496
+ const task = taskId !== undefined ? workflowTasks.get(taskId) : undefined;
2497
+ // No task means the run predates this session (a reloaded transcript) or
2498
+ // the call never started one — show what `execute` said instead.
2499
+ if (renderContext.isError || !task) return new Text(text, 0, 0);
2500
+ return renderWorkflowCard(
2501
+ {
2502
+ progress: task.workflowProgress,
2503
+ task: {
2504
+ status: task.status,
2505
+ workflowName: task.workflowName,
2506
+ startTime: task.startTime,
2507
+ endTime: task.endTime,
2508
+ totalPausedMs: task.totalPausedMs,
2509
+ },
2510
+ meta: task.meta,
2511
+ agentCount: task.agentCount,
2512
+ totalTokens: task.totalTokens,
2513
+ },
2514
+ theme,
2515
+ );
2516
+ },
2517
+
2518
+ execute: async (toolCallId, params, _signal, _onUpdate, ctx) => {
2519
+ const resumeFrom = resolveResumeTarget(params.resumeFromRunId, workflowTasks);
2520
+ if (resumeFrom !== undefined && !resumeFrom.ok) return textResult(resumeFrom.message);
2521
+
2522
+ // A resume with no source of its own re-runs what that run ran. The
2523
+ // common case is an edited script, but "run that again, cheaply" should
2524
+ // not require repeating a path the run already knows.
2525
+ const resolved = resolveWorkflowScript(
2526
+ params.script === undefined && params.scriptPath === undefined && params.name === undefined
2527
+ && resumeFrom !== undefined
2528
+ ? { scriptPath: resumeFrom.scriptPath }
2529
+ : params,
2530
+ ctx.cwd,
2531
+ );
2532
+ if (!resolved.ok) return textResult(resolved.message);
2533
+
2534
+ // Parsed before anything is scheduled: a bad `meta` is an authoring error
2535
+ // the model can fix immediately, and reporting it as a background run
2536
+ // that failed a second later would just cost a turn.
2537
+ let meta: WorkflowMeta;
2538
+ try {
2539
+ meta = extractMeta(resolved.script).meta;
2540
+ } catch (err) {
2541
+ return textResult(err instanceof Error ? err.message : String(err));
2542
+ }
2543
+
2544
+ const runId = workflowRunId();
2545
+ // Every invocation lands on disk next to the agent transcripts, so
2546
+ // iterating is edit-the-file-then-rerun-with-scriptPath rather than
2547
+ // re-emitting the whole source. The journal sits beside it under the same
2548
+ // id, which is what makes a run id enough to resume from.
2549
+ let savedPath: string | undefined;
2550
+ let journalPath: string | undefined;
2551
+ try {
2552
+ const dir = sessionTaskDir(ctx.cwd, ctx.sessionManager.getSessionId());
2553
+ savedPath = join(dir, `${runId}.workflow.js`);
2554
+ writeFileSync(savedPath, resolved.script, "utf-8");
2555
+ journalPath = join(dir, `${runId}.workflow.jsonl`);
2556
+ } catch (err) {
2557
+ savedPath = undefined;
2558
+ journalPath = undefined;
2559
+ console.warn(`[pi-subagents] could not persist workflow script: ${err instanceof Error ? err.message : String(err)}`);
2560
+ }
2561
+
2562
+ const replay = resumeFrom !== undefined ? readJournal(resumeFrom.journalPath) : undefined;
2563
+
2564
+ const task = createWorkflowTask({
2565
+ id: runId,
2566
+ script: resolved.script,
2567
+ scriptPath: resolved.scriptPath ?? savedPath,
2568
+ args: params.args,
2569
+ meta,
2570
+ toolCallId,
2571
+ ...(journalPath !== undefined ? { journalPath } : {}),
2572
+ ...(replay !== undefined && replay.length > 0 ? { replay, resumedFrom: resumeFrom!.runId } : {}),
2573
+ });
2574
+ workflowTasks.set(runId, task);
2575
+ // The run's own row has to appear now, not when it settles. Its agents
2576
+ // are owned by it, so their lifecycle callbacks no longer refresh these
2577
+ // surfaces — nothing else would register the widget for a run whose
2578
+ // first agent has not started yet.
2579
+ widget.update();
2580
+ fleet.update();
2581
+
2582
+ // Background, like Claude Code: the id comes back now and the run keeps
2583
+ // going without the tool call.
2584
+ void runWorkflowTask(ctx, task).then(() => notifyWorkflowFinished(task));
2585
+
2586
+ return {
2587
+ content: [{
2588
+ type: "text" as const,
2589
+ text:
2590
+ `Workflow "${meta.name}" started in the background.\n` +
2591
+ `Task ID: ${runId}\n` +
2592
+ (task.scriptPath ? `Script: ${task.scriptPath}\n` : "") +
2593
+ (task.resumedFrom !== undefined
2594
+ ? `Resuming ${task.resumedFrom}: ${task.replay?.length ?? 0} recorded call(s) available to replay.\n`
2595
+ : params.resumeFromRunId !== undefined
2596
+ ? `Nothing to replay from ${params.resumeFromRunId} — every agent runs live.\n`
2597
+ : "") +
2598
+ `\nYou will be notified when it finishes — do NOT poll or sleep waiting for it.\n` +
2599
+ `To iterate, edit the script file and call SubagentWorkflow again with scriptPath.`,
2600
+ }],
2601
+ details: { taskId: runId },
2602
+ };
2603
+ },
2604
+ });
2605
+
2606
+ if (isWorkflowsEnabled()) pi.registerTool(workflowTool);
2607
+
2608
+ /**
2609
+ * Act on {@link decideWorkflowCollision} — the half that needs the host.
2610
+ *
2611
+ * The policy (what counts as a conflict, what a pin changes, whether there is
2612
+ * anything left to withdraw) lives in `workflow/collisions.ts`; this is the
2613
+ * host-facing shell around it: read the registry, warn, and take our tool out
2614
+ * of the active set.
2615
+ *
2616
+ * ## Why this can only happen at session_start
2617
+ *
2618
+ * `getAllTools` throws during extension loading ("Action methods cannot be
2619
+ * called during extension loading"), and load order means a check at
2620
+ * registration time could not see an extension that has not loaded yet. So
2621
+ * the decision cannot gate `registerTool`; it has to undo it. `setActiveTools`
2622
+ * is what makes that real rather than cosmetic — pi rebuilds the system
2623
+ * prompt from the new set, and `session_start` runs before any turn, so the
2624
+ * model never sees a spec we withdrew. A later `_refreshToolRegistry` keeps
2625
+ * the active set it had and only adds names new to the registry, so ours does
2626
+ * not creep back.
2627
+ *
2628
+ * Best-effort and swallowed. A diagnostic that took the session down would be
2629
+ * worse than the collision it reports.
2630
+ */
2631
+ let collisionsChecked = false;
2632
+ function resolveWorkflowCollisions(ctx: ExtensionContext): void {
2633
+ if (collisionsChecked) return;
2634
+ collisionsChecked = true;
2635
+
2636
+ const warn = (message: string) => {
2637
+ if (ctx.hasUI) ctx.ui.notify(message, "warning");
2638
+ else console.warn(`[pi-subagents] ${message}`);
2639
+ };
2640
+
2641
+ try {
2642
+ if (!isWorkflowsEnabled()) return;
2643
+
2644
+ const verdict = decideWorkflowCollision({
2645
+ tools: pi.getAllTools(),
2646
+ // Identifies our own registration: this extension does not know its
2647
+ // install path, and the description is the one field certainly ours.
2648
+ ownDescription: workflowTool.description,
2649
+ pinned: isWorkflowsPinned(),
2650
+ });
2651
+ if (verdict.kind === "none") return;
2652
+ if (verdict.kind === "report") {
2653
+ warn(verdict.message);
2654
+ return;
2655
+ }
2656
+
2657
+ workflowsEnabled = false; // not setWorkflowsEnabled: this is not the user pinning it
2658
+ widget.update();
2659
+ fleet.update();
2660
+ warn(verdict.message);
2661
+
2662
+ if (!verdict.withdraw) return;
2663
+ const active = pi.getActiveTools();
2664
+ if (active.includes(SUBAGENT_TOOL_NAMES.WORKFLOW)) {
2665
+ pi.setActiveTools(active.filter(name => name !== SUBAGENT_TOOL_NAMES.WORKFLOW));
2666
+ }
2667
+ } catch {
2668
+ // getAllTools/setActiveTools are unavailable in some hosts (print mode,
2669
+ // RPC). Not being able to check is not a reason to fail the session.
2670
+ }
2671
+ }
2672
+
2673
+ /**
2674
+ * `--subagents-workflow-file=<path>` — run a script at startup, with no LLM
2675
+ * round-trip deciding whether to call the tool.
2676
+ *
2677
+ * Read here rather than at activation because that is the only place the real
2678
+ * value exists: the host activates extensions first and applies collected CLI
2679
+ * flags second, so `getFlag` during activation returns the registered default
2680
+ * and nothing else. `examples/extensions/ssh.ts` reads its flag from
2681
+ * session_start for exactly this reason.
2682
+ */
2683
+ let workflowFlagHandled = false;
2684
+ function runWorkflowFlag(ctx: ExtensionContext): void {
2685
+ if (workflowFlagHandled) return;
2686
+ const flag = typeof pi.getFlag === "function" ? pi.getFlag(WORKFLOW_FILE_FLAG) : undefined;
2687
+ if (flag === undefined || flag === false) return;
2688
+ workflowFlagHandled = true;
2689
+
2690
+ const report = (message: string, level: "info" | "warning") => {
2691
+ if (ctx.hasUI) ctx.ui.notify(message, level);
2692
+ else console.warn(`[pi-subagents] ${message}`);
2693
+ };
2694
+
2695
+ // The flag is the same machinery by another door, so the master switch has
2696
+ // to close it too — silently ignoring a flag the user typed would be worse
2697
+ // than saying why nothing ran.
2698
+ if (!isWorkflowsEnabled()) {
2699
+ report(
2700
+ `--${WORKFLOW_FILE_FLAG} ignored: workflows are off. Turn them on in /agents → Settings → Workflows, ` +
2701
+ 'or set `"workflowsEnabled": true` in .pi/subagents.json.',
2702
+ "warning",
2703
+ );
2704
+ return;
2705
+ }
2706
+
2707
+ // A bare `--subagents-workflow-file` parses to boolean `true`. Say what was
2708
+ // missing rather than reading a file called "true".
2709
+ if (typeof flag !== "string" || flag.trim() === "") {
2710
+ report(`--${WORKFLOW_FILE_FLAG} needs a path: --${WORKFLOW_FILE_FLAG}=<path>`, "warning");
2711
+ return;
2712
+ }
2713
+
2714
+ const path = isAbsolute(flag.trim()) ? flag.trim() : join(ctx.cwd, flag.trim());
2715
+ let script: string;
2716
+ try {
2717
+ script = readFileSync(path, "utf-8");
2718
+ } catch (err) {
2719
+ report(`Could not read ${path}: ${err instanceof Error ? err.message : String(err)}`, "warning");
2720
+ return;
2721
+ }
2722
+
2723
+ let meta: WorkflowMeta | undefined;
2724
+ try {
2725
+ meta = extractMeta(script).meta;
2726
+ } catch (err) {
2727
+ report(err instanceof Error ? err.message : String(err), "warning");
2728
+ return;
2729
+ }
2730
+
2731
+ const task = createWorkflowTask({ id: workflowRunId(), script, scriptPath: path, meta });
2732
+ workflowTasks.set(task.id, task);
2733
+ widget.update();
2734
+ fleet.update();
2735
+ report(`Running workflow ${meta.name}…`, "info");
2736
+
2737
+ // Detached: session_start is awaited by the host, and a workflow can run for
2738
+ // minutes — blocking here would hold the whole session's startup.
2739
+ void runWorkflowTask(ctx, task).then(() => {
2740
+ // No tool call to attach a result card to, so the card becomes a session
2741
+ // entry (same layout), and the outcome is handed to the model as context
2742
+ // for its next turn rather than forcing one.
2743
+ pi.appendEntry<WorkflowEntryData>(WORKFLOW_ENTRY_TYPE, workflowEntryData(task));
2744
+ pi.sendMessage({
2745
+ customType: "workflow-result",
2746
+ content: formatWorkflowNotification(task),
2747
+ display: false,
2748
+ }, { deliverAs: "nextTurn" });
2749
+ widget.update();
2750
+ fleet.update();
2751
+ });
2752
+ }
1496
2753
 
1497
2754
  // ---- get_subagent_result tool ----
1498
2755
 
1499
- pi.registerTool(defineTool({
2756
+ registerToolReportingUsage(defineTool({
1500
2757
  name: SUBAGENT_TOOL_NAMES.GET_RESULT,
1501
2758
  label: "Get Agent Result",
1502
2759
  description:
1503
- "Check status and retrieve results from a background agent. Use the agent ID returned by Agent with run_in_background.",
2760
+ "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
2761
  promptSnippet: "Check status and retrieve results from a background agent",
1505
2762
  parameters: Type.Object({
1506
2763
  agent_id: Type.String({
1507
- description: "The agent ID to check.",
2764
+ 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
2765
  }),
1509
2766
  wait: Type.Optional(
1510
2767
  Type.Boolean({
@@ -1518,8 +2775,8 @@ Terse command-style prompts produce shallow, generic work.
1518
2775
  ),
1519
2776
  }),
1520
2777
  execute: async (_toolCallId, params, signal, _onUpdate, _ctx) => {
1521
- const record = manager.getRecord(params.agent_id);
1522
- if (!record) {
2778
+ const record = resolveAgentRef(params.agent_id);
2779
+ if (!record || !isTopLevelAgent(record)) {
1523
2780
  return textResult(`Agent not found: "${params.agent_id}". It may have been cleaned up.`);
1524
2781
  }
1525
2782
 
@@ -1538,15 +2795,16 @@ Terse command-style prompts produce shallow, generic work.
1538
2795
  if (record.promise) await abortable(record.promise, signal);
1539
2796
  }
1540
2797
 
1541
- const durableResult = !record.result?.trim() && record.transcriptPath && currentCtx?.cwd
1542
- ? readAgentHistoryResult(currentCtx.cwd, record.transcriptPath)
1543
- : undefined;
1544
2798
  const displayName = getDisplayName(record.type);
1545
2799
  const duration = formatDuration(record.startedAt, record.completedAt);
1546
2800
  const tokens = formatLifetimeTokens(record);
1547
2801
  const contextPercent = getSessionContextPercent(record.session);
1548
2802
  const statsParts = [`Tool uses: ${record.toolUses}`];
1549
2803
  if (tokens) statsParts.push(tokens);
2804
+ if (showCost) {
2805
+ const costText = formatCost(getLifetimeCost(record.lifetimeUsage));
2806
+ if (costText) statsParts.push(`Cost: ${costText}`);
2807
+ }
1550
2808
  if (contextPercent !== null) statsParts.push(`Context: ${Math.round(contextPercent)}%`);
1551
2809
  if (record.compactionCount) statsParts.push(`Compactions: ${record.compactionCount}`);
1552
2810
  statsParts.push(`Duration: ${duration}`);
@@ -1559,9 +2817,9 @@ Terse command-style prompts produce shallow, generic work.
1559
2817
  if (record.status === "running") {
1560
2818
  output += "Agent is still running. Use wait: true or check back later.";
1561
2819
  } else if (record.status === "error") {
1562
- output += `Error: ${record.error}${partialOutputSuffix(record, durableResult)}`;
2820
+ output += `Error: ${record.error}${partialOutputSuffix(record)}`;
1563
2821
  } else {
1564
- output += durableResult || record.result?.trim() || "No output.";
2822
+ output += record.result?.trim() || "No output.";
1565
2823
  }
1566
2824
 
1567
2825
  // Mark result as consumed — suppresses the completion notification
@@ -1584,7 +2842,7 @@ Terse command-style prompts produce shallow, generic work.
1584
2842
 
1585
2843
  // ---- steer_subagent tool ----
1586
2844
 
1587
- pi.registerTool(defineTool({
2845
+ registerToolReportingUsage(defineTool({
1588
2846
  name: SUBAGENT_TOOL_NAMES.STEER,
1589
2847
  label: "Steer Agent",
1590
2848
  description:
@@ -1593,15 +2851,15 @@ Terse command-style prompts produce shallow, generic work.
1593
2851
  promptSnippet: "Send a steering message to redirect a running background agent",
1594
2852
  parameters: Type.Object({
1595
2853
  agent_id: Type.String({
1596
- description: "The agent ID to steer (must be currently running).",
2854
+ 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
2855
  }),
1598
2856
  message: Type.String({
1599
2857
  description: "The steering message to send. This will appear as a user message in the agent's conversation.",
1600
2858
  }),
1601
2859
  }),
1602
2860
  execute: async (_toolCallId, params, _signal, _onUpdate, _ctx) => {
1603
- const record = manager.getRecord(params.agent_id);
1604
- if (!record) {
2861
+ const record = resolveAgentRef(params.agent_id);
2862
+ if (!record || !isTopLevelAgent(record)) {
1605
2863
  return textResult(`Agent not found: "${params.agent_id}". It may have been cleaned up.`);
1606
2864
  }
1607
2865
  if (record.status !== "running") {
@@ -1622,6 +2880,10 @@ Terse command-style prompts produce shallow, generic work.
1622
2880
  const contextPercent = getSessionContextPercent(record.session);
1623
2881
  const stateParts: string[] = [];
1624
2882
  if (tokens) stateParts.push(tokens);
2883
+ if (showCost) {
2884
+ const costText = formatCost(getLifetimeCost(record.lifetimeUsage));
2885
+ if (costText) stateParts.push(costText);
2886
+ }
1625
2887
  stateParts.push(`${record.toolUses} tool ${record.toolUses === 1 ? "use" : "uses"}`);
1626
2888
  if (contextPercent !== null) stateParts.push(`context ${Math.round(contextPercent)}% full`);
1627
2889
  if (record.compactionCount) stateParts.push(`${record.compactionCount} compaction${record.compactionCount === 1 ? "" : "s"}`);
@@ -1637,20 +2899,9 @@ Terse command-style prompts produce shallow, generic work.
1637
2899
 
1638
2900
  // ---- /agents interactive menu ----
1639
2901
 
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
- }
2902
+ // Directory resolution and the frontmatter edits live in agent-file-toggle.ts
2903
+ // so they are reachable from tests — this command handler is only registered
2904
+ // through `registerCommand`, which every test mocks.
1654
2905
 
1655
2906
  function getModelLabel(type: string, registry?: ModelRegistry): string {
1656
2907
  const cfg = getAgentConfig(type);
@@ -1677,10 +2928,15 @@ Terse command-style prompts produce shallow, generic work.
1677
2928
  // Build select options
1678
2929
  const options: string[] = [];
1679
2930
 
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));
2931
+ // Keep active sessions and durable terminal history as separate menu rows.
2932
+ const agents = manager.listAgents().filter(isTopLevelAgent);
2933
+ const { active, history } = splitAgentRecords(agents, ctx.cwd);
2934
+ if (active.length > 0) {
2935
+ const running = active.filter(a => a.status === "running").length;
2936
+ const queued = active.filter(a => a.status === "queued").length;
2937
+ options.push(`Running agents (${active.length}) — ${running} running, ${queued} queued`);
2938
+ }
2939
+ if (history.length > 0) options.push(`Agent history (${history.length})`);
1684
2940
 
1685
2941
  // Agent types list
1686
2942
  if (allNames.length > 0) {
@@ -1693,11 +2949,17 @@ Terse command-style prompts produce shallow, generic work.
1693
2949
  options.push(`Scheduled jobs (${jobCount})`);
1694
2950
  }
1695
2951
 
2952
+ // Workflow runs, on the same terms as scheduled jobs: shown only when the
2953
+ // feature is on, so the menu never advertises something switched off.
2954
+ if (isWorkflowsEnabled()) {
2955
+ options.push(`Workflows (${workflowTasks.size})`);
2956
+ }
2957
+
1696
2958
  // Actions
1697
2959
  options.push("Create new agent");
1698
2960
  options.push("Settings");
1699
2961
 
1700
- const noAgentsMsg = allNames.length === 0 && active.length === 0 && history.length === 0
2962
+ const noAgentsMsg = allNames.length === 0 && agents.length === 0
1701
2963
  ? "No agents found. Create specialized subagents that can be delegated to.\n\n" +
1702
2964
  "Each subagent has its own context window, custom system prompt, and specific tools.\n\n" +
1703
2965
  "Try creating: Code Reviewer, Security Auditor, Test Writer, or Documentation Writer.\n\n"
@@ -1722,6 +2984,9 @@ Terse command-style prompts produce shallow, generic work.
1722
2984
  } else if (choice.startsWith("Scheduled jobs (")) {
1723
2985
  await showSchedulesMenu(ctx, scheduler);
1724
2986
  await showAgentsMenu(ctx);
2987
+ } else if (choice.startsWith("Workflows (")) {
2988
+ await showWorkflowsMenu(ctx, workflowMenuDeps);
2989
+ await showAgentsMenu(ctx);
1725
2990
  } else if (choice === "Create new agent") {
1726
2991
  await showCreateWizard(ctx);
1727
2992
  } else if (choice === "Settings") {
@@ -1798,164 +3063,78 @@ Terse command-style prompts produce shallow, generic work.
1798
3063
  }
1799
3064
  }
1800
3065
 
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
3066
  async function showRunningAgents(ctx: ExtensionCommandContext) {
1875
- const { active: agents } = splitAgentRecords(manager.listAgents(), ctx.cwd);
3067
+ const agents = manager.listAgents().filter(record => isTopLevelAgent(record) && (record.status === "running" || record.status === "queued"));
1876
3068
  if (agents.length === 0) {
1877
3069
  ctx.ui.notify("No agents.", "info");
1878
3070
  return;
1879
3071
  }
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}` };
3072
+ const record = await ctx.ui.custom<AgentRecord | undefined>((_tui, _theme, _keys, done) => {
3073
+ let index = Math.min(runningSelectionIndex, agents.length - 1);
3074
+ return {
3075
+ render: (width: number) => agents.map((agent, row) => `${row === index ? "→" : " "} ${agent.description}`.slice(0, width)),
3076
+ invalidate() {},
3077
+ handleInput(data: string) {
3078
+ if (data === "\u001b[B") index = Math.min(agents.length - 1, index + 1);
3079
+ else if (data === "\u001b[A") index = Math.max(0, index - 1);
3080
+ else if (data === "\r" || data === "\n") { runningSelectionIndex = index; done(agents[index]); }
3081
+ else if (data === "\u001b") { runningSelectionIndex = index; done(undefined); }
3082
+ },
3083
+ };
1885
3084
  });
1886
- makeUniqueAgentOptionLabels(pairs);
1887
-
1888
- const record = await selectAgentFromReadOnlyList(ctx, "Running agents", pairs, runningAgentSelection);
1889
3085
  if (!record) return;
1890
-
1891
- await viewAgentConversation(ctx, record, "live");
1892
- // Back-navigation: re-show the list at the previously selected agent.
3086
+ await viewAgentConversation(ctx, record);
1893
3087
  await showRunningAgents(ctx);
1894
3088
  }
1895
3089
 
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;
3090
+ async function showAgentHistory(ctx: ExtensionCommandContext): Promise<void> {
3091
+ const history = manager.listAgents().filter(record => isTopLevelAgent(record) && canOpenAgentHistory(record, ctx.cwd));
3092
+ if (history.length === 0) return;
3093
+ const selected = await ctx.ui.custom<AgentRecord | undefined>((_tui, _theme, _keys, done) => {
3094
+ let index = Math.min(historySelectionIndex, history.length - 1);
3095
+ return {
3096
+ render: (width: number) => history.map((record, row) => `${row === index ? "→" : " "} ${record.description}`.slice(0, width)),
3097
+ invalidate() {},
3098
+ handleInput(data: string) {
3099
+ if (data === "\u001b[B") index = Math.min(history.length - 1, index + 1);
3100
+ else if (data === "\u001b[A") index = Math.max(0, index - 1);
3101
+ else if (data === "\r" || data === "\n") { historySelectionIndex = index; done(history[index]); }
3102
+ else if (data === "\u001b") done(undefined);
3103
+ },
3104
+ };
3105
+ });
3106
+ if (selected) {
3107
+ await viewAgentConversation(ctx, selected);
3108
+ await showAgentHistory(ctx);
1901
3109
  }
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
3110
  }
1912
3111
 
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
-
3112
+ async function viewAgentConversation(ctx: ExtensionCommandContext, record: AgentRecord) {
1927
3113
  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
- })();
3114
+ const messages = record.transcriptPath ? readAgentHistory(ctx.cwd, record.transcriptPath) : undefined;
3115
+ const session = record.session ?? (messages ? createStaticConversationSource(messages) : undefined);
1940
3116
  if (!session) {
1941
- ctx.ui.notify("No agent history.", "info");
3117
+ ctx.ui.notify(`Agent is ${record.status === "queued" ? "queued" : "expired"} — no session available.`, "info");
1942
3118
  return;
1943
3119
  }
1944
-
3120
+ const isHistory = record.session === undefined;
1945
3121
  const activity = agentActivity.get(record.id);
1946
- const isLive = mode === "live";
3122
+
1947
3123
  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
- },
3124
+ (tui, theme, keybindings, done) => new ConversationViewer(
3125
+ tui,
3126
+ session,
3127
+ record,
3128
+ activity,
3129
+ theme,
3130
+ done,
3131
+ isHistory ? undefined : () => {
3132
+ if (manager.abort(record.id)) ctx.ui.notify(`Stopped "${record.description}".`, "info");
3133
+ },
3134
+ keybindings,
3135
+ isHistory ? undefined : (message: string) => manager.steer(record.id, message),
3136
+ { pi, ctx, readOnly: isHistory },
3137
+ ),
1959
3138
  {
1960
3139
  overlay: true,
1961
3140
  overlayOptions: { anchor: "center", width: "90%", maxHeight: `${VIEWPORT_HEIGHT_PCT}%` },
@@ -1970,7 +3149,7 @@ Terse command-style prompts produce shallow, generic work.
1970
3149
  return;
1971
3150
  }
1972
3151
 
1973
- const file = findAgentFile(name);
3152
+ const file = locateAgentFile(name, cfg.sourcePath);
1974
3153
  const isDefault = cfg.isDefault === true;
1975
3154
  const disabled = cfg.enabled === false;
1976
3155
 
@@ -2045,29 +3224,7 @@ Terse command-style prompts produce shallow, generic work.
2045
3224
  if (!overwrite) return;
2046
3225
  }
2047
3226
 
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`;
3227
+ const content = serializeAgentFile(cfg);
2071
3228
 
2072
3229
  const { writeFileSync } = await import("node:fs");
2073
3230
  writeFileSync(targetPath, content, "utf-8");
@@ -2077,15 +3234,21 @@ Terse command-style prompts produce shallow, generic work.
2077
3234
 
2078
3235
  /** Disable an agent: set enabled: false in its .md file, or create a stub for built-in defaults. */
2079
3236
  async function disableAgent(ctx: ExtensionCommandContext, name: string) {
2080
- const file = findAgentFile(name);
3237
+ const file = locateAgentFile(name, getAgentConfig(name)?.sourcePath);
2081
3238
  if (file) {
2082
3239
  // Existing file — set enabled: false in frontmatter (idempotent)
2083
3240
  const content = readFileSync(file.path, "utf-8");
2084
- if (content.includes("\nenabled: false\n")) {
3241
+ const { content: updated, outcome } = disableInContent(content);
3242
+ if (outcome === "already-disabled") {
2085
3243
  ctx.ui.notify(`${name} is already disabled.`, "info");
2086
3244
  return;
2087
3245
  }
2088
- const updated = content.replace(/^---\n/, "---\nenabled: false\n");
3246
+ if (outcome === "no-frontmatter") {
3247
+ // Nothing to edit — say so rather than rewriting the file unchanged and
3248
+ // reporting success for a change that never happened.
3249
+ ctx.ui.notify(`Cannot disable ${name}: ${file.path} has no frontmatter block.`, "error");
3250
+ return;
3251
+ }
2089
3252
  const { writeFileSync } = await import("node:fs");
2090
3253
  writeFileSync(file.path, updated, "utf-8");
2091
3254
  reloadCustomAgents();
@@ -2112,15 +3275,21 @@ Terse command-style prompts produce shallow, generic work.
2112
3275
 
2113
3276
  /** Enable a disabled agent by removing enabled: false from its frontmatter. */
2114
3277
  async function enableAgent(ctx: ExtensionCommandContext, name: string) {
2115
- const file = findAgentFile(name);
3278
+ const file = locateAgentFile(name, getAgentConfig(name)?.sourcePath);
2116
3279
  if (!file) return;
2117
3280
 
2118
3281
  const content = readFileSync(file.path, "utf-8");
2119
- const updated = content.replace(/^(---\n)enabled: false\n/, "$1");
3282
+ const { content: updated, changed } = enableInContent(content);
3283
+ if (!changed && !isEmptyStub(updated)) {
3284
+ // The file carries no `enabled: false` to remove, so it was never disabled
3285
+ // by us — reporting success here would hide a no-op.
3286
+ ctx.ui.notify(`${name} is not disabled in ${file.path}.`, "info");
3287
+ return;
3288
+ }
2120
3289
  const { writeFileSync } = await import("node:fs");
2121
3290
 
2122
3291
  // 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") {
3292
+ if (isEmptyStub(updated)) {
2124
3293
  unlinkSync(file.path);
2125
3294
  reloadCustomAgents();
2126
3295
  ctx.ui.notify(`Enabled ${name} (removed ${file.path})`, "info");
@@ -2179,6 +3348,7 @@ The file format is a markdown file with YAML frontmatter and a system prompt bod
2179
3348
  \`\`\`markdown
2180
3349
  ---
2181
3350
  description: <one-line description shown in UI>
3351
+ color: <optional agent name badge color: red, blue, green, yellow, purple, orange, pink, cyan, an Agency Agents alias, or quoted "#RRGGBB">
2182
3352
  tools: <comma-separated built-in tools: read, bash, edit, write, grep, find, ls. Use "none" for no tools. Omit for all tools>
2183
3353
  model: <optional model as "provider/modelId", e.g. "anthropic/claude-haiku-4-5". Omit to inherit parent model>
2184
3354
  thinking: <optional thinking level: ${THINKING_LEVELS.join(", ")}. Omit to inherit>
@@ -2188,11 +3358,18 @@ extensions: <true (inherit all MCP/extension tools), false (none), or comma-sepa
2188
3358
  skills: <true (inherit all), false (none), or comma-separated skill names to preload into prompt. Default: true>
2189
3359
  disallowed_tools: <comma-separated tool names to block, even if otherwise available. Omit for none>
2190
3360
  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>
3361
+ run_in_background: <pin this agent to background (true) or foreground (false). Omit to follow the backgroundByDefault setting, which is background>
2192
3362
  output_transcript: <false to write no transcript file or path for this agent. Independent of persist_session. Default: true>
2193
3363
  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>
3364
+ memory: <"user" (global), "project" (per-project), or "local" (gitignored per-project) for persistent memory. Omit for none>${
3365
+ // Offering the field on a project that turned worktrees off would bake a
3366
+ // request that is refused at spawn time into a file that outlives the
3367
+ // session — the #231 pathology (models fill the fields they are shown)
3368
+ // one layer up. Built per invocation, so this read is live.
3369
+ isWorktreeIsolationEnabled()
3370
+ ? `\nisolation: <"worktree" to run in isolated git worktree; "off" to refuse one even when the caller asks. Omit for normal>`
3371
+ : ""
3372
+ }
2196
3373
  ---
2197
3374
 
2198
3375
  <system prompt body — instructions for the agent>
@@ -2213,6 +3390,12 @@ Write the file using the write tool. Only write the file, nothing else.`;
2213
3390
  const { record } = await manager.spawnAndWait(pi, ctx, "general-purpose", generatePrompt, {
2214
3391
  description: `Generate ${name} agent`,
2215
3392
  maxTurns: 5,
3393
+ // Exempt from maxConcurrentForeground. This runs from a modal wizard, not
3394
+ // a tool call: it passes no signal, and Esc in `ctx.ui` never reaches the
3395
+ // manager — so a user waiting behind a full pool would have no way to
3396
+ // cancel at all. It is also one human action that cannot fan out, which
3397
+ // is what the limit exists to bound. It still counts once started.
3398
+ bypassQueue: true,
2216
3399
  });
2217
3400
 
2218
3401
  if (record.status === "error") {
@@ -2265,13 +3448,12 @@ Write the file using the write tool. Only write the file, nothing else.`;
2265
3448
  ]);
2266
3449
  if (!modelChoice) return;
2267
3450
 
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";
3451
+ let model: string | undefined;
3452
+ if (modelChoice === "haiku") model = "anthropic/claude-haiku-4-5";
3453
+ else if (modelChoice === "sonnet") model = "anthropic/claude-sonnet-4-6";
3454
+ else if (modelChoice === "opus") model = "anthropic/claude-opus-4-6";
2272
3455
  else if (modelChoice === "custom...") {
2273
- const customModel = await ctx.ui.input("Model (provider/modelId)");
2274
- if (customModel) modelLine = `\nmodel: ${customModel}`;
3456
+ model = (await ctx.ui.input("Model (provider/modelId)")) || undefined;
2275
3457
  }
2276
3458
 
2277
3459
  // 5. Thinking
@@ -2279,22 +3461,17 @@ Write the file using the write tool. Only write the file, nothing else.`;
2279
3461
  const thinkingChoice = await ctx.ui.select("Thinking level", ["inherit", ...THINKING_LEVELS]);
2280
3462
  if (!thinkingChoice) return;
2281
3463
 
2282
- let thinkingLine = "";
2283
- if (thinkingChoice !== "inherit") thinkingLine = `\nthinking: ${thinkingChoice}`;
2284
-
2285
3464
  // 6. System prompt
2286
3465
  const systemPrompt = await ctx.ui.editor("System prompt", "");
2287
3466
  if (systemPrompt === undefined) return;
2288
3467
 
2289
- // Build the file
2290
- const content = `---
2291
- description: ${description}
2292
- tools: ${tools}${modelLine}${thinkingLine}
2293
- prompt_mode: replace
2294
- ---
2295
-
2296
- ${systemPrompt}
2297
- `;
3468
+ const content = buildNewAgentFile({
3469
+ description,
3470
+ tools,
3471
+ model,
3472
+ thinking: thinkingChoice === "inherit" ? undefined : thinkingChoice,
3473
+ systemPrompt,
3474
+ });
2298
3475
 
2299
3476
  mkdirSync(targetDir, { recursive: true });
2300
3477
  const targetPath = join(targetDir, `${name}.md`);
@@ -2310,30 +3487,87 @@ ${systemPrompt}
2310
3487
  ctx.ui.notify(`Created ${targetPath}`, "info");
2311
3488
  }
2312
3489
 
2313
- function snapshotSettings(): SubagentsSettings {
3490
+ /**
3491
+ * Every settings mutation writes this WHOLE object back to disk, so a field
3492
+ * missing here is erased from the user's subagents.json the next time they
3493
+ * toggle something unrelated. `SubagentsSettings` has every field optional,
3494
+ * so a `: SubagentsSettings` return annotation would let a newly-added setting
3495
+ * be forgotten here and still type-check. `satisfies` instead: it still checks
3496
+ * each value's type and rejects a mistyped key, but leaves the return type
3497
+ * inferred so `_NoMissingSettingsKeys` below can check completeness.
3498
+ */
3499
+ function snapshotSettings() {
2314
3500
  return {
2315
3501
  maxConcurrent: manager.getMaxConcurrent(),
3502
+ // 0 = unlimited, and the default — see SubagentsSettings.
3503
+ maxConcurrentForeground: manager.getMaxConcurrentForeground(),
2316
3504
  // 0 = unlimited — per SubagentsSettings.defaultMaxTurns docstring and
2317
3505
  // normalizeMaxTurns() in agent-runner.ts (which maps 0 → undefined).
2318
3506
  defaultMaxTurns: getDefaultMaxTurns() ?? 0,
2319
3507
  graceTurns: getGraceTurns(),
2320
3508
  defaultJoinMode: getDefaultJoinMode(),
3509
+ backgroundByDefault: getBackgroundByDefault(),
2321
3510
  schedulingEnabled: isSchedulingEnabled(),
2322
3511
  scopeModels: isScopeModelsEnabled(),
3512
+ strictAgentFiles,
2323
3513
  disableDefaultAgents: isDefaultsDisabled(),
2324
3514
  toolDescriptionMode: getToolDescriptionMode(),
3515
+ fleetView: isFleetViewEnabled(),
3516
+ agentMentions: getAgentMentionMode(),
3517
+ rememberAgents: getRememberAgents(),
2325
3518
  widgetMode: getWidgetMode(),
2326
3519
  outputTranscript: getOutputTranscriptDefault(),
2327
- };
3520
+ worktreeIsolation: isWorktreeIsolationEnabled(),
3521
+ // The user's answer, not the effective one. A stand-down for another
3522
+ // extension's workflow tool is scoped to the session it was detected in;
3523
+ // writing it here would let an unrelated settings change three menus away
3524
+ // freeze it into the file as an explicit `false`, which then survives
3525
+ // uninstalling the extension it was deferring to. undefined is dropped by
3526
+ // JSON.stringify, so unset stays unset — same reasoning as
3527
+ // `fallbackSubagent` below.
3528
+ workflowsEnabled: isWorkflowsPinned() ? isWorkflowsEnabled() : undefined,
3529
+ maxSubagentDepth: getMaxSubagentDepth(),
3530
+ // Deliberately NOT `?? "general-purpose"`: every settings change writes the
3531
+ // whole snapshot, and materializing the implicit default would turn it into
3532
+ // explicit configuration — which then fails loudly if general-purpose later
3533
+ // goes away. undefined is dropped by JSON.stringify.
3534
+ fallbackSubagent: getFallbackSubagent(),
3535
+ reportUsage: isReportUsageEnabled(),
3536
+ showCost: isShowCostEnabled(),
3537
+ showModel: isShowModelEnabled(),
3538
+ viewerMarkdown: getViewerMarkdown(),
3539
+ } satisfies SubagentsSettings;
2328
3540
  }
2329
3541
 
2330
- const NUMERIC_IDS = new Set(["maxConcurrent", "defaultMaxTurns", "graceTurns"]);
3542
+ // Compile-time completeness guard for snapshotSettings(). If a field is added
3543
+ // to SubagentsSettings and not mirrored above, this Exclude is non-empty and
3544
+ // fails to satisfy `never` — turning a silent settings-erasure bug into a
3545
+ // typecheck error. `npm run typecheck` runs in CI.
3546
+ type _NoMissingSettingsKeys =
3547
+ Exclude<keyof SubagentsSettings, keyof ReturnType<typeof snapshotSettings>> extends never
3548
+ ? true
3549
+ : ["snapshotSettings() is missing a SubagentsSettings key"];
3550
+ const _settingsSnapshotIsComplete: _NoMissingSettingsKeys = true;
3551
+ void _settingsSnapshotIsComplete;
3552
+
3553
+ const NUMERIC_IDS = new Set([
3554
+ "maxConcurrent", "maxConcurrentForeground", "defaultMaxTurns", "graceTurns", "maxSubagentDepth",
3555
+ ]);
2331
3556
 
2332
3557
  async function showSettings(ctx: ExtensionCommandContext) {
2333
3558
  function buildItems(): SettingItem[] {
2334
3559
  const mc = manager.getMaxConcurrent();
3560
+ const mcf = manager.getMaxConcurrentForeground();
2335
3561
  const dmt = getDefaultMaxTurns() ?? 0;
2336
3562
  const gt = getGraceTurns();
3563
+ const msd = getMaxSubagentDepth();
3564
+ // Label what unset actually does — it targets general-purpose even when
3565
+ // that is unregistered (the permissive hardcoded tier), so showing "none"
3566
+ // there would advertise strict dispatch for the most permissive state.
3567
+ // `values` still offers only resolvable targets, so the user cannot
3568
+ // persist a fallback that would hard-error on every dispatch.
3569
+ const fallbackValue = getFallbackSubagent() ?? "general-purpose";
3570
+ const fallbackValues = [...new Set([...getAvailableTypes(), NO_FALLBACK])];
2337
3571
 
2338
3572
  return [
2339
3573
  {
@@ -2343,6 +3577,13 @@ ${systemPrompt}
2343
3577
  currentValue: String(mc),
2344
3578
  values: [String(mc)],
2345
3579
  },
3580
+ {
3581
+ id: "maxConcurrentForeground",
3582
+ label: "Max foreground concurrency",
3583
+ description: "Max concurrent foreground (blocking) agents (0 = unlimited, Enter to type)",
3584
+ currentValue: String(mcf),
3585
+ values: [String(mcf)],
3586
+ },
2346
3587
  {
2347
3588
  id: "defaultMaxTurns",
2348
3589
  label: "Default max turns",
@@ -2357,6 +3598,13 @@ ${systemPrompt}
2357
3598
  currentValue: String(gt),
2358
3599
  values: [String(gt)],
2359
3600
  },
3601
+ {
3602
+ id: "maxSubagentDepth",
3603
+ label: "Nested depth",
3604
+ description: "Hard cap on nested delegation — main is 0, its subagents 1 (0/1 = nesting off, Enter to type)",
3605
+ currentValue: String(msd),
3606
+ values: [String(msd)],
3607
+ },
2360
3608
  {
2361
3609
  id: "joinMode",
2362
3610
  label: "Join mode",
@@ -2364,6 +3612,13 @@ ${systemPrompt}
2364
3612
  currentValue: getDefaultJoinMode(),
2365
3613
  values: ["smart", "async", "group"],
2366
3614
  },
3615
+ {
3616
+ id: "backgroundByDefault",
3617
+ label: "Background by default",
3618
+ description: "An Agent call that doesn't say runs detached (off = blocks the turn and returns inline)",
3619
+ currentValue: getBackgroundByDefault() ? "on" : "off",
3620
+ values: ["on", "off"],
3621
+ },
2367
3622
  {
2368
3623
  id: "schedulingEnabled",
2369
3624
  label: "Scheduling",
@@ -2371,6 +3626,15 @@ ${systemPrompt}
2371
3626
  currentValue: isSchedulingEnabled() ? "on" : "off",
2372
3627
  values: ["on", "off"],
2373
3628
  },
3629
+ {
3630
+ id: "workflowsEnabled",
3631
+ label: "Workflows",
3632
+ description:
3633
+ "Scripted workflows, on unless another extension provides a workflow tool "
3634
+ + "(off keeps the SubagentWorkflow tool out of the tool spec; applies on next pi session)",
3635
+ currentValue: isWorkflowsEnabled() ? "on" : "off",
3636
+ values: ["on", "off"],
3637
+ },
2374
3638
  {
2375
3639
  id: "scopeModels",
2376
3640
  label: "Scope models",
@@ -2378,6 +3642,13 @@ ${systemPrompt}
2378
3642
  currentValue: isScopeModelsEnabled() ? "on" : "off",
2379
3643
  values: ["on", "off"],
2380
3644
  },
3645
+ {
3646
+ id: "strictAgentFiles",
3647
+ label: "Strict agent files",
3648
+ description: "Fail startup on an unreadable/unparseable agent .md instead of skipping it with a warning",
3649
+ currentValue: strictAgentFiles ? "on" : "off",
3650
+ values: ["on", "off"],
3651
+ },
2381
3652
  {
2382
3653
  id: "disableDefaultAgents",
2383
3654
  label: "Disable defaults",
@@ -2385,6 +3656,13 @@ ${systemPrompt}
2385
3656
  currentValue: isDefaultsDisabled() ? "on" : "off",
2386
3657
  values: ["on", "off"],
2387
3658
  },
3659
+ {
3660
+ id: "fallbackSubagent",
3661
+ label: "Fallback agent",
3662
+ description: `Agent used when subagent_type is unknown, disabled, or ambiguous; "${NO_FALLBACK}" rejects the call instead (strict dispatch)`,
3663
+ currentValue: fallbackValue,
3664
+ values: fallbackValues,
3665
+ },
2388
3666
  {
2389
3667
  id: "outputTranscript",
2390
3668
  label: "Output transcript",
@@ -2392,6 +3670,67 @@ ${systemPrompt}
2392
3670
  currentValue: getOutputTranscriptDefault() ? "on" : "off",
2393
3671
  values: ["on", "off"],
2394
3672
  },
3673
+ {
3674
+ id: "worktreeIsolation",
3675
+ label: "Worktree isolation",
3676
+ description:
3677
+ "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.",
3678
+ currentValue: isWorktreeIsolationEnabled() ? "on" : "off",
3679
+ values: ["on", "off"],
3680
+ },
3681
+ {
3682
+ id: "reportUsage",
3683
+ label: "Report usage to session",
3684
+ description:
3685
+ "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.",
3686
+ currentValue: isReportUsageEnabled() ? "on" : "off",
3687
+ values: ["on", "off"],
3688
+ },
3689
+ {
3690
+ id: "showCost",
3691
+ label: "Show cost",
3692
+ description:
3693
+ "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.",
3694
+ currentValue: isShowCostEnabled() ? "on" : "off",
3695
+ values: ["on", "off"],
3696
+ },
3697
+ {
3698
+ id: "showModel",
3699
+ label: "Show model",
3700
+ description:
3701
+ "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.",
3702
+ currentValue: isShowModelEnabled() ? "on" : "off",
3703
+ values: ["on", "off"],
3704
+ },
3705
+ {
3706
+ id: "viewerMarkdown",
3707
+ label: "Viewer markdown",
3708
+ description:
3709
+ "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+).",
3710
+ currentValue: getViewerMarkdown(),
3711
+ values: ["off", "assistant", "all"],
3712
+ },
3713
+ {
3714
+ id: "fleetView",
3715
+ label: "Fleet view",
3716
+ description: "Claude Code-style main+subagents list below the editor (↓/← to navigate, Enter to view)",
3717
+ currentValue: isFleetViewEnabled() ? "on" : "off",
3718
+ values: ["on", "off"],
3719
+ },
3720
+ {
3721
+ id: "agentMentions",
3722
+ label: "Agent mentions",
3723
+ 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.",
3724
+ currentValue: getAgentMentionMode(),
3725
+ values: ["model", "direct", "off"],
3726
+ },
3727
+ {
3728
+ id: "rememberAgents",
3729
+ label: "Remember agents",
3730
+ description: "Persist subagent sessions so `@handle` can resume one long after it finished (they also appear in /resume)",
3731
+ currentValue: getRememberAgents() ? "on" : "off",
3732
+ values: ["on", "off"],
3733
+ },
2395
3734
  {
2396
3735
  id: "widgetMode",
2397
3736
  label: "Widget",
@@ -2416,6 +3755,15 @@ ${systemPrompt}
2416
3755
  manager.setMaxConcurrent(n);
2417
3756
  notifyApplied(ctx, `Max concurrency set to ${n}`);
2418
3757
  }
3758
+ } else if (id === "maxConcurrentForeground") {
3759
+ // 0 is meaningful here, unlike maxConcurrent above: it means unlimited.
3760
+ const n = parseInt(value, 10);
3761
+ if (n >= 0) {
3762
+ manager.setMaxConcurrentForeground(n);
3763
+ notifyApplied(ctx, n === 0
3764
+ ? "Max foreground concurrency set to unlimited"
3765
+ : `Max foreground concurrency set to ${n}`);
3766
+ }
2419
3767
  } else if (id === "defaultMaxTurns") {
2420
3768
  const n = parseInt(value, 10);
2421
3769
  if (n === 0) {
@@ -2431,9 +3779,29 @@ ${systemPrompt}
2431
3779
  setGraceTurns(n);
2432
3780
  notifyApplied(ctx, `Grace turns set to ${n}`);
2433
3781
  }
3782
+ } else if (id === "maxSubagentDepth") {
3783
+ const n = parseInt(value, 10);
3784
+ if (n >= 0) {
3785
+ setMaxSubagentDepth(n);
3786
+ notifyApplied(
3787
+ ctx,
3788
+ n <= 1
3789
+ ? "Nested delegation disabled"
3790
+ : `Nested depth set to ${n}. Applies to agents started from now on.`,
3791
+ );
3792
+ }
2434
3793
  } else if (id === "joinMode") {
2435
3794
  setDefaultJoinMode(value as JoinMode);
2436
3795
  notifyApplied(ctx, `Default join mode set to ${value}`);
3796
+ } else if (id === "backgroundByDefault") {
3797
+ const enabled = value === "on";
3798
+ setBackgroundByDefault(enabled);
3799
+ notifyApplied(
3800
+ ctx,
3801
+ enabled
3802
+ ? "Agent calls run in the background unless they pass run_in_background: false"
3803
+ : "Agent calls block and return inline unless they pass run_in_background: true",
3804
+ );
2437
3805
  } else if (id === "schedulingEnabled") {
2438
3806
  const enabled = value === "on";
2439
3807
  if (enabled === isSchedulingEnabled()) {
@@ -2446,21 +3814,95 @@ ${systemPrompt}
2446
3814
  `Scheduling ${enabled ? "enabled" : "disabled"}. Tool spec change takes effect on next pi session.`,
2447
3815
  );
2448
3816
  }
3817
+ } else if (id === "workflowsEnabled") {
3818
+ const enabled = value === "on";
3819
+ if (enabled === isWorkflowsEnabled()) {
3820
+ ctx.ui.notify(`Workflows already ${enabled ? "enabled" : "disabled"}.`, "info");
3821
+ } else {
3822
+ setWorkflowsEnabled(enabled);
3823
+ // Runs already in flight keep going: the switch governs whether the
3824
+ // tool is offered, and killing live agents on a settings toggle would
3825
+ // lose work the user never asked to discard.
3826
+ notifyApplied(
3827
+ ctx,
3828
+ `Workflows ${enabled ? "enabled" : "disabled"}. Tool spec change takes effect on next pi session.`,
3829
+ );
3830
+ }
2449
3831
  } else if (id === "scopeModels") {
2450
3832
  const enabled = value === "on";
2451
3833
  setScopeModelsEnabled(enabled);
2452
3834
  notifyApplied(ctx, `Scope models ${enabled ? "enabled" : "disabled"}`);
3835
+ } else if (id === "strictAgentFiles") {
3836
+ const enabled = value === "on";
3837
+ strictAgentFiles = enabled;
3838
+ notifyApplied(ctx, `Strict agent files ${enabled ? "enabled" : "disabled"}. Takes effect on next pi session.`);
2453
3839
  } else if (id === "disableDefaultAgents") {
2454
3840
  const enabled = value === "on";
2455
3841
  setDisableDefaultAgents(enabled);
2456
3842
  notifyApplied(ctx, `Default agents ${enabled ? "disabled" : "enabled"}. Tool spec change takes effect on next pi session.`);
3843
+ } else if (id === "fallbackSubagent") {
3844
+ setFallbackSubagent(value);
3845
+ notifyApplied(
3846
+ ctx,
3847
+ value === NO_FALLBACK
3848
+ ? "Unknown or disabled agent types will now be rejected"
3849
+ : `Unknown agent types will fall back to ${value}`,
3850
+ );
2457
3851
  } else if (id === "outputTranscript") {
2458
3852
  const enabled = value === "on";
2459
- setOutputTranscript(enabled);
3853
+ setOutputTranscriptDefault(enabled);
2460
3854
  notifyApplied(ctx, `Output transcript ${enabled ? "enabled" : "disabled"} by default`);
3855
+ } else if (id === "worktreeIsolation") {
3856
+ const enabled = value === "on";
3857
+ setWorktreeIsolationEnabled(enabled);
3858
+ // The refusal is live, but the tool schema is built at registration, so
3859
+ // the isolation parameter only appears/disappears next session.
3860
+ notifyApplied(
3861
+ ctx,
3862
+ `Worktree isolation ${enabled ? "enabled" : "disabled"}. Tool parameter updates on next pi session.`,
3863
+ );
2461
3864
  } else if (id === "toolDescriptionMode") {
2462
3865
  setToolDescriptionMode(value as ToolDescriptionMode);
2463
3866
  notifyApplied(ctx, `Tool description set to ${value}. Takes effect on next pi session.`);
3867
+ } else if (id === "reportUsage") {
3868
+ const enabled = value === "on";
3869
+ setReportUsage(enabled);
3870
+ notifyApplied(
3871
+ ctx,
3872
+ enabled
3873
+ ? "Subagent usage now counted in this session's totals"
3874
+ : "Subagent usage no longer counted in this session's totals",
3875
+ );
3876
+ } else if (id === "showCost") {
3877
+ const enabled = value === "on";
3878
+ setShowCost(enabled);
3879
+ notifyApplied(ctx, `Cost display ${enabled ? "enabled" : "disabled"}`);
3880
+ } else if (id === "showModel") {
3881
+ const enabled = value === "on";
3882
+ setShowModel(enabled);
3883
+ notifyApplied(ctx, `Model display ${enabled ? "enabled" : "disabled"}`);
3884
+ } else if (id === "viewerMarkdown") {
3885
+ setViewerMarkdown(value as ViewerMarkdownMode);
3886
+ notifyApplied(ctx, `Viewer markdown set to ${value}`);
3887
+ } else if (id === "fleetView") {
3888
+ const enabled = value === "on";
3889
+ setFleetViewEnabled(enabled);
3890
+ notifyApplied(ctx, `Fleet view ${enabled ? "enabled" : "disabled"}`);
3891
+ } else if (id === "agentMentions") {
3892
+ const mode = value as AgentMentionMode;
3893
+ setAgentMentionMode(mode);
3894
+ notifyApplied(
3895
+ ctx,
3896
+ mode === "off"
3897
+ ? "Agent mentions disabled"
3898
+ : mode === "model"
3899
+ ? "Agent mentions on — a conversation clone starts a mentioned agent off-screen"
3900
+ : "Agent mentions on — a mentioned agent starts here, with no model call",
3901
+ );
3902
+ } else if (id === "rememberAgents") {
3903
+ const enabled = value === "on";
3904
+ setRememberAgents(enabled);
3905
+ notifyApplied(ctx, `Remember agents ${enabled ? "enabled" : "disabled"}`);
2464
3906
  } else if (id === "widgetMode") {
2465
3907
  setWidgetMode(value as WidgetMode);
2466
3908
  notifyApplied(ctx, `Widget set to ${value}`);
@@ -2515,15 +3957,23 @@ ${systemPrompt}
2515
3957
  if (result && NUMERIC_IDS.has(result)) {
2516
3958
  const current = result === "maxConcurrent"
2517
3959
  ? String(manager.getMaxConcurrent())
2518
- : result === "defaultMaxTurns"
2519
- ? String(getDefaultMaxTurns() ?? 0)
2520
- : String(getGraceTurns());
3960
+ : result === "maxConcurrentForeground"
3961
+ ? String(manager.getMaxConcurrentForeground())
3962
+ : result === "defaultMaxTurns"
3963
+ ? String(getDefaultMaxTurns() ?? 0)
3964
+ : result === "maxSubagentDepth"
3965
+ ? String(getMaxSubagentDepth())
3966
+ : String(getGraceTurns());
2521
3967
 
2522
3968
  const label = result === "maxConcurrent"
2523
3969
  ? "Max concurrency (1+)"
2524
- : result === "defaultMaxTurns"
2525
- ? "Default max turns (0 = unlimited)"
2526
- : "Grace turns (1+)";
3970
+ : result === "maxConcurrentForeground"
3971
+ ? "Max foreground concurrency (0 = unlimited)"
3972
+ : result === "defaultMaxTurns"
3973
+ ? "Default max turns (0 = unlimited)"
3974
+ : result === "maxSubagentDepth"
3975
+ ? "Nested depth (0/1 = nesting off)"
3976
+ : "Grace turns (1+)";
2527
3977
 
2528
3978
  // Loop until user enters a valid integer or cancels (Esc / null).
2529
3979
  // Silently trims whitespace; rejects non-numeric input by re-prompting.
@@ -2546,6 +3996,27 @@ ${systemPrompt}
2546
3996
  // the right toast. Successful saves show info; persistence failures downgrade
2547
3997
  // to warning so users aren't silently reverted on restart. Event fires regardless
2548
3998
  // of outcome so listeners see the in-memory change.
3999
+ /**
4000
+ * Persist + broadcast the settings, silent on success — for a change whose
4001
+ * feedback is the UI it just changed: the viewer's `m` key, where a
4002
+ * notification per press would talk over the overlay it is describing.
4003
+ *
4004
+ * A *failed* write still speaks. Every other settings path warns when the
4005
+ * value is session-only, and swallowing it here would leave a preference
4006
+ * looking persisted when the next session will not have it.
4007
+ */
4008
+ function persistSettings(ctx: ExtensionCommandContext | undefined, changeMsg: string): void {
4009
+ const { message, level } = saveAndEmitChanged(
4010
+ snapshotSettings(),
4011
+ changeMsg,
4012
+ (event, payload) => pi.events.emit(event, payload),
4013
+ );
4014
+ // `ctx` is absent only on the fleet path between sessions, where
4015
+ // `currentCtx` has been cleared and there is no UI to carry the warning to.
4016
+ // The write still happens.
4017
+ if (level === "warning") ctx?.ui.notify(message, level);
4018
+ }
4019
+
2549
4020
  function notifyApplied(ctx: ExtensionCommandContext, successMsg: string) {
2550
4021
  const { message, level } = saveAndEmitChanged(
2551
4022
  snapshotSettings(),
@@ -2559,4 +4030,20 @@ ${systemPrompt}
2559
4030
  description: "Manage agents",
2560
4031
  handler: async (_args, ctx) => { await showAgentsMenu(ctx); },
2561
4032
  });
4033
+
4034
+ /**
4035
+ * What `/agents → Workflows` and the fleet list's `workflow` rows need from
4036
+ * here. One object, built once: both entry points open the same inspector,
4037
+ * and handing them different views of the session would let the two drift.
4038
+ */
4039
+ const workflowMenuDeps: WorkflowMenuDeps = {
4040
+ tasks: workflowTasks,
4041
+ getRecord: id => manager.getRecord(id),
4042
+ viewAgentConversation,
4043
+ // Read lazily: `currentCtx` is rebound on every session_start, and the
4044
+ // fleet list may act between sessions, when there is none.
4045
+ getCtx: () => currentCtx as unknown as ExtensionCommandContext | undefined,
4046
+ };
4047
+
4048
+ fleet.setWorkflowSource(fleetWorkflows, id => openWorkflowFromFleet(id, workflowMenuDeps));
2562
4049
  }