@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/dist/index.js CHANGED
@@ -9,67 +9,58 @@
9
9
  * Commands:
10
10
  * /agents — Interactive agent management menu
11
11
  */
12
- import { existsSync, mkdirSync, readFileSync, unlinkSync } from "node:fs";
13
- import { join } from "node:path";
14
- import { defineTool, getAgentDir, getSelectListTheme, getSettingsListTheme } from "@earendil-works/pi-coding-agent";
15
- import { Container, Key, matchesKey, SelectList, SettingsList, Spacer, Text } from "@earendil-works/pi-tui";
12
+ import { existsSync, mkdirSync, readFileSync, unlinkSync, writeFileSync } from "node:fs";
13
+ import { isAbsolute, join } from "node:path";
14
+ import { defineTool, getAgentDir, getSettingsListTheme } from "@earendil-works/pi-coding-agent";
15
+ import { Container, Key, matchesKey, SettingsList, Spacer, Text } from "@earendil-works/pi-tui";
16
16
  import { Type } from "@sinclair/typebox";
17
- import { agentHistoryLocator, createAgentHistoryPath, readAgentHistory, readAgentHistoryResult } from "./agent-history.js";
18
- import { buildAgentStatusMenuEntries, canOpenActiveAgent, canOpenAgentHistory, formatAgentHistoryOption, splitAgentRecords } from "./agent-history-list.js";
19
- import { AgentManager } from "./agent-manager.js";
20
- import { getAgentConversation, getDefaultMaxTurns, getGraceTurns, normalizeMaxTurns, SUBAGENT_TOOL_NAMES, setDefaultMaxTurns, setGraceTurns, steerAgent } from "./agent-runner.js";
21
- import { applyNicoOverrides, BUILTIN_TOOL_NAMES, getAgentConfig, getAllTypes, getAvailableTypes, isDefaultsDisabled, registerAgents, resolveType, setDefaultsDisabled } from "./agent-types.js";
17
+ import { abortable } from "./abortable.js";
18
+ import { hasAgentBadge, renderAgentName } from "./agent-color.js";
19
+ import { buildNewAgentFile, disableInContent, enableInContent, isEmptyStub, locateAgentFile, personalAgentsDir, projectAgentsDir, serializeAgentFile } from "./agent-file-toggle.js";
20
+ import { readAgentHistory } from "./agent-history.js";
21
+ import { canOpenAgentHistory, splitAgentRecords } from "./agent-history-list.js";
22
+ import { AgentManager, isTopLevelAgent } from "./agent-manager.js";
23
+ import { getAgentConversation, getDefaultMaxTurns, getGraceTurns, getRememberAgents, normalizeMaxTurns, resolveEffectiveMaxTurns, SUBAGENT_TOOL_NAMES, setDefaultMaxTurns, setGraceTurns, setRememberAgents, steerAgent } from "./agent-runner.js";
24
+ import { BUILTIN_TOOL_NAMES, getAgentConfig, getAllTypes, getAvailableTypes, getConfig, getFallbackSubagent, isDefaultsDisabled, NO_FALLBACK, registerAgents, resolveSpawnType, resolveType, setDefaultsDisabled, setFallbackSubagent } from "./agent-types.js";
25
+ import { inChildSessionContext } from "./child-context.js";
22
26
  import { registerRpcHandlers } from "./cross-extension-rpc.js";
23
27
  import { loadCustomAgents } from "./custom-agents.js";
24
- import { isModelInScope, readEnabledModels, resolveEnabledModels } from "./enabled-models.js";
25
28
  import { GroupJoinManager } from "./group-join.js";
26
- import { resolveAgentInvocationConfig, resolveJoinMode } from "./invocation-config.js";
27
- import { resolveModel } from "./model-resolver.js";
28
- import { createOutputFilePath, streamToOutputFile, writeInitialEntry } from "./output-file.js";
29
+ import { isolationParam, resolveAgentInvocationConfig, resolveJoinMode } from "./invocation-config.js";
30
+ import { describeMention, handleBase, isReservedHandle, parseMention, resolveHandleToType, stripAgentPrefix } from "./mention.js";
31
+ import { runMentionClone } from "./mention-clone.js";
32
+ import { describeModel, resolveModel } from "./model-resolver.js";
33
+ import { checkModelScope, isScopeModelsEnabled, setScopeModelsEnabled } from "./model-scope.js";
34
+ import { getMaxSubagentDepth, setMaxSubagentDepth } from "./nested-tools.js";
35
+ import { createOutputFilePath, ensureOutputFile, getOutputTranscriptDefault, sessionTaskDir, setOutputTranscriptDefault, streamToOutputFile, writeInitialEntry } from "./output-file.js";
29
36
  import { SubagentScheduler } from "./schedule.js";
30
37
  import { resolveStorePath, ScheduleStore } from "./schedule-store.js";
31
- import { applyAndEmitLoaded, saveAndEmitChanged } from "./settings.js";
32
- import { getStatusNote } from "./status-note.js";
33
- import { AgentWidget, buildInvocationTags, describeActivity, fgPreservingNestedStyles, formatDuration, formatMs, formatTokens, formatTurns, getDisplayName, getPromptModeLabel, SPINNER, } from "./ui/agent-widget.js";
38
+ import { applyAndEmitLoaded, loadSettings, saveAndEmitChanged } from "./settings.js";
39
+ import { getForegroundOutcomeNote, getStatusNote, partialOutputSuffix } from "./status-note.js";
40
+ import { createMentionProvider, mentionRoster } from "./ui/agent-mention.js";
41
+ import { AgentWidget, buildInvocationTags, describeActivity, fgPreservingNestedStyles, formatCost, formatDuration, formatMs, formatTokens, formatTurns, getDisplayName, getPromptModeLabel, SPINNER, } from "./ui/agent-widget.js";
42
+ import { FleetList } from "./ui/fleet-list.js";
34
43
  import { showSchedulesMenu } from "./ui/schedule-menu.js";
35
- import { addUsage, getLifetimeTotal, getSessionContextPercent } from "./usage.js";
44
+ import { renderWorkflowCard, renderWorkflowEntryCard } from "./ui/workflow-card.js";
45
+ import { openWorkflowFromFleet, showWorkflowsMenu } from "./ui/workflow-menu.js";
46
+ import { getLifetimeCost, getLifetimeTotal, getSessionContextPercent, PendingUsagePool, toReportedUsage } from "./usage.js";
47
+ import { decideWorkflowCollision, FOREIGN_WORKFLOW_TOOL_NAMES } from "./workflow/collisions.js";
48
+ import { WORKFLOW_ENTRY_TYPE, workflowEntryData } from "./workflow/entry.js";
49
+ import { createWorkflowHost } from "./workflow/host.js";
50
+ import { appendJournal, readJournal } from "./workflow/journal.js";
51
+ import { extractMeta, workflowCallName } from "./workflow/meta.js";
52
+ import { elapsedMs } from "./workflow/progress.js";
53
+ import { runWorkflow } from "./workflow/runtime.js";
54
+ import { resolveWorkflowScript } from "./workflow/saved.js";
55
+ import { completeWorkflowTask, createWorkflowTask, failWorkflowTask, formatWorkflowNotification, resolveResumeTarget, updateWorkflowProgressBatch, workflowResultText, workflowRunId } from "./workflow/task.js";
56
+ import { fullWorkflowToolDescription } from "./workflow/tool-description.js";
57
+ import { isWorktreeIsolationEnabled, setWorktreeIsolationEnabled } from "./worktree.js";
58
+ import { escapeXml } from "./xml.js";
36
59
  // ---- Shared helpers ----
37
60
  /** Tool execute return value for a text response. */
38
61
  function textResult(msg, details) {
39
62
  return { content: [{ type: "text", text: msg }], details: details };
40
63
  }
41
- /** Await a promise until it settles or the caller cancels, without aborting the underlying work. */
42
- function abortable(promise, signal) {
43
- if (!signal)
44
- return promise;
45
- if (signal.aborted)
46
- return Promise.reject(signal.reason);
47
- return new Promise((resolve, reject) => {
48
- let settled = false;
49
- const cleanup = () => signal.removeEventListener("abort", onAbort);
50
- const onAbort = () => {
51
- if (settled)
52
- return;
53
- settled = true;
54
- cleanup();
55
- reject(signal.reason);
56
- };
57
- signal.addEventListener("abort", onAbort, { once: true });
58
- promise.then((value) => {
59
- if (settled)
60
- return;
61
- settled = true;
62
- cleanup();
63
- resolve(value);
64
- }, (error) => {
65
- if (settled)
66
- return;
67
- settled = true;
68
- cleanup();
69
- reject(error);
70
- });
71
- });
72
- }
73
64
  export function renderRunningAgentStatus(frame, statsText, activity, theme) {
74
65
  const container = new Container();
75
66
  container.addChild(new Text(theme.fg("accent", frame) + (statsText ? " " + statsText : ""), 0, 0));
@@ -122,8 +113,9 @@ function createActivityTracker(maxTurns, onStreamUpdate) {
122
113
  onSessionCreated: (session) => {
123
114
  state.session = session;
124
115
  },
125
- onAssistantUsage: (usage) => {
126
- addUsage(state.lifetimeUsage, usage);
116
+ // Spend is accumulated on the AgentRecord (agent-manager), which is what
117
+ // every surface reads; this callback exists here only to repaint on it.
118
+ onAssistantUsage: (_usage) => {
127
119
  onStreamUpdate?.();
128
120
  },
129
121
  };
@@ -137,15 +129,6 @@ function createActivityTracker(maxTurns, onStreamUpdate) {
137
129
  * host pi version and the selected model — pi clamps unsupported levels down.
138
130
  */
139
131
  const THINKING_LEVELS = ["off", "minimal", "low", "medium", "high", "xhigh", "max"];
140
- /**
141
- * Salvaged partial output of a failed run, as a labeled suffix for the error
142
- * surfaces (or "" if the run produced nothing). `record.result` is bounded to
143
- * the run's own turns, so this is never a stale earlier answer (#144).
144
- */
145
- function partialOutputSuffix(record, fallback) {
146
- const partial = record.result?.trim() || fallback?.trim();
147
- return partial ? `\n\nPartial output before the failure:\n${partial}` : "";
148
- }
149
132
  /** Human-readable status label for agent completion. */
150
133
  function getStatusLabel(status, error) {
151
134
  switch (status) {
@@ -156,18 +139,18 @@ function getStatusLabel(status, error) {
156
139
  default: return "Done";
157
140
  }
158
141
  }
159
- /** Escape XML special characters to prevent injection in structured notifications. */
160
- function escapeXml(s) {
161
- return s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
162
- }
163
142
  /** Format a structured task notification matching Claude Code's <task-notification> XML. */
164
- function formatTaskNotification(record, resultMaxLen) {
143
+ function formatTaskNotification(record, resultMaxLen, showCost = false) {
165
144
  const status = getStatusLabel(record.status, record.error);
166
145
  const durationMs = record.completedAt ? record.completedAt - record.startedAt : 0;
167
146
  const totalTokens = getLifetimeTotal(record.lifetimeUsage);
168
147
  const contextPercent = getSessionContextPercent(record.session);
169
148
  const ctxXml = contextPercent !== null ? `<context_percent>${Math.round(contextPercent)}</context_percent>` : "";
170
149
  const compactXml = record.compactionCount ? `<compactions>${record.compactionCount}</compactions>` : "";
150
+ // Only under `showCost`: this is LLM context, and a figure the orchestrator
151
+ // did not ask for is a figure it may start reporting unprompted.
152
+ const cost = showCost ? getLifetimeCost(record.lifetimeUsage) : 0;
153
+ const costXml = cost > 0 ? `<estimated_cost_usd>${cost.toFixed(4)}</estimated_cost_usd>` : "";
171
154
  const resultPreview = record.result
172
155
  ? record.result.length > resultMaxLen
173
156
  ? record.result.slice(0, resultMaxLen) + "\n...(truncated, use get_subagent_result for full output)"
@@ -181,7 +164,7 @@ function formatTaskNotification(record, resultMaxLen) {
181
164
  `<status>${escapeXml(status)}</status>`,
182
165
  `<summary>Agent "${escapeXml(record.description)}" ${record.status}${getStatusNote(record.status)}</summary>`,
183
166
  `<result>${escapeXml(resultPreview)}</result>`,
184
- `<usage><total_tokens>${totalTokens}</total_tokens><tool_uses>${record.toolUses}</tool_uses>${ctxXml}${compactXml}<duration_ms>${durationMs}</duration_ms></usage>`,
167
+ `<usage><total_tokens>${totalTokens}</total_tokens><tool_uses>${record.toolUses}</tool_uses>${ctxXml}${compactXml}${costXml}<duration_ms>${durationMs}</duration_ms></usage>`,
185
168
  `</task-notification>`,
186
169
  ].filter(Boolean).join('\n');
187
170
  }
@@ -191,6 +174,10 @@ function buildDetails(base, record, activity, overrides) {
191
174
  ...base,
192
175
  toolUses: record.toolUses,
193
176
  tokens: formatLifetimeTokens(record),
177
+ // Raw, and unconditional: `tokens` is preformatted because it is one stat,
178
+ // but a cost is joined by "·" in one surface, "," in another and "|" in a
179
+ // third — so it travels as a number and each renderer punctuates its own.
180
+ cost: getLifetimeCost(record.lifetimeUsage),
194
181
  turnCount: activity?.turnCount,
195
182
  maxTurns: activity?.maxTurns,
196
183
  durationMs: (record.completedAt ?? Date.now()) - record.startedAt,
@@ -211,6 +198,10 @@ function buildNotificationDetails(record, resultMaxLen, activity) {
211
198
  turnCount: activity?.turnCount ?? 0,
212
199
  maxTurns: activity?.maxTurns,
213
200
  totalTokens,
201
+ // Carried unconditionally; the renderer gates on the setting. Details are
202
+ // data, and a notification rendered before a mid-session toggle should not
203
+ // be stuck with the old answer.
204
+ totalCost: getLifetimeCost(record.lifetimeUsage),
214
205
  durationMs: record.completedAt ? record.completedAt - record.startedAt : 0,
215
206
  outputFile: record.outputFile,
216
207
  error: record.error,
@@ -221,7 +212,56 @@ function buildNotificationDetails(record, resultMaxLen, activity) {
221
212
  : "No output.",
222
213
  };
223
214
  }
215
+ /**
216
+ * Format an agent's tool scope for the Agent tool description.
217
+ *
218
+ * This suffix describes BUILT-IN scope only — extension tools are resolved when
219
+ * the agent runs (extensions can register asynchronously), so they cannot be
220
+ * enumerated while the description is being built. That is why an agent with
221
+ * `tools: "*, ext:mcp/search"` renders "*" and always has.
222
+ *
223
+ * Two distinctions matter, both of them capability claims the orchestrator acts on:
224
+ *
225
+ * - absent vs empty. `builtinToolNames: undefined` means the agent never narrowed
226
+ * its tools (the shipped defaults); `[]` is what `tools: none` and an `ext:`-only
227
+ * `tools:` parse to, and the runtime really does hand those agents no built-ins.
228
+ * Rendering both "*" tells the orchestrator a tool-less agent can run `bash`.
229
+ * - empty-with-extensions vs empty-without. Zero built-ins does NOT imply zero
230
+ * tools: `tools: none` alongside `extensions:` still surfaces every extension
231
+ * tool (see test/fixtures/.pi/agents/tools-none.md, which expects three). Calling
232
+ * that "none" understates the agent instead of overstating it — better, but still
233
+ * wrong, and it would route work away from the only agent able to do it. "none"
234
+ * is therefore reserved for agents that genuinely can call nothing: `isolated`
235
+ * agents and those with `extensions: false`.
236
+ */
237
+ export function formatToolsSuffix(cfg) {
238
+ const tools = cfg?.builtinToolNames;
239
+ if (!tools)
240
+ return "*";
241
+ if (tools.length === 0) {
242
+ // `isolated` overrides extensions to false in the runner, so both mean the
243
+ // agent has no extension tools either — and then it truly has nothing.
244
+ const noExtensionTools = cfg?.isolated === true || cfg?.extensions === false;
245
+ return noExtensionTools ? "none" : "no built-ins, extension tools only";
246
+ }
247
+ const isFullSet = tools.length === BUILTIN_TOOL_NAMES.length
248
+ && BUILTIN_TOOL_NAMES.every((t) => tools.includes(t));
249
+ return isFullSet ? "*" : tools.join(", ");
250
+ }
251
+ /** CLI flag that runs a workflow script at session start. */
252
+ export const WORKFLOW_FILE_FLAG = "subagents-workflow-file";
253
+ /**
254
+ * Re-exported from where they now live, because this is where they were
255
+ * defined and a consumer (or a test) that matched a session entry on
256
+ * {@link WORKFLOW_ENTRY_TYPE} imports it from here.
257
+ */
258
+ export { FOREIGN_WORKFLOW_TOOL_NAMES, WORKFLOW_ENTRY_TYPE, workflowEntryData };
224
259
  export default function (pi) {
260
+ // Child AgentSessions load normal extensions. Re-entering this extension there
261
+ // would create another manager and leak handlers. Nested orchestration is
262
+ // injected as scoped custom tools by the existing manager instead.
263
+ if (inChildSessionContext())
264
+ return;
225
265
  // ---- Register custom notification renderer ----
226
266
  pi.registerMessageRenderer("subagent-notification", (message, { expanded }, theme) => {
227
267
  const d = message.details;
@@ -243,6 +283,11 @@ export default function (pi) {
243
283
  parts.push(`${d.toolUses} tool use${d.toolUses === 1 ? "" : "s"}`);
244
284
  if (d.totalTokens > 0)
245
285
  parts.push(formatTokens(d.totalTokens));
286
+ if (showCost) {
287
+ const costText = formatCost(d.totalCost ?? 0);
288
+ if (costText)
289
+ parts.push(costText);
290
+ }
246
291
  if (d.durationMs > 0)
247
292
  parts.push(formatMs(d.durationMs));
248
293
  if (parts.length) {
@@ -265,18 +310,91 @@ export default function (pi) {
265
310
  return line;
266
311
  }
267
312
  const all = [d, ...(d.others ?? [])];
268
- return new Text(all.map(renderOne).join("\n"), 0, 0);
313
+ const rendered = all.map(renderOne);
314
+ // A group of agents lands as one notification, and the number a user wants
315
+ // from it is what the batch cost — not four figures to add up by hand.
316
+ // Derived from the per-agent details rather than carried alongside them:
317
+ // one source, so the total can never disagree with the rows above it.
318
+ if (showCost && all.length > 1) {
319
+ const total = formatCost(all.reduce((sum, a) => sum + (a.totalCost ?? 0), 0));
320
+ if (total) {
321
+ const tokens = all.reduce((sum, a) => sum + a.totalTokens, 0);
322
+ rendered.unshift(theme.fg("dim", `${all.length} agents · ${formatTokens(tokens)} · ${total}`));
323
+ }
324
+ }
325
+ return new Text(rendered.join("\n"), 0, 0);
269
326
  });
327
+ // ---- Workflow run rendered as a session entry ----
328
+ // A workflow launched from the CLI flag has no tool call to hang its result
329
+ // card on, so it renders here instead — through the SAME layout the tool
330
+ // result uses, not a second one. Custom entries with no registered renderer
331
+ // are silently dropped by the host, which is why this is registered at
332
+ // activation rather than lazily.
333
+ if (typeof pi.registerEntryRenderer === "function") {
334
+ pi.registerEntryRenderer(WORKFLOW_ENTRY_TYPE, (entry, _options, theme) => renderWorkflowEntryCard(entry.data, theme));
335
+ }
336
+ // Registered at activation; READ from session_start. The host applies CLI
337
+ // values after every extension factory has run, so `getFlag` here would only
338
+ // ever hand back the registered default (see the read site below).
339
+ if (typeof pi.registerFlag === "function") {
340
+ pi.registerFlag(WORKFLOW_FILE_FLAG, {
341
+ type: "string",
342
+ description: `Run a workflow script at startup: --${WORKFLOW_FILE_FLAG}=<path>. ` +
343
+ "Use the `=` form — the space form consumes the next argument, which would swallow a following prompt.",
344
+ });
345
+ }
346
+ // Read directly rather than waiting for applyAndEmitLoaded below: this decides
347
+ // the initial load, which happens hundreds of lines before settings are applied.
348
+ let strictAgentFiles = loadSettings(process.cwd()).strictAgentFiles === true;
270
349
  /** Reload agents from project/global custom agent dirs and merge with defaults (called on init and each Agent invocation). */
271
- const reloadCustomAgents = () => {
272
- const userAgents = loadCustomAgents(process.cwd());
350
+ const reloadCustomAgents = (strict = false) => {
351
+ const userAgents = loadCustomAgents(process.cwd(), strict);
273
352
  registerAgents(userAgents);
274
- applyNicoOverrides();
275
353
  };
276
- // Initial load
277
- reloadCustomAgents();
354
+ // Initial load — the only strict one. A bad edit mid-session must not kill the
355
+ // session on the next unrelated spawn, so every later reload keeps warning.
356
+ reloadCustomAgents(strictAgentFiles);
278
357
  // ---- Agent activity tracking + widget ----
279
358
  const agentActivity = new Map();
359
+ // ---- Usage reporting (both off by default; see SubagentsSettings) ----
360
+ /** Attach subagent spend to tool results, so the parent session counts it. */
361
+ let reportUsage = false;
362
+ function isReportUsageEnabled() { return reportUsage; }
363
+ function setReportUsage(b) {
364
+ reportUsage = b;
365
+ // Whatever accumulated while it was on is stale the moment it goes off:
366
+ // draining it later would bill the parent for a window the user opted out
367
+ // of, in one lump, on some unrelated later tool call.
368
+ if (!b)
369
+ pendingUsage.drain();
370
+ }
371
+ /** Show `~$X` next to token counts in the subagent surfaces. */
372
+ let showCost = false;
373
+ function isShowCostEnabled() { return showCost; }
374
+ function setShowCost(b) { showCost = b; widget.update(); fleet.update(); }
375
+ /** Name the model and thinking level on the widget's running rows. */
376
+ let showModel = false;
377
+ function isShowModelEnabled() { return showModel; }
378
+ function setShowModel(b) { showModel = b; widget.update(); }
379
+ /**
380
+ * How much of the conversation viewer renders as Markdown. Read through a
381
+ * getter by the viewer rather than captured like `showCost`, because the
382
+ * viewer's `m` key writes back here while the overlay is on screen.
383
+ */
384
+ let viewerMarkdown = "assistant";
385
+ function getViewerMarkdown() { return viewerMarkdown; }
386
+ function setViewerMarkdown(mode) { viewerMarkdown = mode; }
387
+ /**
388
+ * The viewer's `m` key, from either entry point: set the mode and persist it,
389
+ * so the key and `/agents → Settings` stay one setting rather than one per
390
+ * entry point. `ctx` carries only the warning a failed write notifies with,
391
+ * and the fleet list may be acting without one.
392
+ */
393
+ function chooseViewerMarkdown(mode, ctx) {
394
+ setViewerMarkdown(mode);
395
+ persistSettings(ctx, `Viewer markdown set to ${mode}`);
396
+ }
397
+ const pendingUsage = new PendingUsagePool();
280
398
  // ---- Cancellable pending notifications ----
281
399
  // Holds notifications briefly so get_subagent_result can cancel them
282
400
  // before they reach pi.sendMessage (fire-and-forget).
@@ -306,7 +424,7 @@ export default function (pi) {
306
424
  function emitIndividualNudge(record) {
307
425
  if (record.resultConsumed)
308
426
  return; // re-check at send time
309
- const notification = formatTaskNotification(record, 500);
427
+ const notification = formatTaskNotification(record, 500, showCost);
310
428
  const footer = record.outputFile ? `\nFull transcript available at: ${record.outputFile}` : '';
311
429
  pi.sendMessage({
312
430
  customType: "subagent-notification",
@@ -318,6 +436,7 @@ export default function (pi) {
318
436
  function sendIndividualNudge(record) {
319
437
  agentActivity.delete(record.id);
320
438
  widget.markFinished(record.id);
439
+ fleet.onAgentFinished(record.id);
321
440
  scheduleNudge(record.id, () => emitIndividualNudge(record));
322
441
  widget.update();
323
442
  }
@@ -326,6 +445,7 @@ export default function (pi) {
326
445
  for (const r of records) {
327
446
  agentActivity.delete(r.id);
328
447
  widget.markFinished(r.id);
448
+ fleet.onAgentFinished(r.id);
329
449
  }
330
450
  const groupKey = `group:${records.map(r => r.id).join(",")}`;
331
451
  scheduleNudge(groupKey, () => {
@@ -335,7 +455,7 @@ export default function (pi) {
335
455
  widget.update();
336
456
  return;
337
457
  }
338
- const notifications = unconsumed.map(r => formatTaskNotification(r, 300)).join('\n\n');
458
+ const notifications = unconsumed.map(r => formatTaskNotification(r, 300, showCost)).join('\n\n');
339
459
  const label = partial
340
460
  ? `${unconsumed.length} agent(s) finished (partial — others still running)`
341
461
  : `${unconsumed.length} agent(s) finished`;
@@ -365,20 +485,42 @@ export default function (pi) {
365
485
  const tokens = total > 0
366
486
  ? { input: u.input, output: u.output, total }
367
487
  : undefined;
488
+ // The whole run's spend as a pi `Usage` — pi's convention for handing spend
489
+ // to a consumer, so `usage.cost.total` and `usage.cacheRead` are where a
490
+ // listener already expects them and anything pi adds to `Usage` arrives
491
+ // without a change here. Omitted when nothing was spent, so "spent nothing"
492
+ // and "never ran" stay distinguishable. Ungated by `showCost`: that setting
493
+ // governs what a human is shown, not what the event carries.
494
+ //
495
+ // `tokens` above is the other convention, kept as it shipped: a flat view
496
+ // model like pi's own `SessionStats`, carrying the DISPLAY total, which
497
+ // excludes cacheRead (#38). The two answer different questions and neither
498
+ // derives from the other.
499
+ const usage = toReportedUsage(u);
368
500
  return {
369
501
  id: record.id,
370
502
  type: record.type,
371
503
  description: record.description,
372
- result: record.result,
504
+ result: record.transcriptPath ? undefined : record.result,
373
505
  error: record.error,
506
+ transcriptPath: record.transcriptPath,
374
507
  status: record.status,
375
508
  toolUses: record.toolUses,
376
509
  durationMs,
377
510
  tokens,
511
+ usage,
378
512
  };
379
513
  }
380
514
  // Background completion: route through group join or send individual nudge
515
+ let historySelectionIndex = 0;
516
+ let runningSelectionIndex = 0;
381
517
  const manager = new AgentManager((record) => {
518
+ // Owned children — nested, or a workflow's — report only through their
519
+ // owner: the parent's scoped tools, or the workflow's card, notification
520
+ // and dialog. Keep them out of top-level lifecycle, transcript,
521
+ // notification, and UI channels.
522
+ if (!isTopLevelAgent(record))
523
+ return;
382
524
  // Emit lifecycle event based on terminal status
383
525
  const isError = record.status === "error" || record.status === "stopped" || record.status === "aborted";
384
526
  const eventData = buildEventData(record);
@@ -392,21 +534,16 @@ export default function (pi) {
392
534
  pi.appendEntry("subagents:record", {
393
535
  id: record.id, type: record.type, description: record.description,
394
536
  status: record.status,
395
- // Durable transcripts are the source of truth for full output. Avoid
396
- // copying a potentially large result into the parent session branch;
397
- // get_subagent_result reloads it on demand after cleanup/restart.
398
537
  result: record.transcriptPath ? undefined : record.result,
399
538
  error: record.error,
400
- startedAt: record.startedAt, completedAt: record.completedAt,
401
- toolUses: record.toolUses,
402
- lifetimeUsage: record.lifetimeUsage,
403
- invocation: record.invocation,
404
539
  transcriptPath: record.transcriptPath,
540
+ startedAt: record.startedAt, completedAt: record.completedAt,
405
541
  });
406
542
  // Skip notification if result was already consumed via get_subagent_result
407
543
  if (record.resultConsumed) {
408
544
  agentActivity.delete(record.id);
409
545
  widget.markFinished(record.id);
546
+ fleet.onAgentFinished(record.id);
410
547
  widget.update();
411
548
  return;
412
549
  }
@@ -424,15 +561,23 @@ export default function (pi) {
424
561
  // 'delivered' → group callback already fired
425
562
  widget.update();
426
563
  }, undefined, (record) => {
564
+ if (!isTopLevelAgent(record))
565
+ return;
566
+ // Agent-tool spawns refresh these surfaces in their tool handler, but RPC
567
+ // and scheduler spawns enter through the manager directly.
568
+ if (currentCtx?.hasUI && (currentCtx.mode === undefined || currentCtx.mode === "tui")) {
569
+ widget.ensureTimer();
570
+ widget.update();
571
+ }
427
572
  // Emit started event when agent transitions to running (including from queue)
428
573
  pi.events.emit("subagents:started", {
429
574
  id: record.id,
430
575
  type: record.type,
431
576
  description: record.description,
432
577
  });
433
- widget.ensureTimer();
434
- widget.update();
435
578
  }, (record, info) => {
579
+ if (!isTopLevelAgent(record))
580
+ return;
436
581
  // Emit compacted event when agent's session compacts (preserves count on record).
437
582
  pi.events.emit("subagents:compacted", {
438
583
  id: record.id,
@@ -442,9 +587,17 @@ export default function (pi) {
442
587
  tokensBefore: info.tokensBefore,
443
588
  compactionCount: record.compactionCount,
444
589
  });
590
+ }, (_record, usage) => {
591
+ // Every assistant message from every agent — nested included, exactly once.
592
+ // Parked here until a tool result can carry it back to the parent session;
593
+ // see `PendingUsagePool`. Skipped entirely when the feature is off, so no
594
+ // pool grows in a session that will never drain it.
595
+ if (reportUsage)
596
+ pendingUsage.add(usage);
445
597
  });
446
598
  // Expose manager via Symbol.for() global registry for cross-package access.
447
599
  // Standard Node.js pattern for cross-package singletons (used by OpenTelemetry, etc.).
600
+ // Documented for callers in docs/rpc.md ("The manager registry").
448
601
  //
449
602
  // Claim the slot only if it's free: subagent sessions re-activate this
450
603
  // extension in the same process (session.bindExtensions in agent-runner.ts),
@@ -453,11 +606,91 @@ export default function (pi) {
453
606
  // session's entry. The first activation (the root session) wins; child
454
607
  // activations leave it alone.
455
608
  const MANAGER_KEY = Symbol.for("pi-subagents:manager");
609
+ // Process-external callers may supply arbitrary options. Nested ownership and
610
+ // config-root metadata are internal capabilities issued only by scoped tools.
611
+ /**
612
+ * Resolve the agent type and spawn. Trusts its options — every caller must
613
+ * either be in-process or have gone through `spawnTopLevel` first.
614
+ */
615
+ const spawnResolved = (piRef, ctxRef, type, prompt, options) => {
616
+ // Cross-extension callers get the same dispatch contract as the LLM (#183).
617
+ // The RPC layer already throws for an unresolvable model rather than falling
618
+ // back silently; a bad agent type should not be quieter. Throws become error
619
+ // envelopes at the RPC boundary. Reload first so an agent file added mid
620
+ // session is spawnable here too, not only through the Agent tool.
621
+ reloadCustomAgents();
622
+ const dispatch = resolveSpawnType(type);
623
+ if (!dispatch.ok)
624
+ throw new Error(dispatch.message);
625
+ // Every programmatic spawn lands here — cross-extension RPC, both `@handle`
626
+ // mention paths, and the `Symbol.for("pi-subagents:manager")` registry — and
627
+ // none came through the Agent tool, which is where the UI activity tracker is
628
+ // otherwise created. Without one the widget and FleetView have no tool name
629
+ // and no turn count, so the row reads `thinking…` for the agent's whole life
630
+ // while the header's tool-use count climbs beside it (#181). Double-tracking
631
+ // is not possible: the Agent tool calls `manager.spawn` directly. The tracker
632
+ // callbacks are the funnel's own — a caller's are not honoured, since a
633
+ // half-wired tracker renders worse than none.
634
+ //
635
+ // The turn limit is resolved rather than read off `options`, which a mention
636
+ // spawn deliberately omits so the agent's own config can decide: a tracker
637
+ // built with `undefined` renders `↻3` where the Agent tool renders `↻3≤20`.
638
+ // Like the tool's own, it is a prediction — editing the agent file mid-run
639
+ // leaves the displayed ceiling stale.
640
+ const { state, callbacks } = createActivityTracker(resolveEffectiveMaxTurns(dispatch.type, options?.maxTurns));
641
+ // Repaints are left to the manager's `onStart` callback, which already starts
642
+ // the widget/fleet timers for agents that enter this way.
643
+ const id = manager.spawn(piRef, ctxRef, dispatch.type, prompt, { ...options, ...callbacks });
644
+ agentActivity.set(id, state);
645
+ return id;
646
+ };
647
+ const spawnTopLevel = (piRef, ctxRef, type, prompt, options) => {
648
+ const safeOptions = { ...(options ?? {}) };
649
+ delete safeOptions.parentAgentId;
650
+ // Internal too: a forged value would hide an RPC-spawned agent inside
651
+ // someone else's workflow, and take it out of the concurrency pool with it.
652
+ delete safeOptions.workflowId;
653
+ delete safeOptions.depth;
654
+ delete safeOptions.maxSubagentDepth;
655
+ delete safeOptions.configCwd;
656
+ // Also internal: it names a transcript directory, so a forged value would
657
+ // be a path-traversal primitive.
658
+ delete safeOptions.rootSessionId;
659
+ // Worse than rootSessionId: this one names a file to OPEN and replay as a
660
+ // conversation. Only the mention dispatcher may set it, and only from a
661
+ // path this extension itself recorded — never from anything a caller sent.
662
+ delete safeOptions.resumeSessionFile;
663
+ // Bypasses handle allocation, so a forged value would duplicate a live
664
+ // agent's name and make `@handle` ambiguous. Same rule: dispatcher only.
665
+ delete safeOptions.reclaim;
666
+ // Every spawn through here is DETACHED — the caller gets an id back and
667
+ // awaits nothing. A forged `blocking` would charge it to the foreground
668
+ // pool and could defer it behind a queue whose gate nobody is holding.
669
+ delete safeOptions.blocking;
670
+ return spawnResolved(piRef, ctxRef, type, prompt, safeOptions);
671
+ };
672
+ /**
673
+ * Resolve a tool's `agent_id` as an id OR a handle, so the model addresses
674
+ * agents by the same names the user types. Ids are tried first, keeping the
675
+ * existing behaviour exact — a handle is only consulted when the string is
676
+ * not an id at all. Only live records: a tombstone has nothing to steer and
677
+ * no result to read. Callers still enforce the nested-ownership rejection.
678
+ */
679
+ const resolveAgentRef = (ref) => {
680
+ const byId = manager.getRecord(ref);
681
+ if (byId)
682
+ return byId;
683
+ const resolved = manager.resolveMention(ref);
684
+ return resolved?.kind === "live" ? resolved.record : undefined;
685
+ };
456
686
  const registryEntry = {
457
687
  waitForAll: () => manager.waitForAll(),
458
688
  hasRunning: () => manager.hasRunning(),
459
- spawn: (piRef, ctx, type, prompt, options) => manager.spawn(piRef, ctx, type, prompt, options),
460
- getRecord: (id) => manager.getRecord(id),
689
+ spawn: spawnTopLevel,
690
+ getRecord: (id) => {
691
+ const record = manager.getRecord(id);
692
+ return record !== undefined && isTopLevelAgent(record) ? record : undefined;
693
+ },
461
694
  };
462
695
  const ownsManagerRegistry = globalThis[MANAGER_KEY] === undefined;
463
696
  if (ownsManagerRegistry) {
@@ -473,6 +706,8 @@ export default function (pi) {
473
706
  // (currentCtx would stay undefined → spawn always "No active session"). Gating
474
707
  // here makes a filtered session behave like an absent one (#142).
475
708
  let rpcHandle;
709
+ /** Whether the `@handle` autocomplete wrapper has been stacked on pi's provider. */
710
+ let mentionProviderRegistered = false;
476
711
  // ---- Subagent scheduler ----
477
712
  // Session-scoped: store is constructed inside session_start once sessionId
478
713
  // is available. Mirrors pi-chonky-tasks's session-scoped task store —
@@ -494,32 +729,26 @@ export default function (pi) {
494
729
  console.warn("[pi-subagents] Failed to start scheduler:", err);
495
730
  }
496
731
  }
497
- let runningAgentSelection = { index: 0 };
498
- let historyAgentSelection = { index: 0 };
499
- function resetAgentMenuSelections() {
500
- runningAgentSelection = { index: 0 };
501
- historyAgentSelection = { index: 0 };
502
- }
503
732
  // Capture ctx from session_start for RPC spawn handler + start the scheduler.
504
733
  // This also wires the RPC handlers and broadcasts readiness — on the first
505
734
  // bound session_start, so a filtered-out activation never advertises (#142).
506
735
  pi.on("session_start", async (_event, ctx) => {
507
- resetAgentMenuSelections();
508
736
  currentCtx = ctx;
509
- manager.clearCompleted(true);
510
- const branch = ctx.sessionManager?.getBranch?.() ?? [];
511
- manager.restoreCompleted(branch
512
- .filter((entry) => entry?.type === "custom" && entry?.customType === "subagents:record")
513
- .map((entry) => entry.data));
514
- // Checkpoint files cover agents whose parent session never got a terminal
515
- // branch entry (shutdown, session switch, or a process restart).
516
737
  manager.restoreRecovered(ctx.cwd);
517
- // Attach the panel during TUI startup, after restored records are present,
518
- // so terminal agents from the session branch are immediately visible.
519
- if (ctx.mode === "tui") {
738
+ const branchEntries = ctx.sessionManager?.getBranch?.() ?? [];
739
+ const restoredRecords = branchEntries
740
+ .filter((entry) => entry?.customType === "subagents:record" && entry?.data && typeof entry.data.id === "string")
741
+ .map((entry) => entry.data);
742
+ manager.restoreCompleted(restoredRecords);
743
+ historySelectionIndex = 0;
744
+ runningSelectionIndex = 0;
745
+ if (ctx.hasUI && (ctx.mode === undefined || ctx.mode === "tui")) {
520
746
  widget.setUICtx(ctx.ui);
521
747
  widget.update();
748
+ fleet.setUICtx(ctx.ui, false);
749
+ fleet.setCwd(ctx.cwd);
522
750
  }
751
+ manager.clearCompleted(true);
523
752
  // Guard mirrors the `!scheduler.isActive()` pattern below: session_start
524
753
  // fires once per activation, but a double-bind must not leak listeners.
525
754
  if (!rpcHandle) {
@@ -527,7 +756,28 @@ export default function (pi) {
527
756
  events: pi.events,
528
757
  pi,
529
758
  getCtx: () => currentCtx,
530
- manager,
759
+ manager: {
760
+ spawn: spawnTopLevel,
761
+ awaitStartup: (id) => manager.awaitStartup(id),
762
+ getRecord: (id) => manager.getRecord(id),
763
+ // Unguarded on purpose: the stop handler now runs the top-level check
764
+ // itself off `getRecord`, and reports the refusal instead of the
765
+ // "Agent not found" a false from here used to be read as.
766
+ abort: (id) => manager.abort(id),
767
+ consumeResult: (id) => {
768
+ const record = resolveAgentRef(id);
769
+ // Same guard as get_subagent_result: a running agent has no result
770
+ // to consume, and its notification is still the caller's only
771
+ // signal that it finished.
772
+ if (!record || record.parentAgentId)
773
+ return false;
774
+ if (record.status === "running" || record.status === "queued")
775
+ return false;
776
+ record.resultConsumed = true;
777
+ cancelNudge(record.id);
778
+ return true;
779
+ },
780
+ },
531
781
  });
532
782
  // Broadcast readiness so extensions loaded alongside us can discover us.
533
783
  // Emitting after all factories have run (rather than at factory time)
@@ -536,22 +786,254 @@ export default function (pi) {
536
786
  }
537
787
  if (isSchedulingEnabled() && !scheduler.isActive())
538
788
  startScheduler(ctx);
789
+ // Stack `@handle` suggestions on pi's built-in autocomplete. Registered at
790
+ // most once per activation: pi appends wrappers to a list it never prunes,
791
+ // so a second call would layer a duplicate provider on the first. TUI only
792
+ // — print mode has no such method, and RPC mode's is a no-op.
793
+ if (ctx.mode === "tui" && !mentionProviderRegistered && typeof ctx.ui.addAutocompleteProvider === "function") {
794
+ mentionProviderRegistered = true;
795
+ ctx.ui.addAutocompleteProvider(current => createMentionProvider(current,
796
+ // Plain text, not renderAgentName: the same label FleetView and the
797
+ // widget show, but the autocomplete description cannot carry ANSI.
798
+ () => mentionRoster(manager, mentionTypes(), type => getConfig(type).displayName), isAgentMentionsEnabled));
799
+ }
800
+ // Last, and only here: CLI flag values are applied by the host AFTER every
801
+ // extension factory has run, so this is the earliest point the real value
802
+ // exists. Detached inside — a workflow must not hold up session startup.
803
+ resolveWorkflowCollisions(ctx);
804
+ runWorkflowFlag(ctx);
805
+ });
806
+ /** Agent types `@` can start, in the shape the roster wants. */
807
+ const mentionTypes = () => getAvailableTypes().map(name => ({ name, description: getAgentConfig(name)?.description ?? name }));
808
+ /**
809
+ * `@handle message` typed at the prompt addresses that agent instead of the
810
+ * main model — Claude Code's prompt mention, same grammar (see mention.ts).
811
+ *
812
+ * The handle names the *agent*, not one process, so one syntax covers its
813
+ * whole lifecycle: message it while it runs, resume it once it has finished,
814
+ * start it if it never ran. Everything that isn't an agent mention falls
815
+ * through untouched, which is what keeps `@src/foo.ts summarize this`, a bare
816
+ * `@handle`, and ordinary prose working. A delivered mention costs no
817
+ * main-model turn; the answer arrives through the ordinary completion
818
+ * notification either way.
819
+ */
820
+ pi.on("input", async (event, ctx) => {
821
+ // Never hijack text the extension layer itself submitted (pi.sendMessage,
822
+ // scheduled prompts) — only something a person typed can be a mention.
823
+ if (event.source === "extension" || !isAgentMentionsEnabled())
824
+ return { action: "continue" };
825
+ // Claiming the turn is TUI only, matching the `@` completion that teaches
826
+ // the syntax. Pi defaults `session.prompt()` to source "interactive", so a
827
+ // headless `pi -p "@explore …"` reaches here too — and claiming it would
828
+ // answer with silence, which the background hold cannot fix: `handled`
829
+ // returns from prompt() before any turn starts, so the loop that patch wraps
830
+ // never runs (it holds subagents spawned by the Agent tool MID-turn, a
831
+ // different path). The agent would detach, `ctx.ui.notify` is a no-op
832
+ // outside the TUI, and print mode would exit having printed nothing.
833
+ //
834
+ // `model` mode has none of that problem: it queues a reminder and lets the
835
+ // turn run, so the answer is the model's own, printed as usual. It is the
836
+ // only branch allowed to act headlessly; everything else falls through to
837
+ // the main model exactly as it did before mentions existed.
838
+ const canDispatchDirectly = ctx.mode === "tui";
839
+ if (!canDispatchDirectly && getAgentMentionMode() !== "model")
840
+ return { action: "continue" };
841
+ const mention = parseMention(event.text);
842
+ if (!mention)
843
+ return { action: "continue" };
844
+ // `@main` addresses the main conversation, never a subagent — the one name
845
+ // `assignHandle` refuses to allocate. An explicit escape hatch for text
846
+ // that would otherwise read as a mention, so the prefix is dropped and the
847
+ // rest goes to the model with its attachments intact.
848
+ if (isReservedHandle(mention.handle)) {
849
+ return { action: "transform", text: mention.message, ...(event.images && { images: event.images }) };
850
+ }
851
+ // As typed first, so an agent actually called `agent-foo` wins over Claude
852
+ // Code's `@agent-` + `foo` spelling rather than being shadowed by it.
853
+ const alias = stripAgentPrefix(mention.handle);
854
+ const resolved = manager.resolveMention(mention.handle)
855
+ ?? (alias ? manager.resolveMention(alias) : undefined);
856
+ // Steering and resuming are direct in every mode, so headless they are not
857
+ // available at all. Falling through here rather than dropping to the start
858
+ // path below matters: the handle names an agent that already exists, and
859
+ // asking the model to start another one is not what was typed.
860
+ if (resolved && !canDispatchDirectly)
861
+ return { action: "continue" };
862
+ if (resolved?.kind === "live") {
863
+ const record = resolved.record;
864
+ const target = `@${record.alias ?? record.handle ?? mention.handle}`;
865
+ if (record.status === "running" || record.status === "queued") {
866
+ // Steering interrupts after the current tool call, exactly like the
867
+ // steer_subagent tool. Un-consume the result so the agent's reply to
868
+ // this message is still relayed even if the LLM read its last answer.
869
+ record.resultConsumed = false;
870
+ manager.steer(record.id, mention.message);
871
+ pi.events.emit("subagents:steered", { id: record.id, message: mention.message });
872
+ ctx.ui.notify(`Sent to ${target}`, "info");
873
+ return { action: "handled" };
874
+ }
875
+ if (record.session) {
876
+ // Both derived from the record's OWN type: a mention names an existing
877
+ // agent, so its frontmatter is what governs — `output_transcript: false`
878
+ // must keep holding, since record.outputFile is the sole gate every
879
+ // downstream consumer keys off and a resume must not re-open it.
880
+ const config = getAgentConfig(record.type);
881
+ const resumedRecord = await startBackgroundResume(ctx, record, mention.message, {
882
+ outputTranscript: config?.outputTranscript ?? getOutputTranscriptDefault(),
883
+ maxTurns: normalizeMaxTurns(config?.maxTurns ?? getDefaultMaxTurns()),
884
+ });
885
+ ctx.ui.notify(resumedRecord ? `Resuming ${target}` : `Could not resume ${target} — it is still running.`, resumedRecord ? "info" : "warning");
886
+ return { action: "handled" };
887
+ }
888
+ // A live record with no session never got far enough to continue, so it
889
+ // falls through to the start-fresh path below, like Claude's
890
+ // `no_transcript`.
891
+ }
892
+ // Evicted, but its conversation is still on disk: reopen it. This is an
893
+ // ordinary spawn carrying a session file, so the new record picks up the
894
+ // widget, fleet row, transcript and completion notification unchanged —
895
+ // and `reclaim` hands it back the names the tombstone was holding.
896
+ if (resolved?.kind === "tombstone") {
897
+ const entry = resolved.entry;
898
+ const target = `@${entry.alias ?? entry.handle}`;
899
+ // Checked here rather than left to SessionManager.open: that runs inside
900
+ // runAgent, whose rejection lands on the record as an agent error, not in
901
+ // the catch below. A `/new` in another pi window or a manual delete makes
902
+ // the conversation unrecoverable (Claude Code's `not_reachable`), so drop
903
+ // the entry — a row that can only ever fail is worse than none — and say
904
+ // so rather than quietly sending this message to an unrelated agent.
905
+ if (!existsSync(entry.sessionFile)) {
906
+ manager.dropTombstone(entry.handle);
907
+ ctx.ui.notify(`Could not resume ${target} — its session is gone.`, "warning");
908
+ return { action: "handled" };
909
+ }
910
+ // The Agent tool deliberately falls back to general-purpose for a type it
911
+ // cannot resolve (#183), which covers a deleted file AND a merely
912
+ // disabled one. A resume must not inherit that: reopening this
913
+ // conversation under a different agent's prompt and tools is not
914
+ // continuing it, and the new record would re-tombstone under the
915
+ // substitute, so the handle would never find its way back.
916
+ reloadCustomAgents();
917
+ const dispatch = resolveSpawnType(entry.type);
918
+ if (!dispatch.ok || dispatch.fellBackFrom !== undefined) {
919
+ // The tombstone stays: re-enabling the agent makes the handle work
920
+ // again, which a drop would foreclose.
921
+ ctx.ui.notify(`Could not resume ${target} — the ${entry.type} agent is no longer available.`, "warning");
922
+ return { action: "handled" };
923
+ }
924
+ try {
925
+ // spawnResolved, not spawnTopLevel: the latter strips
926
+ // `resumeSessionFile` and `reclaim` as untrusted. This path is the
927
+ // exception — both come from a tombstone this extension wrote.
928
+ const id = spawnResolved(pi, ctx, dispatch.type, mention.message, {
929
+ description: entry.description,
930
+ reclaim: { handle: entry.handle, alias: entry.alias },
931
+ resumeSessionFile: entry.sessionFile,
932
+ isBackground: true,
933
+ });
934
+ // The agent may still be starting — wait, so a startup failure lands in
935
+ // the catch below instead of being announced as a resume.
936
+ await manager.awaitStartup(id);
937
+ // The tombstone deliberately stays. `resolveMention` prefers the live
938
+ // record holding these same names, so it cannot shadow the resume — and
939
+ // if this run dies before establishing its own session, the original
940
+ // transcript is still the right thing for the next mention to reopen.
941
+ // Once the resumed record is evicted it overwrites this entry in place,
942
+ // keyed by the same handle, so nothing accumulates.
943
+ ctx.ui.notify(`Resuming ${target}`, "info");
944
+ }
945
+ catch (err) {
946
+ // The type is already settled above, so what is left is a spawn-time
947
+ // failure: a strict worktree-isolation error, an unusable cwd.
948
+ ctx.ui.notify(`Could not resume ${target}: ${err instanceof Error ? err.message : String(err)}`, "warning");
949
+ }
950
+ return { action: "handled" };
951
+ }
952
+ // No agent under that handle — but the name may still be an agent type, in
953
+ // which case the mention starts one.
954
+ const typeHandle = mention.handle;
955
+ const type = resolveHandleToType(typeHandle, getAvailableTypes())
956
+ ?? (alias ? resolveHandleToType(alias, getAvailableTypes()) : undefined);
957
+ if (!type)
958
+ return { action: "continue" };
959
+ // Claude Code never starts the agent itself: `@agent-<type>` becomes an
960
+ // attachment asking the main model to do it, and the model writes the
961
+ // agent's prompt from the conversation rather than forwarding the typed
962
+ // text. That buys a real `Agent` tool call — transcript, per-tool widget
963
+ // detail, tool-use-id correlation, join grouping — and a prompt with the
964
+ // context a cold spawn lacks.
965
+ //
966
+ // It also costs a visible turn, spent narrating a decision the user already
967
+ // made by typing the handle. So the turn is taken by a clone of this
968
+ // conversation instead (mention-clone.ts): same messages, same system
969
+ // prompt, off-screen, holding only the `Agent` tool. Nothing reaches the
970
+ // chat, and what it starts is an ordinary top-level agent.
971
+ if (getAgentMentionMode() === "model") {
972
+ const label = `@${handleBase(type)}`;
973
+ // "Prompting", not "Starting": in this mode nothing starts until the
974
+ // off-screen clone has taken a whole model turn writing the agent's
975
+ // prompt, and that wait is the one thing the chat cannot show. `direct`
976
+ // says "Started" because by then it has. The distinction tells the user
977
+ // which of the two they are waiting on.
978
+ ctx.ui.notify(`Prompting ${label}…`, "info");
979
+ // Not awaited: the clone runs a full model turn, and prompt() is blocked
980
+ // until this hook returns. The user gets their prompt back immediately
981
+ // and the agent appears in the widget when it starts.
982
+ void runMentionClone({ ctx, type, message: mention.message, agentTool: registeredAgentTool })
983
+ .then(async (result) => {
984
+ if (result.spawned)
985
+ return;
986
+ // A clone that could not run must not swallow the mention: start the
987
+ // agent the direct way rather than leaving the user with a toast and
988
+ // nothing running.
989
+ try {
990
+ const id = spawnTopLevel(pi, ctx, type, mention.message, {
991
+ description: describeMention(mention.message),
992
+ isBackground: true,
993
+ });
994
+ // Same reason as the direct path below: the agent may still be
995
+ // starting, and a failure there must reach this catch.
996
+ await manager.awaitStartup(id);
997
+ ctx.ui.notify(`Started ${label} directly — ${result.error}`, "warning");
998
+ }
999
+ catch (err) {
1000
+ ctx.ui.notify(`Could not start ${label}: ${err instanceof Error ? err.message : String(err)}`, "error");
1001
+ }
1002
+ });
1003
+ return { action: "handled" };
1004
+ }
1005
+ try {
1006
+ // Nothing else to pass: runAgent resolves model, thinking and max turns
1007
+ // from the agent's own config when the spawn omits them, and the
1008
+ // manager's onStart/onComplete callbacks own the widget, the fleet list
1009
+ // and the completion notification — the same contract the scheduler and
1010
+ // cross-extension RPC spawns run under.
1011
+ const id = spawnTopLevel(pi, ctx, type, mention.message, {
1012
+ description: describeMention(mention.message),
1013
+ isBackground: true,
1014
+ });
1015
+ // The agent may still be starting (a worktree copy is an awaited git
1016
+ // call) — report a failure that lands there as a failed start, not as a
1017
+ // "Started" toast for an agent that never ran.
1018
+ await manager.awaitStartup(id);
1019
+ ctx.ui.notify(`Started @${handleBase(type)}`, "info");
1020
+ }
1021
+ catch (err) {
1022
+ ctx.ui.notify(`Could not start @${handleBase(type)}: ${err instanceof Error ? err.message : String(err)}`, "error");
1023
+ }
1024
+ return { action: "handled" };
539
1025
  });
540
1026
  pi.on("session_before_switch", () => {
541
- resetAgentMenuSelections();
542
- // A switch is catchable. Stop and checkpoint live/queued agents before the
543
- // old session context is discarded, then retain their unread history.
544
- manager.abortAll();
545
1027
  manager.clearCompleted(true);
546
1028
  scheduler.stop();
547
1029
  });
548
1030
  // On shutdown, abort all agents immediately and clean up.
549
1031
  // If the session is going down, there's nothing left to consume agent results.
550
1032
  pi.on("session_shutdown", async () => {
551
- resetAgentMenuSelections();
552
1033
  rpcHandle?.unsubSpawn();
553
1034
  rpcHandle?.unsubStop();
554
1035
  rpcHandle?.unsubPing();
1036
+ rpcHandle?.unsubConsume();
555
1037
  rpcHandle = undefined;
556
1038
  currentCtx = undefined;
557
1039
  // Only release the global slot if this activation claimed it — a child
@@ -560,38 +1042,64 @@ export default function (pi) {
560
1042
  delete globalThis[MANAGER_KEY];
561
1043
  }
562
1044
  scheduler.stop();
1045
+ // Before abortAll, and not folded into it: a workflow owns a worker thread
1046
+ // as well as its children, and only its own signal terminates that.
1047
+ for (const task of workflowTasks.values())
1048
+ task.abortController.abort();
1049
+ workflowTasks.clear();
563
1050
  manager.abortAll();
564
1051
  for (const timer of pendingNudges.values())
565
1052
  clearTimeout(timer);
566
1053
  pendingNudges.clear();
567
1054
  widget.dispose();
568
- manager.dispose();
1055
+ fleet.dispose();
1056
+ // Awaited: it emits `session_shutdown` into every retained child session so
1057
+ // extensions bound there can release what they armed in `session_start` (#242).
1058
+ // pi awaits this handler, and the process exits right after — unawaited, those
1059
+ // handlers would never run. Internally bounded, so a hung one can't strand quit.
1060
+ await manager.dispose(pi);
569
1061
  });
570
- // Live widget: show all agents above the editor. Read live at render time.
571
- let widgetMode = "all";
1062
+ // Live widget: show running agents above editor.
1063
+ // widgetMode (default "background") selects what the widget shows: "all" =
1064
+ // every agent; "background" = hide foreground (they already render inline as
1065
+ // the Agent tool result, so showing them here too is a duplicate, #118), keep
1066
+ // everything else; "off" = hide the widget entirely. Read live at render time.
1067
+ let widgetMode = "background";
572
1068
  function getWidgetMode() { return widgetMode; }
573
- const widget = new AgentWidget(manager, agentActivity, getWidgetMode, {
574
- canOpenHistory: (record) => canOpenAgentHistory(record, currentCtx?.cwd),
575
- onOpen: (record, mode) => {
576
- const ctx = currentCtx;
577
- if (ctx)
578
- void viewAgentConversation(ctx, record, mode);
579
- },
580
- });
581
- function setWidgetMode(m) {
582
- widgetMode = m;
583
- widget.update();
584
- }
585
- // Project/global default for writing the subagent .output transcript. A custom
586
- // agent's `output_transcript` frontmatter overrides this per spawn; when the
587
- // frontmatter is silent, this default applies. Read live at spawn time.
588
- let outputTranscriptDefault = true;
589
- function getOutputTranscriptDefault() { return outputTranscriptDefault; }
590
- function setOutputTranscript(b) { outputTranscriptDefault = b; }
1069
+ const widget = new AgentWidget(manager, agentActivity, getWidgetMode, isShowCostEnabled, isShowModelEnabled);
1070
+ function setWidgetMode(m) { widgetMode = m; widget.update(); }
1071
+ // Claude Code-style FleetView: navigable list of main + subagents below the editor.
1072
+ // The last two arguments keep a conversation overlay opened here identical to
1073
+ // one opened from `/agents`: same setting on the way in, same persist out.
1074
+ const fleet = new FleetList(manager, agentActivity, isShowCostEnabled, getViewerMarkdown, (mode) => chooseViewerMarkdown(mode, currentCtx), process.cwd());
1075
+ let fleetViewEnabled = true;
1076
+ function isFleetViewEnabled() { return fleetViewEnabled; }
1077
+ function setFleetViewEnabled(b) { fleetViewEnabled = b; fleet.setEnabled(b); }
1078
+ // Claude Code-style `@handle message` prompt mentions. Read live by both the
1079
+ // `input` hook and the stacked autocomplete provider, so the toggle applies
1080
+ // immediately — the provider itself can never be unregistered (pi's wrapper
1081
+ // list is append-only), it just delegates everything when this is off.
1082
+ let agentMentionMode = "model";
1083
+ function getAgentMentionMode() { return agentMentionMode; }
1084
+ function setAgentMentionMode(mode) { agentMentionMode = mode; }
1085
+ // `model` and `direct` differ only in who starts a not-yet-running agent, so
1086
+ // everything that just asks "are mentions live at all" — the suggestion list,
1087
+ // the steer and resume branches — reads this instead of the mode.
1088
+ function isAgentMentionsEnabled() { return agentMentionMode !== "off"; }
1089
+ // Project/global default for writing the subagent .output transcript lives in
1090
+ // output-file.ts (both spawn paths read it). A custom agent's
1091
+ // `output_transcript` frontmatter overrides it per spawn; when the frontmatter
1092
+ // is silent, this default applies. Read live at spawn time.
591
1093
  // ---- Join mode configuration ----
592
1094
  let defaultJoinMode = 'smart';
593
1095
  function getDefaultJoinMode() { return defaultJoinMode; }
594
1096
  function setDefaultJoinMode(mode) { defaultJoinMode = mode; }
1097
+ // What an unqualified top-level spawn means. Defaults to background,
1098
+ // following Claude Code; `backgroundByDefault: false` restores the previous
1099
+ // foreground default. Nested spawns ignore this — see nested-tools.ts.
1100
+ let backgroundByDefault = true;
1101
+ function getBackgroundByDefault() { return backgroundByDefault; }
1102
+ function setBackgroundByDefault(b) { backgroundByDefault = b; }
595
1103
  // Master switch for the schedule subagent feature. Defaults to enabled.
596
1104
  // Read once at extension init (before tool registration) so the Agent tool's
597
1105
  // param schema reflects the persisted setting. Runtime toggles via /agents
@@ -601,16 +1109,25 @@ export default function (pi) {
601
1109
  let schedulingEnabled = true;
602
1110
  function isSchedulingEnabled() { return schedulingEnabled; }
603
1111
  function setSchedulingEnabled(b) { schedulingEnabled = b; }
604
- // ---- Scope models configuration ----
605
- // When enabled, subagent model choices are validated against `enabledModels`
606
- // from pi's settings — both global `<agentDir>/settings.json` and
607
- // project-local `<cwd>/.pi/settings.json` (project overrides global).
608
- // Off by default; opt-in via `/agents → Settings`. See docstring on
609
- // SubagentsSettings.scopeModels for the hard-error vs warn-and-proceed
610
- // policy and its rationale.
611
- let scopeModelsEnabled = false;
612
- function isScopeModelsEnabled() { return scopeModelsEnabled; }
613
- function setScopeModelsEnabled(enabled) { scopeModelsEnabled = enabled; }
1112
+ // Master switch for scripted workflows. Defaults to ON. Off means the
1113
+ // `SubagentWorkflow` tool is never registered: the model is not told the
1114
+ // feature exists (zero context cost) and has nothing to call. The
1115
+ // `/agents → Workflows` view and `--subagents-workflow-file` are refused too, so
1116
+ // there is no second door into the same machinery.
1117
+ //
1118
+ // `workflowsPinned` records that the answer came from the user — a boolean in
1119
+ // subagents.json, or the settings toggle — rather than from this default. It
1120
+ // is what `resolveWorkflowCollisions` checks before yielding to another
1121
+ // extension's workflow tool: a default may be overridden by what else is
1122
+ // loaded, an explicit choice may not.
1123
+ let workflowsEnabled = true;
1124
+ let workflowsPinned = false;
1125
+ function isWorkflowsEnabled() { return workflowsEnabled; }
1126
+ function isWorkflowsPinned() { return workflowsPinned; }
1127
+ function setWorkflowsEnabled(b) {
1128
+ workflowsEnabled = b;
1129
+ workflowsPinned = true;
1130
+ }
614
1131
  // ---- Disable default agents configuration ----
615
1132
  // When enabled, the three hardcoded default agents (general-purpose, Explore,
616
1133
  // Plan) are not registered. User-defined agents from project/global custom
@@ -671,20 +1188,99 @@ export default function (pi) {
671
1188
  }
672
1189
  }
673
1190
  }
1191
+ /**
1192
+ * Launch a detached resume of an existing agent and wire everything a
1193
+ * re-running agent needs: transcript anchoring, activity tracking, join-mode
1194
+ * batching, the widget/fleet refresh, and the `subagents:created` event.
1195
+ *
1196
+ * Shared by the Agent tool's `resume` + `run_in_background` branch and the
1197
+ * `@handle message` prompt mention — they differ only in how they report the
1198
+ * outcome. Returns the record, or undefined when the manager refused because
1199
+ * the agent is still running (see AgentManager.resume).
1200
+ *
1201
+ * Callers must have already established that the record has a session.
1202
+ */
1203
+ async function startBackgroundResume(ctx, existing, prompt, opts) {
1204
+ const id = existing.id;
1205
+ const joinMode = resolveJoinMode(defaultJoinMode, true);
1206
+ // Assigned unconditionally: the completion notification carries this as
1207
+ // `<tool-use-id>`, so a mention-resume (which passes none) has to CLEAR the
1208
+ // id left by the spawn that created the record. Keeping it would point the
1209
+ // orchestrator's new result at a tool call that was answered runs ago.
1210
+ existing.toolCallId = opts.toolCallId;
1211
+ if (joinMode)
1212
+ existing.joinMode = joinMode;
1213
+ // Reuse the agent's transcript rather than starting a fresh one: the
1214
+ // path is deterministic per agent+session, so writing an initial entry
1215
+ // would truncate the previous run's turns (see ensureOutputFile).
1216
+ if (opts.outputTranscript) {
1217
+ existing.outputFile = createOutputFilePath(ctx.cwd, id, ctx.sessionManager.getSessionId());
1218
+ ensureOutputFile(existing.outputFile);
1219
+ }
1220
+ // Anchor streaming past the turns already on disk, captured BEFORE the
1221
+ // run starts. The resumed prompt lands as an ordinary user message at
1222
+ // this index, so it is written exactly once.
1223
+ const transcriptAnchor = existing.session?.messages.length ?? 0;
1224
+ const { state: bgState, callbacks: bgCallbacks } = createActivityTracker(opts.maxTurns);
1225
+ // resumeAgent has no onSessionCreated — the session predates this run —
1226
+ // so seed it directly, or the widget shows no context % for the agent.
1227
+ bgState.session = existing.session;
1228
+ // No `signal`: a background spawn deliberately omits it, and a detached
1229
+ // resume must behave the same. Passing it would abort this agent when
1230
+ // the parent turn is interrupted (user Esc), while agents started with
1231
+ // run_in_background in that same turn keep going.
1232
+ const record = await manager.resume(id, prompt, undefined, {
1233
+ isBackground: true,
1234
+ onToolActivity: bgCallbacks.onToolActivity,
1235
+ onAssistantUsage: bgCallbacks.onAssistantUsage,
1236
+ // Fires when the run actually starts — immediately, or on queue
1237
+ // drain. Wiring it here (rather than after resume() returns) means a
1238
+ // resume stopped while still queued never started streaming, so
1239
+ // there is no subscription left behind for a later run to trip over.
1240
+ onStarted: () => {
1241
+ const rec = manager.getRecord(id);
1242
+ if (rec?.session && rec.outputFile) {
1243
+ rec.outputCleanup = streamToOutputFile(rec.session, rec.outputFile, id, ctx.cwd, transcriptAnchor);
1244
+ }
1245
+ },
1246
+ });
1247
+ if (!record)
1248
+ return undefined;
1249
+ if (joinMode != null && joinMode !== 'async') {
1250
+ currentBatchAgents.push({ id, joinMode });
1251
+ if (batchFinalizeTimer)
1252
+ clearTimeout(batchFinalizeTimer);
1253
+ batchFinalizeTimer = setTimeout(finalizeBatch, 100);
1254
+ }
1255
+ agentActivity.set(id, bgState);
1256
+ // This agent already finished once, so the widget holds a finished-age
1257
+ // for it that is past the linger limit — without clearing it, the
1258
+ // resumed run's ✓/✗ line never renders and the agent just vanishes.
1259
+ widget.markRunning(id);
1260
+ widget.ensureTimer();
1261
+ widget.update();
1262
+ fleet.ensureTimer();
1263
+ fleet.update();
1264
+ // Resume ignores subagent_type (the record keeps the type it was
1265
+ // spawned with), so report the record's own identity — a "created"
1266
+ // event carrying the caller's type would re-register the agent under
1267
+ // the wrong one in cross-extension mirrors keyed by id.
1268
+ pi.events.emit("subagents:created", {
1269
+ id,
1270
+ type: existing.type,
1271
+ description: existing.description,
1272
+ isBackground: true,
1273
+ });
1274
+ return record;
1275
+ }
674
1276
  // Grab UI context from first tool execution + clear lingering widget on new turn
675
1277
  pi.on("tool_execution_start", async (_event, ctx) => {
676
- widget.setUICtx(ctx.ui);
1278
+ if (ctx.hasUI && (ctx.mode === undefined || ctx.mode === "tui"))
1279
+ widget.setUICtx(ctx.ui);
1280
+ if (ctx.hasUI && ctx.mode === undefined)
1281
+ fleet.setUICtx(ctx.ui, true);
677
1282
  widget.onTurnStart();
678
1283
  });
679
- /** Format an agent's tool scope: "*" when it has all built-ins, else a comma-separated list. */
680
- const formatToolsSuffix = (cfg) => {
681
- const tools = cfg?.builtinToolNames;
682
- if (!tools || tools.length === 0)
683
- return "*";
684
- const isFullSet = tools.length === BUILTIN_TOOL_NAMES.length
685
- && BUILTIN_TOOL_NAMES.every((t) => tools.includes(t));
686
- return isFullSet ? "*" : tools.join(", ");
687
- };
688
1284
  /** Build the full type list text dynamically from available agents only. */
689
1285
  const buildTypeListText = () => {
690
1286
  const available = getAvailableTypes();
@@ -717,15 +1313,29 @@ export default function (pi) {
717
1313
  // to stderr and falls back to defaults.
718
1314
  applyAndEmitLoaded({
719
1315
  setMaxConcurrent: (n) => manager.setMaxConcurrent(n),
1316
+ setMaxConcurrentForeground: (n) => manager.setMaxConcurrentForeground(n),
720
1317
  setDefaultMaxTurns,
721
1318
  setGraceTurns,
722
1319
  setDefaultJoinMode,
1320
+ setBackgroundByDefault,
723
1321
  setSchedulingEnabled,
724
1322
  setScopeModels: setScopeModelsEnabled,
1323
+ setStrictAgentFiles: (b) => { strictAgentFiles = b; },
725
1324
  setDisableDefaultAgents: setDisableDefaultAgents,
726
1325
  setToolDescriptionMode: setToolDescriptionMode,
1326
+ setFleetView: setFleetViewEnabled,
1327
+ setAgentMentions: setAgentMentionMode,
1328
+ setRememberAgents,
727
1329
  setWidgetMode: setWidgetMode,
728
- setOutputTranscript: setOutputTranscript,
1330
+ setOutputTranscript: setOutputTranscriptDefault,
1331
+ setWorktreeIsolation: setWorktreeIsolationEnabled,
1332
+ setWorkflowsEnabled: setWorkflowsEnabled,
1333
+ setMaxSubagentDepth: setMaxSubagentDepth,
1334
+ setFallbackSubagent: setFallbackSubagent,
1335
+ setReportUsage,
1336
+ setShowCost,
1337
+ setShowModel,
1338
+ setViewerMarkdown,
729
1339
  }, (event, payload) => pi.events.emit(event, payload));
730
1340
  // ---- Agent tool ----
731
1341
  // Schedule param + its guideline are gated on `schedulingEnabled` (read once
@@ -744,6 +1354,19 @@ export default function (pi) {
744
1354
  const scheduleGuideline = isSchedulingEnabled()
745
1355
  ? `\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.`
746
1356
  : "";
1357
+ // Same trade as scheduleParam/scheduleGuideline above: `isolationParam` drops
1358
+ // the field from the schema when the project set `worktreeIsolation: false`,
1359
+ // so the prose has to go with it. Left in, it would teach the model to pass a
1360
+ // parameter that isn't declared — accepted (TypeBox sets no
1361
+ // `additionalProperties: false`) and then silently dropped by the resolver.
1362
+ // With no per-result note by design, the model would have every reason to go
1363
+ // on reporting a `pi-agent-*` branch that was never created.
1364
+ const isolationGuideline = isWorktreeIsolationEnabled()
1365
+ ? `\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.`
1366
+ : "";
1367
+ const isolationCompactGuideline = isWorktreeIsolationEnabled()
1368
+ ? `\n- isolation: "worktree" gives the agent its own git worktree (removed on completion); changes land on a branch named in the result.`
1369
+ : "";
747
1370
  // Compact Agent tool description (#91, `toolDescriptionMode: "compact"`) —
748
1371
  // the same load-bearing facts as the full version at ~75% fewer tokens, for
749
1372
  // small/local models. Per-option details live in the param descriptions.
@@ -754,10 +1377,10 @@ Custom agents: .pi/agents/<name>.md (project) or ${getAgentDir()}/agents/<name>.
754
1377
 
755
1378
  Notes:
756
1379
  - description: 3-5 words (shown in UI). Prompts must be self-contained — the agent has not seen this conversation.
757
- - Parallel work: one message, multiple Agent calls, run_in_background: true on each. You are notified when background agents finish — never poll or sleep.
1380
+ - Parallel work: one message, multiple Agent calls — they run concurrently.
1381
+ - 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.
758
1382
  - The result is not shown to the user — summarize it for them. Verify an agent's claimed code changes before reporting work done.
759
- - resume continues a previous agent by ID; steer_subagent messages a running one.
760
- - isolation: "worktree" runs the agent in an isolated git worktree; changes land on a branch.`;
1383
+ - resume continues a previous agent by ID; steer_subagent messages a running one.${isolationCompactGuideline}`;
761
1384
  const fullAgentToolDescription = `Launch a new agent to handle complex, multi-step tasks autonomously. Each agent type has specific capabilities and tools available to it.
762
1385
 
763
1386
  Available agent types and the tools they have access to:
@@ -774,23 +1397,23 @@ If the target is already known, use a direct tool — \`read\` for a known path,
774
1397
  ## Usage notes
775
1398
 
776
1399
  - Always include a short (3-5 word) description summarizing what the agent will do (shown in UI).
777
- - 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.
1400
+ - 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.
778
1401
  - 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.
779
- - 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.
780
- - 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.
781
- - 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.
1402
+ - 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.
1403
+ - 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.
1404
+ - **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.
1405
+ - **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.
782
1406
  - 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.
783
1407
  - Use steer_subagent to send mid-run messages to a running background agent.
784
1408
  - 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.
785
1409
  - If an agent's description says it should be used proactively, try to use it without the user having to ask for it first.
786
1410
  - Use model to specify a different model (as "provider/modelId", or fuzzy e.g. "haiku", "sonnet").
787
1411
  - Use thinking to control extended thinking level.
788
- - Use inherit_context if the agent needs the parent conversation history.
789
- - 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}
1412
+ - Use inherit_context if the agent needs the parent conversation history.${isolationGuideline}${scheduleGuideline}
790
1413
 
791
1414
  ## Writing the prompt
792
1415
 
793
- 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.
1416
+ 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.
794
1417
  - Explain what you're trying to accomplish and why.
795
1418
  - Describe what you've already learned or ruled out.
796
1419
  - Give enough context about the surrounding problem that the agent can make judgment calls rather than just following a narrow instruction.
@@ -809,6 +1432,7 @@ Terse command-style prompts produce shallow, generic work.
809
1432
  typeList: buildTypeListText,
810
1433
  compactTypeList: buildCompactTypeListText,
811
1434
  agentDir: getAgentDir,
1435
+ isolationGuideline: () => isolationGuideline,
812
1436
  scheduleGuideline: () => scheduleGuideline,
813
1437
  };
814
1438
  // Replacement callback (not a string) — agent descriptions may contain `$&` etc.
@@ -850,7 +1474,10 @@ Terse command-style prompts produce shallow, generic work.
850
1474
  }
851
1475
  return fullAgentToolDescription;
852
1476
  })();
853
- pi.registerTool(defineTool({
1477
+ // Held rather than registered inline: the mention clone reuses this exact
1478
+ // definition, so the agent it starts is an ordinary top-level spawn instead
1479
+ // of a second implementation that has to be kept in step with this one.
1480
+ const agentTool = defineTool({
854
1481
  name: SUBAGENT_TOOL_NAMES.AGENT,
855
1482
  label: "Agent",
856
1483
  description: agentToolDescription,
@@ -868,6 +1495,9 @@ Terse command-style prompts produce shallow, generic work.
868
1495
  description: Type.String({
869
1496
  description: "A short (3-5 word) description of the task (shown in UI).",
870
1497
  }),
1498
+ name: Type.Optional(Type.String({
1499
+ description: '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.',
1500
+ })),
871
1501
  subagent_type: Type.String({
872
1502
  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.`,
873
1503
  }),
@@ -882,10 +1512,10 @@ Terse command-style prompts produce shallow, generic work.
882
1512
  minimum: 1,
883
1513
  })),
884
1514
  run_in_background: Type.Optional(Type.Boolean({
885
- description: "Set to true to run in background. Returns agent ID immediately. You will be notified on completion.",
1515
+ 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.",
886
1516
  })),
887
1517
  resume: Type.Optional(Type.String({
888
- description: "Optional agent ID to resume from. Continues from previous context.",
1518
+ 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.",
889
1519
  })),
890
1520
  isolated: Type.Optional(Type.Boolean({
891
1521
  description: "If true, agent gets no extension/MCP tools — only built-in tools.",
@@ -893,21 +1523,37 @@ Terse command-style prompts produce shallow, generic work.
893
1523
  inherit_context: Type.Optional(Type.Boolean({
894
1524
  description: "If true, fork parent conversation into the agent. Default: false (fresh context).",
895
1525
  })),
896
- isolation: Type.Optional(Type.Literal("worktree", {
897
- 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.',
898
- })),
1526
+ ...isolationParam(isWorktreeIsolationEnabled()),
899
1527
  ...scheduleParam,
900
1528
  }),
901
1529
  // ---- Custom rendering: Claude Code style ----
902
- renderCall(args, theme) {
903
- const displayName = args.subagent_type ? getDisplayName(args.subagent_type) : "Agent";
1530
+ renderCall(args, theme, context) {
1531
+ // A badge closes its own background, which would clear the tool block's row tint
1532
+ // for the rest of the line, so the badge restores it. The tint is opened here too:
1533
+ // the TUI's Box paints it, but HTML export takes it from CSS, and restoring a
1534
+ // background the line never opened is what banded the export before. The line is
1535
+ // deliberately left open — Box.applyBackgroundToLine pads to width and *then*
1536
+ // wraps, so closing here would leave that padding untinted, and HTML export closes
1537
+ // any open span per line anyway. No badge means no tint, so an uncolored agent
1538
+ // renders exactly the line it always did.
1539
+ const rowBackground = hasAgentBadge(args.subagent_type)
1540
+ ? theme.getBgAnsi(context.isPartial ? "toolPendingBg" : context.isError ? "toolErrorBg" : "toolSuccessBg")
1541
+ : "";
904
1542
  const desc = args.description ?? "";
905
- return new Text("▸ " + theme.fg("toolTitle", theme.bold(displayName)) + (desc ? " " + theme.fg("muted", desc) : ""), 0, 0);
1543
+ const name = renderAgentName(args.subagent_type, theme, {
1544
+ fallbackColor: "toolTitle",
1545
+ restoreBackground: rowBackground,
1546
+ bold: true,
1547
+ });
1548
+ return new Text(rowBackground + "▸ " + name + (desc ? " " + theme.fg("muted", desc) : ""), 0, 0);
906
1549
  },
907
- renderResult(result, { expanded, isPartial }, theme) {
1550
+ renderResult(result, { expanded, isPartial }, theme, renderContext) {
908
1551
  const details = result.details;
909
- if (!details) {
910
- const text = result.content[0]?.type === "text" ? result.content[0].text : "";
1552
+ const text = result.content[0]?.type === "text" ? result.content[0].text : "";
1553
+ // Pi reports pre-execution failures (extension block, abort, argument
1554
+ // validation) as `{ content: [reason], details: {} }` with isError set —
1555
+ // no status to render, so show the reason instead of inventing one (#199).
1556
+ if (renderContext.isError || !details?.status) {
911
1557
  return new Text(text, 0, 0);
912
1558
  }
913
1559
  // Helper: build "haiku · thinking: high · ↻5≤30 · 3 tool uses · 33.8k tokens" stats string
@@ -924,6 +1570,11 @@ Terse command-style prompts produce shallow, generic work.
924
1570
  parts.push(`${d.toolUses} tool use${d.toolUses === 1 ? "" : "s"}`);
925
1571
  if (d.tokens)
926
1572
  parts.push(d.tokens);
1573
+ if (showCost) {
1574
+ const costText = formatCost(d.cost ?? 0);
1575
+ if (costText)
1576
+ parts.push(costText);
1577
+ }
927
1578
  return parts.map(p => fgPreservingNestedStyles(theme, "dim", p)).join(" " + theme.fg("dim", "·") + " ");
928
1579
  };
929
1580
  // ---- While running (streaming) ----
@@ -969,6 +1620,11 @@ Terse command-style prompts produce shallow, generic work.
969
1620
  line += "\n" + theme.fg("dim", " ⎿ Stopped");
970
1621
  return new Text(line, 0, 0);
971
1622
  }
1623
+ // Anything left ("queued", or a status added later) has no rendering of
1624
+ // its own — the turn-limit wording below must not be the catch-all.
1625
+ if (details.status !== "error" && details.status !== "aborted") {
1626
+ return new Text(text, 0, 0);
1627
+ }
972
1628
  // ---- Error / Aborted (hard max_turns) ----
973
1629
  const s = stats(details);
974
1630
  let line = theme.fg("error", "✗") + (s ? " " + s : "");
@@ -987,13 +1643,38 @@ Terse command-style prompts produce shallow, generic work.
987
1643
  // Reload custom agents so new project/global .md files are picked up without restart
988
1644
  reloadCustomAgents();
989
1645
  const rawType = params.subagent_type;
990
- const resolved = resolveType(rawType);
991
- const subagentType = resolved ?? "general-purpose";
992
- const fellBack = resolved === undefined;
1646
+ // Single decision point for dispatch (#183): unknown, disabled and
1647
+ // case-ambiguous types are refused here, BEFORE anything spawns, so a
1648
+ // background or scheduled call can't start running the wrong agent while
1649
+ // the caller is still unaware. `fallbackSubagent` decides whether an
1650
+ // unresolvable type falls back or fails closed.
1651
+ const dispatch = resolveSpawnType(rawType);
1652
+ // `resume` replays a stored session and ignores `subagent_type` entirely,
1653
+ // but the parameter is required by the schema — so gating it here would
1654
+ // make a live agent unresumable the moment its type is deleted, disabled,
1655
+ // or gains a case-clashing sibling. Only a real spawn is gated.
1656
+ if (!dispatch.ok && !params.resume)
1657
+ return textResult(dispatch.message);
1658
+ const subagentType = dispatch.ok ? dispatch.type : rawType;
1659
+ // What the caller actually asked for, named once: `fellBackFrom` is "" for
1660
+ // a blank request, so reading it inline invites the `??`-vs-`||` slip that
1661
+ // once persisted an empty type into a scheduled job.
1662
+ const requestedType = (dispatch.ok && dispatch.fellBackFrom) || subagentType;
1663
+ // Computed at resolution rather than after the run, so the background and
1664
+ // schedule branches carry it too — previously it existed only on the
1665
+ // foreground path. Resume deliberately doesn't: it replays the stored
1666
+ // session and ignores `subagent_type` entirely, so a note about type
1667
+ // substitution would be describing something that didn't happen.
1668
+ const fallbackNote = dispatch.ok && dispatch.fellBackFrom !== undefined
1669
+ ? `Note: Unknown agent type "${dispatch.fellBackFrom}" — using ${resolveType(subagentType) ? subagentType : "the fallback agent config"}.\n\n`
1670
+ : "";
993
1671
  const displayName = getDisplayName(subagentType);
994
1672
  // Get agent config (if any)
995
1673
  const customConfig = getAgentConfig(subagentType);
996
- const resolvedConfig = resolveAgentInvocationConfig(customConfig, params);
1674
+ const resolvedConfig = resolveAgentInvocationConfig(customConfig, params, {
1675
+ worktreeAllowed: isWorktreeIsolationEnabled(),
1676
+ defaultRunInBackground: getBackgroundByDefault(),
1677
+ });
997
1678
  // Resolve model from agent config first; tool-call params only fill gaps.
998
1679
  let model = ctx.model;
999
1680
  if (resolvedConfig.modelInput) {
@@ -1008,28 +1689,20 @@ Terse command-style prompts produce shallow, generic work.
1008
1689
  }
1009
1690
  }
1010
1691
  // Scope validation: the effective resolved model is checked against the
1011
- // user's enabledModels list (read in `enabled-models.ts`).
1012
- //
1013
- // Design: scopeModels guards against *runtime* LLM choices, not user-level config.
1014
- // - Caller-supplied out-of-scope → hard error (the orchestrator made an explicit
1015
- // out-of-scope choice; surface it so it picks differently).
1016
- // - Frontmatter-pinned or parent-inherited out-of-scope → warn but proceed (the
1017
- // user authored/installed this agent or chose the parent's model; trust it).
1018
- // See SubagentsSettings.scopeModels docstring for the full policy.
1019
- if (isScopeModelsEnabled() && model) {
1020
- const allowed = resolveEnabledModels(readEnabledModels(ctx.cwd), ctx.modelRegistry, ctx.cwd);
1021
- if (allowed && !isModelInScope(model, allowed)) {
1022
- if (resolvedConfig.modelFromParams) {
1023
- const list = [...allowed].sort().map(m => ` ${m}`).join("\n");
1024
- return textResult(`Model not in scope: "${resolvedConfig.modelInput}".\n\n` +
1025
- `Allowed models (from enabledModels):\n${list}`);
1026
- }
1027
- // Frontmatter-pinned or parent-inherited: warn + proceed.
1028
- const agentLabel = customConfig?.displayName ?? subagentType;
1029
- const modelLabel = resolvedConfig.modelInput ?? `${model.provider}/${model.id}`;
1030
- ctx.ui.notify(`Agent "${agentLabel}" using out-of-scope model "${modelLabel}"`, "warning");
1031
- }
1032
- }
1692
+ // user's enabledModels list. Policy (hard error vs warn-and-proceed) lives
1693
+ // in model-scope.ts so the nested delegation tools apply the same rule.
1694
+ const scopeVerdict = checkModelScope({
1695
+ model,
1696
+ cwd: ctx.cwd,
1697
+ modelRegistry: ctx.modelRegistry,
1698
+ callerSupplied: resolvedConfig.modelFromParams,
1699
+ agentLabel: customConfig?.displayName ?? subagentType,
1700
+ modelInput: resolvedConfig.modelInput,
1701
+ });
1702
+ if (scopeVerdict.kind === "error")
1703
+ return textResult(scopeVerdict.message);
1704
+ if (scopeVerdict.kind === "warn")
1705
+ ctx.ui.notify(scopeVerdict.message, "warning");
1033
1706
  const thinking = resolvedConfig.thinking;
1034
1707
  const inheritContext = resolvedConfig.inheritContext;
1035
1708
  const runInBackground = resolvedConfig.runInBackground;
@@ -1046,29 +1719,35 @@ Terse command-style prompts produce shallow, generic work.
1046
1719
  return;
1047
1720
  rec.outputFile = createOutputFilePath(ctx.cwd, agentId, ctx.sessionManager.getSessionId());
1048
1721
  writeInitialEntry(rec.outputFile, agentId, params.prompt, ctx.cwd);
1049
- try {
1050
- rec.historyFile = createAgentHistoryPath(ctx.cwd, agentId);
1051
- rec.transcriptPath = agentHistoryLocator(ctx.cwd, rec.historyFile);
1052
- writeInitialEntry(rec.historyFile, agentId, params.prompt, ctx.cwd);
1053
- manager.setTranscript(agentId, rec.historyFile, rec.transcriptPath, ctx.cwd);
1054
- }
1055
- catch (err) {
1056
- rec.historyFile = undefined;
1057
- rec.transcriptPath = undefined;
1058
- ctx.ui.notify(`Could not create durable transcript for agent ${agentId}: ${err instanceof Error ? err.message : String(err)}`, "warning");
1059
- }
1060
1722
  };
1061
- const parentModelId = ctx.model?.id;
1062
- const effectiveModelId = model?.id;
1063
- const modelName = effectiveModelId && effectiveModelId !== parentModelId
1064
- ? (model?.name ?? effectiveModelId).replace(/^Claude\s+/i, "").toLowerCase()
1065
- : undefined;
1723
+ // Unconditional, not "only when it differs from the parent": a thinking
1724
+ // level reads as a property of a model, and an agent that inherited the
1725
+ // parent's model used to show the level with nothing to attach it to.
1726
+ // This is the pre-session snapshot — agent-manager overwrites it with the
1727
+ // effective values the moment a session reports them.
1728
+ const { modelName, modelId } = model ? describeModel(model) : { modelName: undefined, modelId: undefined };
1729
+ // What the caller SPELLED, kept only if it names a different model than the
1730
+ // one that won. Model input is fuzzy — `"haiku"` and
1731
+ // `"anthropic/claude-haiku-4-5"` are the same model — so comparing the two
1732
+ // strings would disclose an override that never happened. A spelling that
1733
+ // resolves to nothing is still worth disclosing: it cannot have taken effect.
1734
+ const askedModel = ((asked) => {
1735
+ if (!asked)
1736
+ return undefined;
1737
+ const resolvedAsked = resolveModel(asked, ctx.modelRegistry);
1738
+ if (typeof resolvedAsked === "string")
1739
+ return asked;
1740
+ return resolvedAsked.provider === model?.provider && resolvedAsked.id === model?.id ? undefined : asked;
1741
+ })(resolvedConfig.overridden?.model);
1066
1742
  const effectiveMaxTurns = normalizeMaxTurns(resolvedConfig.maxTurns ?? getDefaultMaxTurns());
1067
1743
  const agentInvocation = {
1068
1744
  modelName,
1069
- effectiveModelName: model?.name ?? model?.id ?? ctx.model?.name ?? ctx.model?.id,
1745
+ modelId,
1070
1746
  thinking,
1071
- effectiveThinking: thinking,
1747
+ // Only set where the agent file outranked the caller, so the surfaces can
1748
+ // disclose a parameter that was accepted but could not take effect (#182).
1749
+ requestedThinking: resolvedConfig.overridden?.thinking,
1750
+ requestedModel: askedModel,
1072
1751
  // Explicit value only — the default fallback would just add noise.
1073
1752
  // Normalize so `0` (unlimited) doesn't surface as a misleading "max turns: 0".
1074
1753
  maxTurns: normalizeMaxTurns(resolvedConfig.maxTurns),
@@ -1088,6 +1767,34 @@ Terse command-style prompts produce shallow, generic work.
1088
1767
  modelName,
1089
1768
  tags: agentTags.length > 0 ? agentTags : undefined,
1090
1769
  };
1770
+ /**
1771
+ * `detailBase` for a record that exists, which outranks it: the base is a
1772
+ * snapshot of what this call REQUESTED, and pi may have resolved a
1773
+ * different model or clamped the thinking level (agent-manager writes the
1774
+ * effective values back when the session reports them). Resume goes
1775
+ * further and ignores the model/thinking parameters outright — it runs on
1776
+ * the session it is reopening — so rendering the base there advertises
1777
+ * settings the run never used.
1778
+ *
1779
+ * The mode label is rebuilt rather than carried over: it hangs off the
1780
+ * agent TYPE, not the invocation, so tags taken straight from
1781
+ * buildInvocationTags would silently drop `twin`.
1782
+ */
1783
+ const detailBaseFor = (rec) => {
1784
+ if (!rec?.invocation)
1785
+ return detailBase;
1786
+ const type = rec.type;
1787
+ const { modelName: recModelName, tags } = buildInvocationTags(rec.invocation);
1788
+ const recModeLabel = getPromptModeLabel(type);
1789
+ const recTags = recModeLabel ? [recModeLabel, ...tags] : tags;
1790
+ return {
1791
+ displayName: getDisplayName(type),
1792
+ description: rec.description,
1793
+ subagentType: type,
1794
+ modelName: recModelName,
1795
+ tags: recTags.length > 0 ? recTags : undefined,
1796
+ };
1797
+ };
1091
1798
  // ---- Schedule: register a job, don't spawn now ----
1092
1799
  if (params.schedule) {
1093
1800
  if (!isSchedulingEnabled()) {
@@ -1110,7 +1817,9 @@ Terse command-style prompts produce shallow, generic work.
1110
1817
  name: params.description,
1111
1818
  description: params.description,
1112
1819
  schedule: params.schedule,
1113
- subagent_type: subagentType,
1820
+ // The caller's own name, not the substitute — the scheduler re-resolves
1821
+ // at fire time, and the original is what a user edits.
1822
+ subagent_type: requestedType,
1114
1823
  prompt: params.prompt,
1115
1824
  model: params.model,
1116
1825
  thinking: thinking,
@@ -1119,7 +1828,7 @@ Terse command-style prompts produce shallow, generic work.
1119
1828
  isolation: isolation,
1120
1829
  });
1121
1830
  const next = scheduler.getNextRun(job.id);
1122
- return textResult(`Scheduled "${job.name}" (id: ${job.id}, type: ${job.scheduleType}). ` +
1831
+ return textResult(`${fallbackNote}Scheduled "${job.name}" (id: ${job.id}, type: ${job.scheduleType}). ` +
1123
1832
  `Next run: ${next ?? "(unknown)"}. ` +
1124
1833
  `Manage via /agents → Scheduled jobs.`);
1125
1834
  }
@@ -1130,12 +1839,44 @@ Terse command-style prompts produce shallow, generic work.
1130
1839
  // Resume existing agent
1131
1840
  if (params.resume) {
1132
1841
  const existing = manager.getRecord(params.resume);
1133
- if (!existing) {
1842
+ if (!existing || !isTopLevelAgent(existing)) {
1134
1843
  return textResult(`Agent not found: "${params.resume}". It may have been cleaned up.`);
1135
1844
  }
1136
1845
  if (!existing.session) {
1137
1846
  return textResult(`Agent "${params.resume}" has no active session to resume.`);
1138
1847
  }
1848
+ // Background resume: detached run that notifies on completion, mirroring
1849
+ // a background spawn. Previously run_in_background was silently ignored
1850
+ // on resume (this branch returned before the background branch below),
1851
+ // so a resumed agent always blocked the main loop until it finished.
1852
+ if (runInBackground) {
1853
+ const id = existing.id;
1854
+ // A detached resume hands control back while the record stays
1855
+ // "running", so nothing stops the model from resuming the same agent
1856
+ // again mid-run. manager.resume() refuses that (it would orphan the
1857
+ // live run's abort controller); say why here, where the model can act
1858
+ // on it, instead of letting it read as a generic failure.
1859
+ if (existing.status === "running" || existing.status === "queued") {
1860
+ return textResult(`Agent "${params.resume}" is still ${existing.status} — it can only be resumed once its current run finishes.\n` +
1861
+ `Use steer_subagent to send it a message mid-run, or get_subagent_result to wait for it.`);
1862
+ }
1863
+ const record = await startBackgroundResume(ctx, existing, params.prompt, {
1864
+ outputTranscript,
1865
+ maxTurns: effectiveMaxTurns,
1866
+ toolCallId,
1867
+ });
1868
+ if (!record) {
1869
+ return textResult(`Failed to resume agent "${params.resume}".`);
1870
+ }
1871
+ const isQueued = record.status === "queued";
1872
+ return textResult(`Agent ${isQueued ? "queued" : "resumed"} in background.\n` +
1873
+ `Agent ID: ${id}\n` +
1874
+ `Type: ${existing.type}\n` +
1875
+ (record.outputFile ? `Output file: ${record.outputFile}\n` : "") +
1876
+ (isQueued ? `Position: queued (max ${manager.getMaxConcurrent()} concurrent)\n` : "") +
1877
+ `\nYou will be notified when this agent completes.\n` +
1878
+ `Use get_subagent_result to retrieve full results, or steer_subagent to send it messages.`, { ...detailBaseFor(record), toolUses: record.toolUses, tokens: "", durationMs: 0, status: "background", agentId: id });
1879
+ }
1139
1880
  const record = await manager.resume(params.resume, params.prompt, signal);
1140
1881
  if (!record) {
1141
1882
  return textResult(`Failed to resume agent "${params.resume}".`);
@@ -1143,53 +1884,57 @@ Terse command-style prompts produce shallow, generic work.
1143
1884
  // A failed resume surfaces the error, plus any partial output THIS
1144
1885
  // resume produced (never the previous turn's answer, #144).
1145
1886
  if (record.status === "error") {
1146
- return textResult(`Agent failed: ${record.error}${partialOutputSuffix(record)}`, buildDetails(detailBase, record));
1887
+ return textResult(`Agent failed: ${record.error}${partialOutputSuffix(record)}`, buildDetails(detailBaseFor(record), record));
1147
1888
  }
1148
- return textResult(record.result?.trim() || "No output.", buildDetails(detailBase, record));
1889
+ return textResult(record.result?.trim() || "No output.", buildDetails(detailBaseFor(record), record));
1149
1890
  }
1150
1891
  // Background execution
1151
1892
  if (runInBackground) {
1152
1893
  const { state: bgState, callbacks: bgCallbacks } = createActivityTracker(effectiveMaxTurns);
1153
1894
  // Wrap onSessionCreated to wire output file streaming.
1154
- // The callback reads the transcript paths installed synchronously by
1155
- // onSpawned before the agent can queue or start.
1895
+ // The callback lazily reads record.outputFile (set right after spawn)
1896
+ // rather than closing over a value that doesn't exist yet.
1156
1897
  let id;
1157
- const joinMode = resolveJoinMode(defaultJoinMode, true);
1158
1898
  const origBgOnSession = bgCallbacks.onSessionCreated;
1159
1899
  bgCallbacks.onSessionCreated = (session) => {
1160
1900
  origBgOnSession(session);
1161
1901
  const rec = manager.getRecord(id);
1162
1902
  if (rec?.outputFile) {
1163
- rec.outputCleanup = streamToOutputFile(session, rec.outputFile, id, ctx.cwd, rec.historyFile);
1903
+ rec.outputCleanup = streamToOutputFile(session, rec.outputFile, id, ctx.cwd, undefined);
1164
1904
  }
1165
1905
  };
1166
- try {
1167
- id = manager.spawn(pi, ctx, subagentType, params.prompt, {
1168
- description: params.description,
1169
- model,
1170
- maxTurns: effectiveMaxTurns,
1171
- isolated,
1172
- inheritContext,
1173
- thinkingLevel: thinking,
1174
- isBackground: true,
1175
- isolation,
1176
- invocation: agentInvocation,
1177
- onSpawned: (spawnedId) => {
1178
- attachTranscript(manager.getRecord(spawnedId), spawnedId);
1179
- },
1180
- ...bgCallbacks,
1181
- });
1182
- }
1183
- catch (err) {
1184
- return textResult(err instanceof Error ? err.message : String(err));
1185
- }
1186
- // Set join metadata after spawn. Transcript metadata was installed by
1187
- // the manager's synchronous onSpawned callback before this point.
1906
+ // A throw here means the agent never started. Let it out: pi marks a
1907
+ // tool call failed only when execute throws, and a returned message
1908
+ // reads to the model as a subagent that ran and reported this (#179).
1909
+ id = manager.spawn(pi, ctx, subagentType, params.prompt, {
1910
+ description: params.description,
1911
+ name: params.name,
1912
+ model,
1913
+ maxTurns: effectiveMaxTurns,
1914
+ isolated,
1915
+ inheritContext,
1916
+ thinkingLevel: thinking,
1917
+ isBackground: true,
1918
+ isolation,
1919
+ invocation: agentInvocation,
1920
+ outputTranscript,
1921
+ rootSessionId: ctx.sessionManager.getSessionId(),
1922
+ ...bgCallbacks,
1923
+ });
1924
+ // Set output file + join mode synchronously after spawn, before the
1925
+ // event loop yields — onSessionCreated is async so this is safe.
1926
+ const joinMode = resolveJoinMode(defaultJoinMode, true);
1188
1927
  const record = manager.getRecord(id);
1189
1928
  if (record && joinMode) {
1190
1929
  record.joinMode = joinMode;
1191
1930
  record.toolCallId = toolCallId;
1931
+ attachTranscript(record, id);
1192
1932
  }
1933
+ // With isolation: "worktree" the agent isn't running yet — the repo
1934
+ // copy is an awaited git call. Wait for it here, after the synchronous
1935
+ // wiring above, so a strict-isolation failure still fails THIS tool
1936
+ // call instead of being reported as a subagent that ran (#179).
1937
+ await manager.awaitStartup(id);
1193
1938
  if (joinMode == null || joinMode === 'async') {
1194
1939
  // Foreground/no join mode or explicit async — not part of any batch
1195
1940
  }
@@ -1205,6 +1950,8 @@ Terse command-style prompts produce shallow, generic work.
1205
1950
  agentActivity.set(id, bgState);
1206
1951
  widget.ensureTimer();
1207
1952
  widget.update();
1953
+ fleet.ensureTimer();
1954
+ fleet.update();
1208
1955
  // Emit created event
1209
1956
  pi.events.emit("subagents:created", {
1210
1957
  id,
@@ -1213,7 +1960,7 @@ Terse command-style prompts produce shallow, generic work.
1213
1960
  isBackground: true,
1214
1961
  });
1215
1962
  const isQueued = record?.status === "queued";
1216
- return textResult(`Agent ${isQueued ? "queued" : "started"} in background.\n` +
1963
+ return textResult(`${fallbackNote}Agent ${isQueued ? "queued" : "started"} in background.\n` +
1217
1964
  `Agent ID: ${id}\n` +
1218
1965
  `Type: ${displayName}\n` +
1219
1966
  `Description: ${params.description}\n` +
@@ -1221,22 +1968,38 @@ Terse command-style prompts produce shallow, generic work.
1221
1968
  (isQueued ? `Position: queued (max ${manager.getMaxConcurrent()} concurrent)\n` : "") +
1222
1969
  `\nYou will be notified when this agent completes.\n` +
1223
1970
  `Use get_subagent_result to retrieve full results, or steer_subagent to send it messages.\n` +
1224
- `Do not duplicate this agent's work.`, { ...detailBase, toolUses: 0, tokens: "", durationMs: 0, status: "background", agentId: id });
1971
+ `Do not duplicate this agent's work.`, { ...detailBaseFor(record), toolUses: 0, tokens: "", durationMs: 0, status: "background", agentId: id });
1225
1972
  }
1226
1973
  // Foreground (synchronous) execution — stream progress via onUpdate
1227
1974
  let spinnerFrame = 0;
1228
1975
  const startedAt = Date.now();
1229
1976
  let fgId;
1977
+ // Set only while the spawn is parked on a foreground concurrency slot
1978
+ // (maxConcurrentForeground); undefined the rest of the time, including
1979
+ // always when the limit is unset.
1980
+ let queuedAhead;
1230
1981
  const streamUpdate = () => {
1982
+ // Spend from the record, everything else from the live tracker. `fgId`
1983
+ // is set in onSessionCreated below, which fires before the first
1984
+ // assistant message — so nothing is spent while this reads zero.
1985
+ const fgRecord = fgId ? manager.getRecord(fgId) : undefined;
1231
1986
  const details = {
1232
- ...detailBase,
1987
+ ...detailBaseFor(fgRecord),
1233
1988
  toolUses: fgState.toolUses,
1234
- tokens: formatLifetimeTokens(fgState),
1989
+ tokens: fgRecord ? formatLifetimeTokens(fgRecord) : "",
1990
+ cost: fgRecord ? getLifetimeCost(fgRecord.lifetimeUsage) : 0,
1235
1991
  turnCount: fgState.turnCount,
1236
1992
  maxTurns: fgState.maxTurns,
1237
1993
  durationMs: Date.now() - startedAt,
1994
+ // Deliberately still "running" while queued: the renderer routes any
1995
+ // status it doesn't know to raw text (see the catch-all below), which
1996
+ // would drop the spinner and read as hung. Only the activity line
1997
+ // changes — "thinking…" would be a lie for an agent that has not
1998
+ // started and may not for minutes.
1238
1999
  status: "running",
1239
- activity: describeActivity(fgState.activeTools, fgState.responseText),
2000
+ activity: queuedAhead === undefined
2001
+ ? describeActivity(fgState.activeTools, fgState.responseText)
2002
+ : `queued — waiting for a foreground slot${queuedAhead > 0 ? ` (${queuedAhead} ahead)` : ""}`,
1240
2003
  spinnerFrame: spinnerFrame % SPINNER.length,
1241
2004
  };
1242
2005
  onUpdate?.({
@@ -1251,12 +2014,20 @@ Terse command-style prompts produce shallow, generic work.
1251
2014
  const origOnSession = fgCallbacks.onSessionCreated;
1252
2015
  fgCallbacks.onSessionCreated = (session) => {
1253
2016
  origOnSession(session);
2017
+ // It really started — stop reporting it as queued, and repaint now
2018
+ // rather than leaving the stale line up for the next spinner tick.
2019
+ // Guarded, so a spawn that never queued emits no extra update.
2020
+ if (queuedAhead !== undefined) {
2021
+ queuedAhead = undefined;
2022
+ streamUpdate();
2023
+ }
1254
2024
  for (const a of manager.listAgents()) {
1255
2025
  if (a.session === session) {
1256
2026
  fgId = a.id;
1257
2027
  agentActivity.set(a.id, fgState);
1258
2028
  widget.ensureTimer();
1259
- widget.update();
2029
+ fleet.ensureTimer();
2030
+ fleet.update();
1260
2031
  break;
1261
2032
  }
1262
2033
  }
@@ -1264,7 +2035,7 @@ Terse command-style prompts produce shallow, generic work.
1264
2035
  if (fgId) {
1265
2036
  const rec = manager.getRecord(fgId);
1266
2037
  if (rec?.outputFile) {
1267
- rec.outputCleanup = streamToOutputFile(session, rec.outputFile, fgId, ctx.cwd, rec.historyFile);
2038
+ rec.outputCleanup = streamToOutputFile(session, rec.outputFile, fgId, ctx.cwd, undefined);
1268
2039
  }
1269
2040
  }
1270
2041
  };
@@ -1278,6 +2049,7 @@ Terse command-style prompts produce shallow, generic work.
1278
2049
  try {
1279
2050
  const fgResult = await manager.spawnAndWait(pi, ctx, subagentType, params.prompt, {
1280
2051
  description: params.description,
2052
+ name: params.name,
1281
2053
  model,
1282
2054
  maxTurns: effectiveMaxTurns,
1283
2055
  isolated,
@@ -1285,7 +2057,13 @@ Terse command-style prompts produce shallow, generic work.
1285
2057
  thinkingLevel: thinking,
1286
2058
  isolation,
1287
2059
  invocation: agentInvocation,
2060
+ outputTranscript,
1288
2061
  signal,
2062
+ rootSessionId: ctx.sessionManager.getSessionId(),
2063
+ // Deliberately does NOT set fgId: that drives agentActivity, the
2064
+ // widget and the `finally` cleanup below, none of which should see an
2065
+ // agent that has no session and may never get one.
2066
+ onQueued: (_id, ahead) => { queuedAhead = ahead; streamUpdate(); },
1289
2067
  ...fgCallbacks,
1290
2068
  }, (fgAgentId) => {
1291
2069
  // onSpawned: called synchronously after spawn, before onSessionCreated fires.
@@ -1295,24 +2073,21 @@ Terse command-style prompts produce shallow, generic work.
1295
2073
  });
1296
2074
  record = fgResult.record;
1297
2075
  }
1298
- catch (err) {
2076
+ finally {
2077
+ // Runs on both paths, so a startup throw — which now propagates, see
2078
+ // the background spawn above (#179) — no longer leaves the spinner
2079
+ // ticking or a finished agent on the widget.
1299
2080
  clearInterval(spinnerInterval);
1300
- return textResult(err instanceof Error ? err.message : String(err));
2081
+ if (fgId) {
2082
+ agentActivity.delete(fgId);
2083
+ widget.markFinished(fgId);
2084
+ fleet.onAgentFinished(fgId);
2085
+ }
1301
2086
  }
1302
- clearInterval(spinnerInterval);
1303
- // Clean up foreground agent from widget
1304
- if (fgId) {
1305
- agentActivity.delete(fgId);
1306
- widget.markFinished(fgId);
1307
- }
1308
- // Get final token count
1309
- const tokenText = formatLifetimeTokens(fgState);
1310
- const details = buildDetails(detailBase, record, fgState, { tokens: tokenText });
1311
- // "general-purpose" may itself be unregistered (defaults disabled, no
1312
- // user override) — getConfig then uses the hardcoded fallback config.
1313
- const fallbackNote = fellBack
1314
- ? `Note: Unknown agent type "${rawType}" — using ${resolveType("general-purpose") ? "general-purpose" : "the fallback agent config"}.\n\n`
1315
- : "";
2087
+ // Get final token count — from the record, like the cost below it, so the
2088
+ // two describe the same work when the agent delegated to nested children.
2089
+ const tokenText = formatLifetimeTokens(record);
2090
+ const details = buildDetails(detailBaseFor(record), record, fgState, { tokens: tokenText });
1316
2091
  if (record.status === "error") {
1317
2092
  // Error headline + any partial output the run produced before failing.
1318
2093
  return textResult(`${fallbackNote}Agent failed: ${record.error}${partialOutputSuffix(record)}`, details);
@@ -1321,19 +2096,441 @@ Terse command-style prompts produce shallow, generic work.
1321
2096
  const statsParts = [`${record.toolUses} tool uses`];
1322
2097
  if (tokenText)
1323
2098
  statsParts.push(tokenText);
1324
- return textResult(`${fallbackNote}Agent completed in ${formatMs(durationMs)} (${statsParts.join(", ")})${getStatusNote(record.status)}.\n\n` +
2099
+ if (showCost) {
2100
+ const costText = formatCost(getLifetimeCost(record.lifetimeUsage));
2101
+ if (costText)
2102
+ statsParts.push(costText);
2103
+ }
2104
+ return textResult(`${fallbackNote}Agent completed in ${formatMs(durationMs)} (${statsParts.join(", ")})${getForegroundOutcomeNote(record.status)}.\n\n` +
1325
2105
  (record.result?.trim() || "No output."), details);
1326
2106
  },
1327
- }));
2107
+ });
2108
+ /**
2109
+ * Wrap a tool so its results carry back whatever subagent spend the parent
2110
+ * session has not been told about yet (see `PendingUsagePool`).
2111
+ *
2112
+ * Pi copies `AgentToolResult.usage` onto the persisted tool-result message and
2113
+ * folds it into `getSessionStats()`, which is what the footer, the statusline
2114
+ * and `/cost` read — so this is the whole of "report usage to the parent".
2115
+ *
2116
+ * Nothing is attached to a call with no tool-call id. That is the `@handle`
2117
+ * mention path (`mention-clone.ts`), which invokes this tool from a fork of the
2118
+ * conversation that is discarded moments later: the result never becomes a
2119
+ * message in the real session, so usage hung on it would be spend the user paid
2120
+ * for and nobody counted. Skipping leaves it pending for the next real result.
2121
+ */
2122
+ function withUsageReporting(tool) {
2123
+ return {
2124
+ ...tool,
2125
+ execute: async (toolCallId, ...rest) => {
2126
+ const result = await tool.execute(toolCallId, ...rest);
2127
+ if (!reportUsage || !toolCallId)
2128
+ return result;
2129
+ const usage = pendingUsage.drain();
2130
+ return usage ? { ...result, usage } : result;
2131
+ },
2132
+ };
2133
+ }
2134
+ function registerToolReportingUsage(tool) {
2135
+ pi.registerTool(withUsageReporting(tool));
2136
+ }
2137
+ // The mention path is handed THIS object, not the bare `agentTool` — see the
2138
+ // mention-clone header on why the clone must call the registered tool.
2139
+ const registeredAgentTool = withUsageReporting(agentTool);
2140
+ pi.registerTool(registeredAgentTool);
2141
+ // ---- Workflow tool ----
2142
+ /**
2143
+ * Live runs, by task id. The tool returns before the run finishes, so its
2144
+ * result card looks the task up here on every render rather than freezing a
2145
+ * snapshot into `details` — that is what makes the inline card follow a
2146
+ * background run.
2147
+ */
2148
+ const workflowTasks = new Map();
2149
+ /**
2150
+ * Workflow runs as the fleet list wants them.
2151
+ *
2152
+ * Mapped here rather than handing `WorkflowTask` over the seam: the list is
2153
+ * deliberately ignorant of the workflow engine, and a run's counters live in
2154
+ * the progress log rather than on the record, so they are derived per call
2155
+ * the same way the card derives them.
2156
+ */
2157
+ function fleetWorkflows() {
2158
+ // Cached counters only, no derivation: the fleet list calls this on a
2159
+ // 200ms tick and reads the roster several times per update, so walking a
2160
+ // run's progress log here would put O(log) work in the render loop.
2161
+ return [...workflowTasks.values()].map(task => ({
2162
+ id: task.id,
2163
+ name: task.meta?.name ?? task.workflowName ?? task.id,
2164
+ status: task.status,
2165
+ doneCount: task.doneCount,
2166
+ totalCount: task.agentCount,
2167
+ startedAt: task.startTime,
2168
+ ...(task.endTime !== undefined ? { completedAt: task.endTime } : {}),
2169
+ tokens: task.totalTokens,
2170
+ }));
2171
+ }
2172
+ /**
2173
+ * Run a task to completion against the real manager, settling the record
2174
+ * either way. Never rejects: a run that cannot start (bad `meta`, oversized
2175
+ * source, non-JSON `args`) is a failed workflow, and both callers here are
2176
+ * detached — a rejection would surface as an unhandled one.
2177
+ */
2178
+ async function runWorkflowTask(ctx, task) {
2179
+ try {
2180
+ const result = await runWorkflow({
2181
+ script: task.script,
2182
+ args: task.args,
2183
+ signal: task.abortController.signal,
2184
+ host: createWorkflowHost({
2185
+ pi,
2186
+ ctx,
2187
+ manager,
2188
+ signal: task.abortController.signal,
2189
+ rootSessionId: ctx.sessionManager.getSessionId(),
2190
+ workflowId: task.id,
2191
+ }),
2192
+ onProgress: entries => updateWorkflowProgressBatch(task, entries),
2193
+ // The dialog's pause / skip / retry keys run through this; it is dropped
2194
+ // again when the task settles.
2195
+ onControl: control => { task.control = control; },
2196
+ journal: {
2197
+ ...(task.replay !== undefined ? { entries: task.replay } : {}),
2198
+ ...(task.journalPath !== undefined
2199
+ ? { append: (entry) => appendJournal(task.journalPath, entry) }
2200
+ : {}),
2201
+ },
2202
+ });
2203
+ completeWorkflowTask(task, result);
2204
+ }
2205
+ catch (err) {
2206
+ failWorkflowTask(task, err instanceof Error ? err.message : String(err));
2207
+ }
2208
+ }
2209
+ /**
2210
+ * Hand a finished run back to the model through the SAME channel a background
2211
+ * agent uses — held briefly by `scheduleNudge`, delivered as a follow-up that
2212
+ * triggers a turn, rendered by the existing `subagent-notification` renderer.
2213
+ */
2214
+ function notifyWorkflowFinished(task) {
2215
+ widget.update();
2216
+ fleet.update();
2217
+ const result = workflowResultText(task);
2218
+ scheduleNudge(task.id, () => {
2219
+ pi.sendMessage({
2220
+ customType: "subagent-notification",
2221
+ content: formatWorkflowNotification(task),
2222
+ display: true,
2223
+ details: {
2224
+ id: task.id,
2225
+ description: `Workflow ${task.workflowName ?? task.id}`,
2226
+ status: task.status === "completed" ? "completed" : task.status === "killed" ? "stopped" : "error",
2227
+ toolUses: task.totalToolCalls,
2228
+ // A workflow has agents, not turns; rendering "↻0" would be noise.
2229
+ turnCount: 0,
2230
+ totalTokens: task.totalTokens,
2231
+ durationMs: elapsedMs(task, Date.now()),
2232
+ error: task.error,
2233
+ resultPreview: result.length > 500 ? `${result.slice(0, 500)}…` : result,
2234
+ },
2235
+ }, { deliverAs: "followUp", triggerTurn: true });
2236
+ });
2237
+ }
2238
+ // Defined unconditionally, registered only when the feature is on — the same
2239
+ // shape the Agent tool uses. Keeping the definition out of the `if` means the
2240
+ // switch changes exactly one thing: whether pi is ever told about the tool.
2241
+ const workflowTool = defineTool({
2242
+ name: SUBAGENT_TOOL_NAMES.WORKFLOW,
2243
+ label: "SubagentWorkflow",
2244
+ description: renderToolDescriptionTemplate(fullWorkflowToolDescription),
2245
+ promptSnippet: "Run a deterministic script that orchestrates many subagents",
2246
+ promptGuidelines: [
2247
+ "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.",
2248
+ "Prefer `pipeline` over `parallel` — a barrier costs wall-clock whenever the stages are unevenly sized.",
2249
+ "A workflow runs in the background and notifies you when it finishes — do not poll or sleep waiting for it.",
2250
+ ],
2251
+ parameters: Type.Object({
2252
+ script: Type.Optional(Type.String({
2253
+ maxLength: 524288,
2254
+ description: "Inline workflow source. Must begin with `export const meta = { name, description }`.",
2255
+ })),
2256
+ scriptPath: Type.Optional(Type.String({
2257
+ description: "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.",
2258
+ })),
2259
+ name: Type.Optional(Type.String({
2260
+ description: "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.",
2261
+ })),
2262
+ args: Type.Optional(Type.Any({
2263
+ description: "Exposed to the script as the global `args`, verbatim. Must be JSON-shaped.",
2264
+ })),
2265
+ resumeFromRunId: Type.Optional(Type.String({
2266
+ pattern: "^wf_[a-z0-9-]{6,}$",
2267
+ description: "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.",
2268
+ })),
2269
+ // Accepted and ignored, as in Claude Code. Models reach for them because
2270
+ // every other tool has them, and a hard schema rejection would cost a
2271
+ // whole turn to re-emit a script that was already correct. The `meta`
2272
+ // block is the one place a workflow is named.
2273
+ title: Type.Optional(Type.String({ description: "Ignored — set the workflow title in the script's `meta` block." })),
2274
+ description: Type.Optional(Type.String({ description: "Ignored — set the workflow description in the script's `meta` block." })),
2275
+ }),
2276
+ renderCall(args, theme) {
2277
+ return new Text(`${theme.fg("toolTitle", "▸ ")}${theme.bold(theme.fg("toolTitle", "SubagentWorkflow"))} ${theme.fg("muted", workflowCallName(args))}`, 0, 0);
2278
+ },
2279
+ renderResult(result, _options, theme, renderContext) {
2280
+ const text = result.content[0]?.type === "text" ? result.content[0].text : "";
2281
+ const taskId = result.details?.taskId;
2282
+ const task = taskId !== undefined ? workflowTasks.get(taskId) : undefined;
2283
+ // No task means the run predates this session (a reloaded transcript) or
2284
+ // the call never started one — show what `execute` said instead.
2285
+ if (renderContext.isError || !task)
2286
+ return new Text(text, 0, 0);
2287
+ return renderWorkflowCard({
2288
+ progress: task.workflowProgress,
2289
+ task: {
2290
+ status: task.status,
2291
+ workflowName: task.workflowName,
2292
+ startTime: task.startTime,
2293
+ endTime: task.endTime,
2294
+ totalPausedMs: task.totalPausedMs,
2295
+ },
2296
+ meta: task.meta,
2297
+ agentCount: task.agentCount,
2298
+ totalTokens: task.totalTokens,
2299
+ }, theme);
2300
+ },
2301
+ execute: async (toolCallId, params, _signal, _onUpdate, ctx) => {
2302
+ const resumeFrom = resolveResumeTarget(params.resumeFromRunId, workflowTasks);
2303
+ if (resumeFrom !== undefined && !resumeFrom.ok)
2304
+ return textResult(resumeFrom.message);
2305
+ // A resume with no source of its own re-runs what that run ran. The
2306
+ // common case is an edited script, but "run that again, cheaply" should
2307
+ // not require repeating a path the run already knows.
2308
+ const resolved = resolveWorkflowScript(params.script === undefined && params.scriptPath === undefined && params.name === undefined
2309
+ && resumeFrom !== undefined
2310
+ ? { scriptPath: resumeFrom.scriptPath }
2311
+ : params, ctx.cwd);
2312
+ if (!resolved.ok)
2313
+ return textResult(resolved.message);
2314
+ // Parsed before anything is scheduled: a bad `meta` is an authoring error
2315
+ // the model can fix immediately, and reporting it as a background run
2316
+ // that failed a second later would just cost a turn.
2317
+ let meta;
2318
+ try {
2319
+ meta = extractMeta(resolved.script).meta;
2320
+ }
2321
+ catch (err) {
2322
+ return textResult(err instanceof Error ? err.message : String(err));
2323
+ }
2324
+ const runId = workflowRunId();
2325
+ // Every invocation lands on disk next to the agent transcripts, so
2326
+ // iterating is edit-the-file-then-rerun-with-scriptPath rather than
2327
+ // re-emitting the whole source. The journal sits beside it under the same
2328
+ // id, which is what makes a run id enough to resume from.
2329
+ let savedPath;
2330
+ let journalPath;
2331
+ try {
2332
+ const dir = sessionTaskDir(ctx.cwd, ctx.sessionManager.getSessionId());
2333
+ savedPath = join(dir, `${runId}.workflow.js`);
2334
+ writeFileSync(savedPath, resolved.script, "utf-8");
2335
+ journalPath = join(dir, `${runId}.workflow.jsonl`);
2336
+ }
2337
+ catch (err) {
2338
+ savedPath = undefined;
2339
+ journalPath = undefined;
2340
+ console.warn(`[pi-subagents] could not persist workflow script: ${err instanceof Error ? err.message : String(err)}`);
2341
+ }
2342
+ const replay = resumeFrom !== undefined ? readJournal(resumeFrom.journalPath) : undefined;
2343
+ const task = createWorkflowTask({
2344
+ id: runId,
2345
+ script: resolved.script,
2346
+ scriptPath: resolved.scriptPath ?? savedPath,
2347
+ args: params.args,
2348
+ meta,
2349
+ toolCallId,
2350
+ ...(journalPath !== undefined ? { journalPath } : {}),
2351
+ ...(replay !== undefined && replay.length > 0 ? { replay, resumedFrom: resumeFrom.runId } : {}),
2352
+ });
2353
+ workflowTasks.set(runId, task);
2354
+ // The run's own row has to appear now, not when it settles. Its agents
2355
+ // are owned by it, so their lifecycle callbacks no longer refresh these
2356
+ // surfaces — nothing else would register the widget for a run whose
2357
+ // first agent has not started yet.
2358
+ widget.update();
2359
+ fleet.update();
2360
+ // Background, like Claude Code: the id comes back now and the run keeps
2361
+ // going without the tool call.
2362
+ void runWorkflowTask(ctx, task).then(() => notifyWorkflowFinished(task));
2363
+ return {
2364
+ content: [{
2365
+ type: "text",
2366
+ text: `Workflow "${meta.name}" started in the background.\n` +
2367
+ `Task ID: ${runId}\n` +
2368
+ (task.scriptPath ? `Script: ${task.scriptPath}\n` : "") +
2369
+ (task.resumedFrom !== undefined
2370
+ ? `Resuming ${task.resumedFrom}: ${task.replay?.length ?? 0} recorded call(s) available to replay.\n`
2371
+ : params.resumeFromRunId !== undefined
2372
+ ? `Nothing to replay from ${params.resumeFromRunId} — every agent runs live.\n`
2373
+ : "") +
2374
+ `\nYou will be notified when it finishes — do NOT poll or sleep waiting for it.\n` +
2375
+ `To iterate, edit the script file and call SubagentWorkflow again with scriptPath.`,
2376
+ }],
2377
+ details: { taskId: runId },
2378
+ };
2379
+ },
2380
+ });
2381
+ if (isWorkflowsEnabled())
2382
+ pi.registerTool(workflowTool);
2383
+ /**
2384
+ * Act on {@link decideWorkflowCollision} — the half that needs the host.
2385
+ *
2386
+ * The policy (what counts as a conflict, what a pin changes, whether there is
2387
+ * anything left to withdraw) lives in `workflow/collisions.ts`; this is the
2388
+ * host-facing shell around it: read the registry, warn, and take our tool out
2389
+ * of the active set.
2390
+ *
2391
+ * ## Why this can only happen at session_start
2392
+ *
2393
+ * `getAllTools` throws during extension loading ("Action methods cannot be
2394
+ * called during extension loading"), and load order means a check at
2395
+ * registration time could not see an extension that has not loaded yet. So
2396
+ * the decision cannot gate `registerTool`; it has to undo it. `setActiveTools`
2397
+ * is what makes that real rather than cosmetic — pi rebuilds the system
2398
+ * prompt from the new set, and `session_start` runs before any turn, so the
2399
+ * model never sees a spec we withdrew. A later `_refreshToolRegistry` keeps
2400
+ * the active set it had and only adds names new to the registry, so ours does
2401
+ * not creep back.
2402
+ *
2403
+ * Best-effort and swallowed. A diagnostic that took the session down would be
2404
+ * worse than the collision it reports.
2405
+ */
2406
+ let collisionsChecked = false;
2407
+ function resolveWorkflowCollisions(ctx) {
2408
+ if (collisionsChecked)
2409
+ return;
2410
+ collisionsChecked = true;
2411
+ const warn = (message) => {
2412
+ if (ctx.hasUI)
2413
+ ctx.ui.notify(message, "warning");
2414
+ else
2415
+ console.warn(`[pi-subagents] ${message}`);
2416
+ };
2417
+ try {
2418
+ if (!isWorkflowsEnabled())
2419
+ return;
2420
+ const verdict = decideWorkflowCollision({
2421
+ tools: pi.getAllTools(),
2422
+ // Identifies our own registration: this extension does not know its
2423
+ // install path, and the description is the one field certainly ours.
2424
+ ownDescription: workflowTool.description,
2425
+ pinned: isWorkflowsPinned(),
2426
+ });
2427
+ if (verdict.kind === "none")
2428
+ return;
2429
+ if (verdict.kind === "report") {
2430
+ warn(verdict.message);
2431
+ return;
2432
+ }
2433
+ workflowsEnabled = false; // not setWorkflowsEnabled: this is not the user pinning it
2434
+ widget.update();
2435
+ fleet.update();
2436
+ warn(verdict.message);
2437
+ if (!verdict.withdraw)
2438
+ return;
2439
+ const active = pi.getActiveTools();
2440
+ if (active.includes(SUBAGENT_TOOL_NAMES.WORKFLOW)) {
2441
+ pi.setActiveTools(active.filter(name => name !== SUBAGENT_TOOL_NAMES.WORKFLOW));
2442
+ }
2443
+ }
2444
+ catch {
2445
+ // getAllTools/setActiveTools are unavailable in some hosts (print mode,
2446
+ // RPC). Not being able to check is not a reason to fail the session.
2447
+ }
2448
+ }
2449
+ /**
2450
+ * `--subagents-workflow-file=<path>` — run a script at startup, with no LLM
2451
+ * round-trip deciding whether to call the tool.
2452
+ *
2453
+ * Read here rather than at activation because that is the only place the real
2454
+ * value exists: the host activates extensions first and applies collected CLI
2455
+ * flags second, so `getFlag` during activation returns the registered default
2456
+ * and nothing else. `examples/extensions/ssh.ts` reads its flag from
2457
+ * session_start for exactly this reason.
2458
+ */
2459
+ let workflowFlagHandled = false;
2460
+ function runWorkflowFlag(ctx) {
2461
+ if (workflowFlagHandled)
2462
+ return;
2463
+ const flag = typeof pi.getFlag === "function" ? pi.getFlag(WORKFLOW_FILE_FLAG) : undefined;
2464
+ if (flag === undefined || flag === false)
2465
+ return;
2466
+ workflowFlagHandled = true;
2467
+ const report = (message, level) => {
2468
+ if (ctx.hasUI)
2469
+ ctx.ui.notify(message, level);
2470
+ else
2471
+ console.warn(`[pi-subagents] ${message}`);
2472
+ };
2473
+ // The flag is the same machinery by another door, so the master switch has
2474
+ // to close it too — silently ignoring a flag the user typed would be worse
2475
+ // than saying why nothing ran.
2476
+ if (!isWorkflowsEnabled()) {
2477
+ report(`--${WORKFLOW_FILE_FLAG} ignored: workflows are off. Turn them on in /agents → Settings → Workflows, ` +
2478
+ 'or set `"workflowsEnabled": true` in .pi/subagents.json.', "warning");
2479
+ return;
2480
+ }
2481
+ // A bare `--subagents-workflow-file` parses to boolean `true`. Say what was
2482
+ // missing rather than reading a file called "true".
2483
+ if (typeof flag !== "string" || flag.trim() === "") {
2484
+ report(`--${WORKFLOW_FILE_FLAG} needs a path: --${WORKFLOW_FILE_FLAG}=<path>`, "warning");
2485
+ return;
2486
+ }
2487
+ const path = isAbsolute(flag.trim()) ? flag.trim() : join(ctx.cwd, flag.trim());
2488
+ let script;
2489
+ try {
2490
+ script = readFileSync(path, "utf-8");
2491
+ }
2492
+ catch (err) {
2493
+ report(`Could not read ${path}: ${err instanceof Error ? err.message : String(err)}`, "warning");
2494
+ return;
2495
+ }
2496
+ let meta;
2497
+ try {
2498
+ meta = extractMeta(script).meta;
2499
+ }
2500
+ catch (err) {
2501
+ report(err instanceof Error ? err.message : String(err), "warning");
2502
+ return;
2503
+ }
2504
+ const task = createWorkflowTask({ id: workflowRunId(), script, scriptPath: path, meta });
2505
+ workflowTasks.set(task.id, task);
2506
+ widget.update();
2507
+ fleet.update();
2508
+ report(`Running workflow ${meta.name}…`, "info");
2509
+ // Detached: session_start is awaited by the host, and a workflow can run for
2510
+ // minutes — blocking here would hold the whole session's startup.
2511
+ void runWorkflowTask(ctx, task).then(() => {
2512
+ // No tool call to attach a result card to, so the card becomes a session
2513
+ // entry (same layout), and the outcome is handed to the model as context
2514
+ // for its next turn rather than forcing one.
2515
+ pi.appendEntry(WORKFLOW_ENTRY_TYPE, workflowEntryData(task));
2516
+ pi.sendMessage({
2517
+ customType: "workflow-result",
2518
+ content: formatWorkflowNotification(task),
2519
+ display: false,
2520
+ }, { deliverAs: "nextTurn" });
2521
+ widget.update();
2522
+ fleet.update();
2523
+ });
2524
+ }
1328
2525
  // ---- get_subagent_result tool ----
1329
- pi.registerTool(defineTool({
2526
+ registerToolReportingUsage(defineTool({
1330
2527
  name: SUBAGENT_TOOL_NAMES.GET_RESULT,
1331
2528
  label: "Get Agent Result",
1332
- description: "Check status and retrieve results from a background agent. Use the agent ID returned by Agent with run_in_background.",
2529
+ description: "Check status and retrieve a background agent's full result — its completion notification carries only a preview. Use the agent ID returned by Agent.",
1333
2530
  promptSnippet: "Check status and retrieve results from a background agent",
1334
2531
  parameters: Type.Object({
1335
2532
  agent_id: Type.String({
1336
- description: "The agent ID to check.",
2533
+ 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`).",
1337
2534
  }),
1338
2535
  wait: Type.Optional(Type.Boolean({
1339
2536
  description: "If true, wait for the agent to complete before returning. Default: false.",
@@ -1343,8 +2540,8 @@ Terse command-style prompts produce shallow, generic work.
1343
2540
  })),
1344
2541
  }),
1345
2542
  execute: async (_toolCallId, params, signal, _onUpdate, _ctx) => {
1346
- const record = manager.getRecord(params.agent_id);
1347
- if (!record) {
2543
+ const record = resolveAgentRef(params.agent_id);
2544
+ if (!record || !isTopLevelAgent(record)) {
1348
2545
  return textResult(`Agent not found: "${params.agent_id}". It may have been cleaned up.`);
1349
2546
  }
1350
2547
  // Wait for completion if requested. Cancellation stops only this tool
@@ -1359,9 +2556,6 @@ Terse command-style prompts produce shallow, generic work.
1359
2556
  if (record.promise)
1360
2557
  await abortable(record.promise, signal);
1361
2558
  }
1362
- const durableResult = !record.result?.trim() && record.transcriptPath && currentCtx?.cwd
1363
- ? readAgentHistoryResult(currentCtx.cwd, record.transcriptPath)
1364
- : undefined;
1365
2559
  const displayName = getDisplayName(record.type);
1366
2560
  const duration = formatDuration(record.startedAt, record.completedAt);
1367
2561
  const tokens = formatLifetimeTokens(record);
@@ -1369,6 +2563,11 @@ Terse command-style prompts produce shallow, generic work.
1369
2563
  const statsParts = [`Tool uses: ${record.toolUses}`];
1370
2564
  if (tokens)
1371
2565
  statsParts.push(tokens);
2566
+ if (showCost) {
2567
+ const costText = formatCost(getLifetimeCost(record.lifetimeUsage));
2568
+ if (costText)
2569
+ statsParts.push(`Cost: ${costText}`);
2570
+ }
1372
2571
  if (contextPercent !== null)
1373
2572
  statsParts.push(`Context: ${Math.round(contextPercent)}%`);
1374
2573
  if (record.compactionCount)
@@ -1381,10 +2580,10 @@ Terse command-style prompts produce shallow, generic work.
1381
2580
  output += "Agent is still running. Use wait: true or check back later.";
1382
2581
  }
1383
2582
  else if (record.status === "error") {
1384
- output += `Error: ${record.error}${partialOutputSuffix(record, durableResult)}`;
2583
+ output += `Error: ${record.error}${partialOutputSuffix(record)}`;
1385
2584
  }
1386
2585
  else {
1387
- output += durableResult || record.result?.trim() || "No output.";
2586
+ output += record.result?.trim() || "No output.";
1388
2587
  }
1389
2588
  // Mark result as consumed — suppresses the completion notification
1390
2589
  if (record.status !== "running" && record.status !== "queued") {
@@ -1402,7 +2601,7 @@ Terse command-style prompts produce shallow, generic work.
1402
2601
  },
1403
2602
  }));
1404
2603
  // ---- steer_subagent tool ----
1405
- pi.registerTool(defineTool({
2604
+ registerToolReportingUsage(defineTool({
1406
2605
  name: SUBAGENT_TOOL_NAMES.STEER,
1407
2606
  label: "Steer Agent",
1408
2607
  description: "Send a steering message to a running agent. The message will interrupt the agent after its current tool execution " +
@@ -1410,15 +2609,15 @@ Terse command-style prompts produce shallow, generic work.
1410
2609
  promptSnippet: "Send a steering message to redirect a running background agent",
1411
2610
  parameters: Type.Object({
1412
2611
  agent_id: Type.String({
1413
- description: "The agent ID to steer (must be currently running).",
2612
+ 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`).",
1414
2613
  }),
1415
2614
  message: Type.String({
1416
2615
  description: "The steering message to send. This will appear as a user message in the agent's conversation.",
1417
2616
  }),
1418
2617
  }),
1419
2618
  execute: async (_toolCallId, params, _signal, _onUpdate, _ctx) => {
1420
- const record = manager.getRecord(params.agent_id);
1421
- if (!record) {
2619
+ const record = resolveAgentRef(params.agent_id);
2620
+ if (!record || !isTopLevelAgent(record)) {
1422
2621
  return textResult(`Agent not found: "${params.agent_id}". It may have been cleaned up.`);
1423
2622
  }
1424
2623
  if (record.status !== "running") {
@@ -1440,6 +2639,11 @@ Terse command-style prompts produce shallow, generic work.
1440
2639
  const stateParts = [];
1441
2640
  if (tokens)
1442
2641
  stateParts.push(tokens);
2642
+ if (showCost) {
2643
+ const costText = formatCost(getLifetimeCost(record.lifetimeUsage));
2644
+ if (costText)
2645
+ stateParts.push(costText);
2646
+ }
1443
2647
  stateParts.push(`${record.toolUses} tool ${record.toolUses === 1 ? "use" : "uses"}`);
1444
2648
  if (contextPercent !== null)
1445
2649
  stateParts.push(`context ${Math.round(contextPercent)}% full`);
@@ -1454,22 +2658,9 @@ Terse command-style prompts produce shallow, generic work.
1454
2658
  },
1455
2659
  }));
1456
2660
  // ---- /agents interactive menu ----
1457
- const projectAgentsDir = () => join(process.cwd(), ".pi", "agents");
1458
- const workspaceAgentsDir = () => join(process.cwd(), ".agents", "agents");
1459
- const personalAgentsDir = () => join(getAgentDir(), "agents");
1460
- /** Find the file path of a custom agent by name, in discovery-precedence order (project, workspace, then global). */
1461
- function findAgentFile(name) {
1462
- const projectPath = join(projectAgentsDir(), `${name}.md`);
1463
- if (existsSync(projectPath))
1464
- return { path: projectPath, location: "project" };
1465
- const workspacePath = join(workspaceAgentsDir(), `${name}.md`);
1466
- if (existsSync(workspacePath))
1467
- return { path: workspacePath, location: "workspace" };
1468
- const personalPath = join(personalAgentsDir(), `${name}.md`);
1469
- if (existsSync(personalPath))
1470
- return { path: personalPath, location: "personal" };
1471
- return undefined;
1472
- }
2661
+ // Directory resolution and the frontmatter edits live in agent-file-toggle.ts
2662
+ // so they are reachable from tests — this command handler is only registered
2663
+ // through `registerCommand`, which every test mocks.
1473
2664
  function getModelLabel(type, registry) {
1474
2665
  const cfg = getAgentConfig(type);
1475
2666
  if (!cfg?.model)
@@ -1496,10 +2687,16 @@ Terse command-style prompts produce shallow, generic work.
1496
2687
  const allNames = getAllTypes();
1497
2688
  // Build select options
1498
2689
  const options = [];
1499
- // Keep active agents and terminal history in separate menu entries.
1500
- const records = manager.listAgents();
1501
- const { active, history } = splitAgentRecords(records, ctx.cwd);
1502
- options.push(...buildAgentStatusMenuEntries(records, ctx.cwd));
2690
+ // Keep active sessions and durable terminal history as separate menu rows.
2691
+ const agents = manager.listAgents().filter(isTopLevelAgent);
2692
+ const { active, history } = splitAgentRecords(agents, ctx.cwd);
2693
+ if (active.length > 0) {
2694
+ const running = active.filter(a => a.status === "running").length;
2695
+ const queued = active.filter(a => a.status === "queued").length;
2696
+ options.push(`Running agents (${active.length}) — ${running} running, ${queued} queued`);
2697
+ }
2698
+ if (history.length > 0)
2699
+ options.push(`Agent history (${history.length})`);
1503
2700
  // Agent types list
1504
2701
  if (allNames.length > 0) {
1505
2702
  options.push(`Agent types (${allNames.length})`);
@@ -1509,10 +2706,15 @@ Terse command-style prompts produce shallow, generic work.
1509
2706
  const jobCount = scheduler.list().length;
1510
2707
  options.push(`Scheduled jobs (${jobCount})`);
1511
2708
  }
2709
+ // Workflow runs, on the same terms as scheduled jobs: shown only when the
2710
+ // feature is on, so the menu never advertises something switched off.
2711
+ if (isWorkflowsEnabled()) {
2712
+ options.push(`Workflows (${workflowTasks.size})`);
2713
+ }
1512
2714
  // Actions
1513
2715
  options.push("Create new agent");
1514
2716
  options.push("Settings");
1515
- const noAgentsMsg = allNames.length === 0 && active.length === 0 && history.length === 0
2717
+ const noAgentsMsg = allNames.length === 0 && agents.length === 0
1516
2718
  ? "No agents found. Create specialized subagents that can be delegated to.\n\n" +
1517
2719
  "Each subagent has its own context window, custom system prompt, and specific tools.\n\n" +
1518
2720
  "Try creating: Code Reviewer, Security Auditor, Test Writer, or Documentation Writer.\n\n"
@@ -1539,6 +2741,10 @@ Terse command-style prompts produce shallow, generic work.
1539
2741
  await showSchedulesMenu(ctx, scheduler);
1540
2742
  await showAgentsMenu(ctx);
1541
2743
  }
2744
+ else if (choice.startsWith("Workflows (")) {
2745
+ await showWorkflowsMenu(ctx, workflowMenuDeps);
2746
+ await showAgentsMenu(ctx);
2747
+ }
1542
2748
  else if (choice === "Create new agent") {
1543
2749
  await showCreateWizard(ctx);
1544
2750
  }
@@ -1610,137 +2816,80 @@ Terse command-style prompts produce shallow, generic work.
1610
2816
  await showAllAgentsList(ctx);
1611
2817
  }
1612
2818
  }
1613
- function makeUniqueAgentOptionLabels(pairs) {
1614
- const counts = new Map();
1615
- for (const pair of pairs)
1616
- counts.set(pair.label, (counts.get(pair.label) ?? 0) + 1);
1617
- const used = new Set();
1618
- return pairs.map((pair) => {
1619
- const { record, label } = pair;
1620
- if ((counts.get(label) ?? 0) === 1) {
1621
- used.add(label);
1622
- return label;
1623
- }
1624
- const suffix = ` · #${record.id.slice(-8)}`;
1625
- let candidate = `${label}${suffix}`;
1626
- let n = 2;
1627
- while (used.has(candidate))
1628
- candidate = `${label}${suffix}-${n++}`;
1629
- used.add(candidate);
1630
- pair.label = candidate;
1631
- return candidate;
1632
- });
1633
- }
1634
- async function selectAgentFromReadOnlyList(ctx, title, pairs, selection) {
1635
- const options = pairs.map(({ record, label }) => ({ value: record.id, label }));
1636
- const rememberedIndex = selection.id
1637
- ? pairs.findIndex(({ record }) => record.id === selection.id)
1638
- : -1;
1639
- const initialIndex = rememberedIndex >= 0
1640
- ? rememberedIndex
1641
- : Math.max(0, Math.min(selection.index, pairs.length - 1));
1642
- const remember = (id) => {
1643
- const index = pairs.findIndex(({ record }) => record.id === id);
1644
- if (index >= 0) {
1645
- selection.id = id;
1646
- selection.index = index;
1647
- }
1648
- };
1649
- const choice = await ctx.ui.custom((_tui, _theme, _kb, done) => {
1650
- const list = new SelectList(options, Math.min(options.length, 10), getSelectListTheme());
1651
- list.setSelectedIndex(initialIndex);
1652
- const initialItem = options[initialIndex];
1653
- if (initialItem)
1654
- remember(initialItem.value);
1655
- list.onSelectionChange = item => remember(item.value);
1656
- list.onSelect = item => {
1657
- remember(item.value);
1658
- done(item.value);
1659
- };
1660
- list.onCancel = () => done(undefined);
1661
- const container = new Container();
1662
- container.addChild(new Text(title, 0, 0));
1663
- container.addChild(new Spacer(1));
1664
- container.addChild(list);
1665
- return {
1666
- render: (w) => container.render(w),
1667
- invalidate: () => container.invalidate(),
1668
- handleInput: (data) => list.handleInput(data),
1669
- };
1670
- });
1671
- if (!choice)
1672
- return undefined;
1673
- return pairs.find(({ record }) => record.id === choice)?.record;
1674
- }
1675
2819
  async function showRunningAgents(ctx) {
1676
- const { active: agents } = splitAgentRecords(manager.listAgents(), ctx.cwd);
2820
+ const agents = manager.listAgents().filter(record => isTopLevelAgent(record) && (record.status === "running" || record.status === "queued"));
1677
2821
  if (agents.length === 0) {
1678
2822
  ctx.ui.notify("No agents.", "info");
1679
2823
  return;
1680
2824
  }
1681
- const pairs = agents.map((record) => {
1682
- const dn = getDisplayName(record.type);
1683
- const dur = formatDuration(record.startedAt, record.completedAt);
1684
- return { record, label: `${dn} (${record.description}) · ${record.toolUses} tools · ${record.status} · ${dur}` };
2825
+ const record = await ctx.ui.custom((_tui, _theme, _keys, done) => {
2826
+ let index = Math.min(runningSelectionIndex, agents.length - 1);
2827
+ return {
2828
+ render: (width) => agents.map((agent, row) => `${row === index ? "→" : " "} ${agent.description}`.slice(0, width)),
2829
+ invalidate() { },
2830
+ handleInput(data) {
2831
+ if (data === "\u001b[B")
2832
+ index = Math.min(agents.length - 1, index + 1);
2833
+ else if (data === "\u001b[A")
2834
+ index = Math.max(0, index - 1);
2835
+ else if (data === "\r" || data === "\n") {
2836
+ runningSelectionIndex = index;
2837
+ done(agents[index]);
2838
+ }
2839
+ else if (data === "\u001b") {
2840
+ runningSelectionIndex = index;
2841
+ done(undefined);
2842
+ }
2843
+ },
2844
+ };
1685
2845
  });
1686
- makeUniqueAgentOptionLabels(pairs);
1687
- const record = await selectAgentFromReadOnlyList(ctx, "Running agents", pairs, runningAgentSelection);
1688
2846
  if (!record)
1689
2847
  return;
1690
- await viewAgentConversation(ctx, record, "live");
1691
- // Back-navigation: re-show the list at the previously selected agent.
2848
+ await viewAgentConversation(ctx, record);
1692
2849
  await showRunningAgents(ctx);
1693
2850
  }
1694
2851
  async function showAgentHistory(ctx) {
1695
- const { history } = splitAgentRecords(manager.listAgents(), ctx.cwd);
1696
- if (history.length === 0) {
1697
- ctx.ui.notify("No agent history.", "info");
2852
+ const history = manager.listAgents().filter(record => isTopLevelAgent(record) && canOpenAgentHistory(record, ctx.cwd));
2853
+ if (history.length === 0)
1698
2854
  return;
2855
+ const selected = await ctx.ui.custom((_tui, _theme, _keys, done) => {
2856
+ let index = Math.min(historySelectionIndex, history.length - 1);
2857
+ return {
2858
+ render: (width) => history.map((record, row) => `${row === index ? "→" : " "} ${record.description}`.slice(0, width)),
2859
+ invalidate() { },
2860
+ handleInput(data) {
2861
+ if (data === "\u001b[B")
2862
+ index = Math.min(history.length - 1, index + 1);
2863
+ else if (data === "\u001b[A")
2864
+ index = Math.max(0, index - 1);
2865
+ else if (data === "\r" || data === "\n") {
2866
+ historySelectionIndex = index;
2867
+ done(history[index]);
2868
+ }
2869
+ else if (data === "\u001b")
2870
+ done(undefined);
2871
+ },
2872
+ };
2873
+ });
2874
+ if (selected) {
2875
+ await viewAgentConversation(ctx, selected);
2876
+ await showAgentHistory(ctx);
1699
2877
  }
1700
- const pairs = history.map((record) => ({ record, label: formatAgentHistoryOption(record, Date.now()) }));
1701
- makeUniqueAgentOptionLabels(pairs);
1702
- const record = await selectAgentFromReadOnlyList(ctx, "Agent history", pairs, historyAgentSelection);
1703
- if (!record)
1704
- return;
1705
- await viewAgentConversation(ctx, record, "history");
1706
- // Back-navigation: re-show the list at the previously selected agent.
1707
- await showAgentHistory(ctx);
1708
2878
  }
1709
- async function viewAgentConversation(ctx, record, mode) {
1710
- if (mode === "live" && !canOpenActiveAgent(record)) {
1711
- ctx.ui.notify(`Agent is ${record.status === "queued" ? "queued" : "expired"} — no history available.`, "info");
1712
- return;
1713
- }
1714
- if (mode === "history" && !canOpenAgentHistory(record, ctx.cwd)) {
1715
- ctx.ui.notify("No agent history.", "info");
1716
- return;
1717
- }
2879
+ async function viewAgentConversation(ctx, record) {
1718
2880
  const { ConversationViewer, VIEWPORT_HEIGHT_PCT, createStaticConversationSource } = await import("./ui/conversation-viewer.js");
1719
- const session = mode === "live"
1720
- ? record.session
1721
- : (() => {
1722
- const messages = record.transcriptPath
1723
- ? readAgentHistory(ctx.cwd, record.transcriptPath)
1724
- : undefined;
1725
- return messages
1726
- ? createStaticConversationSource(messages)
1727
- : record.session
1728
- ? createStaticConversationSource(record.session.messages)
1729
- : undefined;
1730
- })();
2881
+ const messages = record.transcriptPath ? readAgentHistory(ctx.cwd, record.transcriptPath) : undefined;
2882
+ const session = record.session ?? (messages ? createStaticConversationSource(messages) : undefined);
1731
2883
  if (!session) {
1732
- ctx.ui.notify("No agent history.", "info");
2884
+ ctx.ui.notify(`Agent is ${record.status === "queued" ? "queued" : "expired"} — no session available.`, "info");
1733
2885
  return;
1734
2886
  }
2887
+ const isHistory = record.session === undefined;
1735
2888
  const activity = agentActivity.get(record.id);
1736
- const isLive = mode === "live";
1737
- await ctx.ui.custom((tui, theme, keybindings, done) => {
1738
- return new ConversationViewer(tui, session, record, activity, theme, done, isLive ? () => {
1739
- if (manager.abort(record.id)) {
1740
- ctx.ui.notify(`Stopped "${record.description}".`, "info");
1741
- }
1742
- } : undefined, keybindings, isLive ? (message) => manager.steer(record.id, message) : undefined, mode === "history" ? { pi, ctx, readOnly: true } : { pi, ctx });
1743
- }, {
2889
+ await ctx.ui.custom((tui, theme, keybindings, done) => new ConversationViewer(tui, session, record, activity, theme, done, isHistory ? undefined : () => {
2890
+ if (manager.abort(record.id))
2891
+ ctx.ui.notify(`Stopped "${record.description}".`, "info");
2892
+ }, keybindings, isHistory ? undefined : (message) => manager.steer(record.id, message), { pi, ctx, readOnly: isHistory }), {
1744
2893
  overlay: true,
1745
2894
  overlayOptions: { anchor: "center", width: "90%", maxHeight: `${VIEWPORT_HEIGHT_PCT}%` },
1746
2895
  });
@@ -1751,7 +2900,7 @@ Terse command-style prompts produce shallow, generic work.
1751
2900
  ctx.ui.notify(`Agent config not found for "${name}".`, "warning");
1752
2901
  return;
1753
2902
  }
1754
- const file = findAgentFile(name);
2903
+ const file = locateAgentFile(name, cfg.sourcePath);
1755
2904
  const isDefault = cfg.isDefault === true;
1756
2905
  const disabled = cfg.enabled === false;
1757
2906
  let menuOptions;
@@ -1830,44 +2979,7 @@ Terse command-style prompts produce shallow, generic work.
1830
2979
  if (!overwrite)
1831
2980
  return;
1832
2981
  }
1833
- // Build the .md file content
1834
- const fmFields = [];
1835
- fmFields.push(`description: ${JSON.stringify(cfg.description)}`);
1836
- if (cfg.displayName)
1837
- fmFields.push(`display_name: ${cfg.displayName}`);
1838
- fmFields.push(`tools: ${cfg.builtinToolNames?.join(", ") || "all"}`);
1839
- if (cfg.model)
1840
- fmFields.push(`model: ${cfg.model}`);
1841
- if (cfg.thinking)
1842
- fmFields.push(`thinking: ${cfg.thinking}`);
1843
- if (cfg.maxTurns)
1844
- fmFields.push(`max_turns: ${cfg.maxTurns}`);
1845
- fmFields.push(`prompt_mode: ${cfg.promptMode}`);
1846
- if (cfg.extensions === false)
1847
- fmFields.push("extensions: false");
1848
- else if (Array.isArray(cfg.extensions))
1849
- fmFields.push(`extensions: ${cfg.extensions.join(", ")}`);
1850
- if (cfg.excludeExtensions?.length)
1851
- fmFields.push(`exclude_extensions: ${cfg.excludeExtensions.join(", ")}`);
1852
- if (cfg.skills === false)
1853
- fmFields.push("skills: false");
1854
- else if (Array.isArray(cfg.skills))
1855
- fmFields.push(`skills: ${cfg.skills.join(", ")}`);
1856
- if (cfg.disallowedTools?.length)
1857
- fmFields.push(`disallowed_tools: ${cfg.disallowedTools.join(", ")}`);
1858
- if (cfg.inheritContext)
1859
- fmFields.push("inherit_context: true");
1860
- if (cfg.runInBackground)
1861
- fmFields.push("run_in_background: true");
1862
- if (cfg.outputTranscript === false)
1863
- fmFields.push("output_transcript: false");
1864
- if (cfg.isolated)
1865
- fmFields.push("isolated: true");
1866
- if (cfg.memory)
1867
- fmFields.push(`memory: ${cfg.memory}`);
1868
- if (cfg.isolation)
1869
- fmFields.push(`isolation: ${cfg.isolation}`);
1870
- const content = `---\n${fmFields.join("\n")}\n---\n\n${cfg.systemPrompt}\n`;
2982
+ const content = serializeAgentFile(cfg);
1871
2983
  const { writeFileSync } = await import("node:fs");
1872
2984
  writeFileSync(targetPath, content, "utf-8");
1873
2985
  reloadCustomAgents();
@@ -1875,15 +2987,21 @@ Terse command-style prompts produce shallow, generic work.
1875
2987
  }
1876
2988
  /** Disable an agent: set enabled: false in its .md file, or create a stub for built-in defaults. */
1877
2989
  async function disableAgent(ctx, name) {
1878
- const file = findAgentFile(name);
2990
+ const file = locateAgentFile(name, getAgentConfig(name)?.sourcePath);
1879
2991
  if (file) {
1880
2992
  // Existing file — set enabled: false in frontmatter (idempotent)
1881
2993
  const content = readFileSync(file.path, "utf-8");
1882
- if (content.includes("\nenabled: false\n")) {
2994
+ const { content: updated, outcome } = disableInContent(content);
2995
+ if (outcome === "already-disabled") {
1883
2996
  ctx.ui.notify(`${name} is already disabled.`, "info");
1884
2997
  return;
1885
2998
  }
1886
- const updated = content.replace(/^---\n/, "---\nenabled: false\n");
2999
+ if (outcome === "no-frontmatter") {
3000
+ // Nothing to edit — say so rather than rewriting the file unchanged and
3001
+ // reporting success for a change that never happened.
3002
+ ctx.ui.notify(`Cannot disable ${name}: ${file.path} has no frontmatter block.`, "error");
3003
+ return;
3004
+ }
1887
3005
  const { writeFileSync } = await import("node:fs");
1888
3006
  writeFileSync(file.path, updated, "utf-8");
1889
3007
  reloadCustomAgents();
@@ -1907,14 +3025,20 @@ Terse command-style prompts produce shallow, generic work.
1907
3025
  }
1908
3026
  /** Enable a disabled agent by removing enabled: false from its frontmatter. */
1909
3027
  async function enableAgent(ctx, name) {
1910
- const file = findAgentFile(name);
3028
+ const file = locateAgentFile(name, getAgentConfig(name)?.sourcePath);
1911
3029
  if (!file)
1912
3030
  return;
1913
3031
  const content = readFileSync(file.path, "utf-8");
1914
- const updated = content.replace(/^(---\n)enabled: false\n/, "$1");
3032
+ const { content: updated, changed } = enableInContent(content);
3033
+ if (!changed && !isEmptyStub(updated)) {
3034
+ // The file carries no `enabled: false` to remove, so it was never disabled
3035
+ // by us — reporting success here would hide a no-op.
3036
+ ctx.ui.notify(`${name} is not disabled in ${file.path}.`, "info");
3037
+ return;
3038
+ }
1915
3039
  const { writeFileSync } = await import("node:fs");
1916
3040
  // If the file was just a stub ("---\n---\n"), delete it to restore the built-in default
1917
- if (updated.trim() === "---\n---" || updated.trim() === "---\n---\n") {
3041
+ if (isEmptyStub(updated)) {
1918
3042
  unlinkSync(file.path);
1919
3043
  reloadCustomAgents();
1920
3044
  ctx.ui.notify(`Enabled ${name} (removed ${file.path})`, "info");
@@ -1970,6 +3094,7 @@ The file format is a markdown file with YAML frontmatter and a system prompt bod
1970
3094
  \`\`\`markdown
1971
3095
  ---
1972
3096
  description: <one-line description shown in UI>
3097
+ color: <optional agent name badge color: red, blue, green, yellow, purple, orange, pink, cyan, an Agency Agents alias, or quoted "#RRGGBB">
1973
3098
  tools: <comma-separated built-in tools: read, bash, edit, write, grep, find, ls. Use "none" for no tools. Omit for all tools>
1974
3099
  model: <optional model as "provider/modelId", e.g. "anthropic/claude-haiku-4-5". Omit to inherit parent model>
1975
3100
  thinking: <optional thinking level: ${THINKING_LEVELS.join(", ")}. Omit to inherit>
@@ -1979,11 +3104,17 @@ extensions: <true (inherit all MCP/extension tools), false (none), or comma-sepa
1979
3104
  skills: <true (inherit all), false (none), or comma-separated skill names to preload into prompt. Default: true>
1980
3105
  disallowed_tools: <comma-separated tool names to block, even if otherwise available. Omit for none>
1981
3106
  inherit_context: <true to fork parent conversation into agent so it sees chat history. Default: false>
1982
- run_in_background: <true to run in background by default. Default: false>
3107
+ run_in_background: <pin this agent to background (true) or foreground (false). Omit to follow the backgroundByDefault setting, which is background>
1983
3108
  output_transcript: <false to write no transcript file or path for this agent. Independent of persist_session. Default: true>
1984
3109
  isolated: <true for no extension/MCP tools, only built-in tools. Default: false>
1985
- memory: <"user" (global), "project" (per-project), or "local" (gitignored per-project) for persistent memory. Omit for none>
1986
- isolation: <"worktree" to run in isolated git worktree. Omit for normal>
3110
+ memory: <"user" (global), "project" (per-project), or "local" (gitignored per-project) for persistent memory. Omit for none>${
3111
+ // Offering the field on a project that turned worktrees off would bake a
3112
+ // request that is refused at spawn time into a file that outlives the
3113
+ // session — the #231 pathology (models fill the fields they are shown)
3114
+ // one layer up. Built per invocation, so this read is live.
3115
+ isWorktreeIsolationEnabled()
3116
+ ? `\nisolation: <"worktree" to run in isolated git worktree; "off" to refuse one even when the caller asks. Omit for normal>`
3117
+ : ""}
1987
3118
  ---
1988
3119
 
1989
3120
  <system prompt body — instructions for the agent>
@@ -2003,6 +3134,12 @@ Write the file using the write tool. Only write the file, nothing else.`;
2003
3134
  const { record } = await manager.spawnAndWait(pi, ctx, "general-purpose", generatePrompt, {
2004
3135
  description: `Generate ${name} agent`,
2005
3136
  maxTurns: 5,
3137
+ // Exempt from maxConcurrentForeground. This runs from a modal wizard, not
3138
+ // a tool call: it passes no signal, and Esc in `ctx.ui` never reaches the
3139
+ // manager — so a user waiting behind a full pool would have no way to
3140
+ // cancel at all. It is also one human action that cannot fan out, which
3141
+ // is what the limit exists to bound. It still counts once started.
3142
+ bypassQueue: true,
2006
3143
  });
2007
3144
  if (record.status === "error") {
2008
3145
  ctx.ui.notify(`Generation failed: ${record.error}`, "warning");
@@ -2055,39 +3192,32 @@ Write the file using the write tool. Only write the file, nothing else.`;
2055
3192
  ]);
2056
3193
  if (!modelChoice)
2057
3194
  return;
2058
- let modelLine = "";
3195
+ let model;
2059
3196
  if (modelChoice === "haiku")
2060
- modelLine = "\nmodel: anthropic/claude-haiku-4-5";
3197
+ model = "anthropic/claude-haiku-4-5";
2061
3198
  else if (modelChoice === "sonnet")
2062
- modelLine = "\nmodel: anthropic/claude-sonnet-4-6";
3199
+ model = "anthropic/claude-sonnet-4-6";
2063
3200
  else if (modelChoice === "opus")
2064
- modelLine = "\nmodel: anthropic/claude-opus-4-6";
3201
+ model = "anthropic/claude-opus-4-6";
2065
3202
  else if (modelChoice === "custom...") {
2066
- const customModel = await ctx.ui.input("Model (provider/modelId)");
2067
- if (customModel)
2068
- modelLine = `\nmodel: ${customModel}`;
3203
+ model = (await ctx.ui.input("Model (provider/modelId)")) || undefined;
2069
3204
  }
2070
3205
  // 5. Thinking
2071
3206
  // "inherit" is a UI-only pseudo-choice (omit the field); the rest mirror pi.
2072
3207
  const thinkingChoice = await ctx.ui.select("Thinking level", ["inherit", ...THINKING_LEVELS]);
2073
3208
  if (!thinkingChoice)
2074
3209
  return;
2075
- let thinkingLine = "";
2076
- if (thinkingChoice !== "inherit")
2077
- thinkingLine = `\nthinking: ${thinkingChoice}`;
2078
3210
  // 6. System prompt
2079
3211
  const systemPrompt = await ctx.ui.editor("System prompt", "");
2080
3212
  if (systemPrompt === undefined)
2081
3213
  return;
2082
- // Build the file
2083
- const content = `---
2084
- description: ${description}
2085
- tools: ${tools}${modelLine}${thinkingLine}
2086
- prompt_mode: replace
2087
- ---
2088
-
2089
- ${systemPrompt}
2090
- `;
3214
+ const content = buildNewAgentFile({
3215
+ description,
3216
+ tools,
3217
+ model,
3218
+ thinking: thinkingChoice === "inherit" ? undefined : thinkingChoice,
3219
+ systemPrompt,
3220
+ });
2091
3221
  mkdirSync(targetDir, { recursive: true });
2092
3222
  const targetPath = join(targetDir, `${name}.md`);
2093
3223
  if (existsSync(targetPath)) {
@@ -2100,28 +3230,76 @@ ${systemPrompt}
2100
3230
  reloadCustomAgents();
2101
3231
  ctx.ui.notify(`Created ${targetPath}`, "info");
2102
3232
  }
3233
+ /**
3234
+ * Every settings mutation writes this WHOLE object back to disk, so a field
3235
+ * missing here is erased from the user's subagents.json the next time they
3236
+ * toggle something unrelated. `SubagentsSettings` has every field optional,
3237
+ * so a `: SubagentsSettings` return annotation would let a newly-added setting
3238
+ * be forgotten here and still type-check. `satisfies` instead: it still checks
3239
+ * each value's type and rejects a mistyped key, but leaves the return type
3240
+ * inferred so `_NoMissingSettingsKeys` below can check completeness.
3241
+ */
2103
3242
  function snapshotSettings() {
2104
3243
  return {
2105
3244
  maxConcurrent: manager.getMaxConcurrent(),
3245
+ // 0 = unlimited, and the default — see SubagentsSettings.
3246
+ maxConcurrentForeground: manager.getMaxConcurrentForeground(),
2106
3247
  // 0 = unlimited — per SubagentsSettings.defaultMaxTurns docstring and
2107
3248
  // normalizeMaxTurns() in agent-runner.ts (which maps 0 → undefined).
2108
3249
  defaultMaxTurns: getDefaultMaxTurns() ?? 0,
2109
3250
  graceTurns: getGraceTurns(),
2110
3251
  defaultJoinMode: getDefaultJoinMode(),
3252
+ backgroundByDefault: getBackgroundByDefault(),
2111
3253
  schedulingEnabled: isSchedulingEnabled(),
2112
3254
  scopeModels: isScopeModelsEnabled(),
3255
+ strictAgentFiles,
2113
3256
  disableDefaultAgents: isDefaultsDisabled(),
2114
3257
  toolDescriptionMode: getToolDescriptionMode(),
3258
+ fleetView: isFleetViewEnabled(),
3259
+ agentMentions: getAgentMentionMode(),
3260
+ rememberAgents: getRememberAgents(),
2115
3261
  widgetMode: getWidgetMode(),
2116
3262
  outputTranscript: getOutputTranscriptDefault(),
3263
+ worktreeIsolation: isWorktreeIsolationEnabled(),
3264
+ // The user's answer, not the effective one. A stand-down for another
3265
+ // extension's workflow tool is scoped to the session it was detected in;
3266
+ // writing it here would let an unrelated settings change three menus away
3267
+ // freeze it into the file as an explicit `false`, which then survives
3268
+ // uninstalling the extension it was deferring to. undefined is dropped by
3269
+ // JSON.stringify, so unset stays unset — same reasoning as
3270
+ // `fallbackSubagent` below.
3271
+ workflowsEnabled: isWorkflowsPinned() ? isWorkflowsEnabled() : undefined,
3272
+ maxSubagentDepth: getMaxSubagentDepth(),
3273
+ // Deliberately NOT `?? "general-purpose"`: every settings change writes the
3274
+ // whole snapshot, and materializing the implicit default would turn it into
3275
+ // explicit configuration — which then fails loudly if general-purpose later
3276
+ // goes away. undefined is dropped by JSON.stringify.
3277
+ fallbackSubagent: getFallbackSubagent(),
3278
+ reportUsage: isReportUsageEnabled(),
3279
+ showCost: isShowCostEnabled(),
3280
+ showModel: isShowModelEnabled(),
3281
+ viewerMarkdown: getViewerMarkdown(),
2117
3282
  };
2118
3283
  }
2119
- const NUMERIC_IDS = new Set(["maxConcurrent", "defaultMaxTurns", "graceTurns"]);
3284
+ const _settingsSnapshotIsComplete = true;
3285
+ void _settingsSnapshotIsComplete;
3286
+ const NUMERIC_IDS = new Set([
3287
+ "maxConcurrent", "maxConcurrentForeground", "defaultMaxTurns", "graceTurns", "maxSubagentDepth",
3288
+ ]);
2120
3289
  async function showSettings(ctx) {
2121
3290
  function buildItems() {
2122
3291
  const mc = manager.getMaxConcurrent();
3292
+ const mcf = manager.getMaxConcurrentForeground();
2123
3293
  const dmt = getDefaultMaxTurns() ?? 0;
2124
3294
  const gt = getGraceTurns();
3295
+ const msd = getMaxSubagentDepth();
3296
+ // Label what unset actually does — it targets general-purpose even when
3297
+ // that is unregistered (the permissive hardcoded tier), so showing "none"
3298
+ // there would advertise strict dispatch for the most permissive state.
3299
+ // `values` still offers only resolvable targets, so the user cannot
3300
+ // persist a fallback that would hard-error on every dispatch.
3301
+ const fallbackValue = getFallbackSubagent() ?? "general-purpose";
3302
+ const fallbackValues = [...new Set([...getAvailableTypes(), NO_FALLBACK])];
2125
3303
  return [
2126
3304
  {
2127
3305
  id: "maxConcurrent",
@@ -2130,6 +3308,13 @@ ${systemPrompt}
2130
3308
  currentValue: String(mc),
2131
3309
  values: [String(mc)],
2132
3310
  },
3311
+ {
3312
+ id: "maxConcurrentForeground",
3313
+ label: "Max foreground concurrency",
3314
+ description: "Max concurrent foreground (blocking) agents (0 = unlimited, Enter to type)",
3315
+ currentValue: String(mcf),
3316
+ values: [String(mcf)],
3317
+ },
2133
3318
  {
2134
3319
  id: "defaultMaxTurns",
2135
3320
  label: "Default max turns",
@@ -2144,6 +3329,13 @@ ${systemPrompt}
2144
3329
  currentValue: String(gt),
2145
3330
  values: [String(gt)],
2146
3331
  },
3332
+ {
3333
+ id: "maxSubagentDepth",
3334
+ label: "Nested depth",
3335
+ description: "Hard cap on nested delegation — main is 0, its subagents 1 (0/1 = nesting off, Enter to type)",
3336
+ currentValue: String(msd),
3337
+ values: [String(msd)],
3338
+ },
2147
3339
  {
2148
3340
  id: "joinMode",
2149
3341
  label: "Join mode",
@@ -2151,6 +3343,13 @@ ${systemPrompt}
2151
3343
  currentValue: getDefaultJoinMode(),
2152
3344
  values: ["smart", "async", "group"],
2153
3345
  },
3346
+ {
3347
+ id: "backgroundByDefault",
3348
+ label: "Background by default",
3349
+ description: "An Agent call that doesn't say runs detached (off = blocks the turn and returns inline)",
3350
+ currentValue: getBackgroundByDefault() ? "on" : "off",
3351
+ values: ["on", "off"],
3352
+ },
2154
3353
  {
2155
3354
  id: "schedulingEnabled",
2156
3355
  label: "Scheduling",
@@ -2158,6 +3357,14 @@ ${systemPrompt}
2158
3357
  currentValue: isSchedulingEnabled() ? "on" : "off",
2159
3358
  values: ["on", "off"],
2160
3359
  },
3360
+ {
3361
+ id: "workflowsEnabled",
3362
+ label: "Workflows",
3363
+ description: "Scripted workflows, on unless another extension provides a workflow tool "
3364
+ + "(off keeps the SubagentWorkflow tool out of the tool spec; applies on next pi session)",
3365
+ currentValue: isWorkflowsEnabled() ? "on" : "off",
3366
+ values: ["on", "off"],
3367
+ },
2161
3368
  {
2162
3369
  id: "scopeModels",
2163
3370
  label: "Scope models",
@@ -2165,6 +3372,13 @@ ${systemPrompt}
2165
3372
  currentValue: isScopeModelsEnabled() ? "on" : "off",
2166
3373
  values: ["on", "off"],
2167
3374
  },
3375
+ {
3376
+ id: "strictAgentFiles",
3377
+ label: "Strict agent files",
3378
+ description: "Fail startup on an unreadable/unparseable agent .md instead of skipping it with a warning",
3379
+ currentValue: strictAgentFiles ? "on" : "off",
3380
+ values: ["on", "off"],
3381
+ },
2168
3382
  {
2169
3383
  id: "disableDefaultAgents",
2170
3384
  label: "Disable defaults",
@@ -2172,6 +3386,13 @@ ${systemPrompt}
2172
3386
  currentValue: isDefaultsDisabled() ? "on" : "off",
2173
3387
  values: ["on", "off"],
2174
3388
  },
3389
+ {
3390
+ id: "fallbackSubagent",
3391
+ label: "Fallback agent",
3392
+ description: `Agent used when subagent_type is unknown, disabled, or ambiguous; "${NO_FALLBACK}" rejects the call instead (strict dispatch)`,
3393
+ currentValue: fallbackValue,
3394
+ values: fallbackValues,
3395
+ },
2175
3396
  {
2176
3397
  id: "outputTranscript",
2177
3398
  label: "Output transcript",
@@ -2179,6 +3400,62 @@ ${systemPrompt}
2179
3400
  currentValue: getOutputTranscriptDefault() ? "on" : "off",
2180
3401
  values: ["on", "off"],
2181
3402
  },
3403
+ {
3404
+ id: "worktreeIsolation",
3405
+ label: "Worktree isolation",
3406
+ description: "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.",
3407
+ currentValue: isWorktreeIsolationEnabled() ? "on" : "off",
3408
+ values: ["on", "off"],
3409
+ },
3410
+ {
3411
+ id: "reportUsage",
3412
+ label: "Report usage to session",
3413
+ description: "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.",
3414
+ currentValue: isReportUsageEnabled() ? "on" : "off",
3415
+ values: ["on", "off"],
3416
+ },
3417
+ {
3418
+ id: "showCost",
3419
+ label: "Show cost",
3420
+ description: "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.",
3421
+ currentValue: isShowCostEnabled() ? "on" : "off",
3422
+ values: ["on", "off"],
3423
+ },
3424
+ {
3425
+ id: "showModel",
3426
+ label: "Show model",
3427
+ description: "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.",
3428
+ currentValue: isShowModelEnabled() ? "on" : "off",
3429
+ values: ["on", "off"],
3430
+ },
3431
+ {
3432
+ id: "viewerMarkdown",
3433
+ label: "Viewer markdown",
3434
+ description: "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+).",
3435
+ currentValue: getViewerMarkdown(),
3436
+ values: ["off", "assistant", "all"],
3437
+ },
3438
+ {
3439
+ id: "fleetView",
3440
+ label: "Fleet view",
3441
+ description: "Claude Code-style main+subagents list below the editor (↓/← to navigate, Enter to view)",
3442
+ currentValue: isFleetViewEnabled() ? "on" : "off",
3443
+ values: ["on", "off"],
3444
+ },
3445
+ {
3446
+ id: "agentMentions",
3447
+ label: "Agent mentions",
3448
+ 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.",
3449
+ currentValue: getAgentMentionMode(),
3450
+ values: ["model", "direct", "off"],
3451
+ },
3452
+ {
3453
+ id: "rememberAgents",
3454
+ label: "Remember agents",
3455
+ description: "Persist subagent sessions so `@handle` can resume one long after it finished (they also appear in /resume)",
3456
+ currentValue: getRememberAgents() ? "on" : "off",
3457
+ values: ["on", "off"],
3458
+ },
2182
3459
  {
2183
3460
  id: "widgetMode",
2184
3461
  label: "Widget",
@@ -2203,6 +3480,16 @@ ${systemPrompt}
2203
3480
  notifyApplied(ctx, `Max concurrency set to ${n}`);
2204
3481
  }
2205
3482
  }
3483
+ else if (id === "maxConcurrentForeground") {
3484
+ // 0 is meaningful here, unlike maxConcurrent above: it means unlimited.
3485
+ const n = parseInt(value, 10);
3486
+ if (n >= 0) {
3487
+ manager.setMaxConcurrentForeground(n);
3488
+ notifyApplied(ctx, n === 0
3489
+ ? "Max foreground concurrency set to unlimited"
3490
+ : `Max foreground concurrency set to ${n}`);
3491
+ }
3492
+ }
2206
3493
  else if (id === "defaultMaxTurns") {
2207
3494
  const n = parseInt(value, 10);
2208
3495
  if (n === 0) {
@@ -2221,10 +3508,26 @@ ${systemPrompt}
2221
3508
  notifyApplied(ctx, `Grace turns set to ${n}`);
2222
3509
  }
2223
3510
  }
3511
+ else if (id === "maxSubagentDepth") {
3512
+ const n = parseInt(value, 10);
3513
+ if (n >= 0) {
3514
+ setMaxSubagentDepth(n);
3515
+ notifyApplied(ctx, n <= 1
3516
+ ? "Nested delegation disabled"
3517
+ : `Nested depth set to ${n}. Applies to agents started from now on.`);
3518
+ }
3519
+ }
2224
3520
  else if (id === "joinMode") {
2225
3521
  setDefaultJoinMode(value);
2226
3522
  notifyApplied(ctx, `Default join mode set to ${value}`);
2227
3523
  }
3524
+ else if (id === "backgroundByDefault") {
3525
+ const enabled = value === "on";
3526
+ setBackgroundByDefault(enabled);
3527
+ notifyApplied(ctx, enabled
3528
+ ? "Agent calls run in the background unless they pass run_in_background: false"
3529
+ : "Agent calls block and return inline unless they pass run_in_background: true");
3530
+ }
2228
3531
  else if (id === "schedulingEnabled") {
2229
3532
  const enabled = value === "on";
2230
3533
  if (enabled === isSchedulingEnabled()) {
@@ -2237,25 +3540,96 @@ ${systemPrompt}
2237
3540
  notifyApplied(ctx, `Scheduling ${enabled ? "enabled" : "disabled"}. Tool spec change takes effect on next pi session.`);
2238
3541
  }
2239
3542
  }
3543
+ else if (id === "workflowsEnabled") {
3544
+ const enabled = value === "on";
3545
+ if (enabled === isWorkflowsEnabled()) {
3546
+ ctx.ui.notify(`Workflows already ${enabled ? "enabled" : "disabled"}.`, "info");
3547
+ }
3548
+ else {
3549
+ setWorkflowsEnabled(enabled);
3550
+ // Runs already in flight keep going: the switch governs whether the
3551
+ // tool is offered, and killing live agents on a settings toggle would
3552
+ // lose work the user never asked to discard.
3553
+ notifyApplied(ctx, `Workflows ${enabled ? "enabled" : "disabled"}. Tool spec change takes effect on next pi session.`);
3554
+ }
3555
+ }
2240
3556
  else if (id === "scopeModels") {
2241
3557
  const enabled = value === "on";
2242
3558
  setScopeModelsEnabled(enabled);
2243
3559
  notifyApplied(ctx, `Scope models ${enabled ? "enabled" : "disabled"}`);
2244
3560
  }
3561
+ else if (id === "strictAgentFiles") {
3562
+ const enabled = value === "on";
3563
+ strictAgentFiles = enabled;
3564
+ notifyApplied(ctx, `Strict agent files ${enabled ? "enabled" : "disabled"}. Takes effect on next pi session.`);
3565
+ }
2245
3566
  else if (id === "disableDefaultAgents") {
2246
3567
  const enabled = value === "on";
2247
3568
  setDisableDefaultAgents(enabled);
2248
3569
  notifyApplied(ctx, `Default agents ${enabled ? "disabled" : "enabled"}. Tool spec change takes effect on next pi session.`);
2249
3570
  }
3571
+ else if (id === "fallbackSubagent") {
3572
+ setFallbackSubagent(value);
3573
+ notifyApplied(ctx, value === NO_FALLBACK
3574
+ ? "Unknown or disabled agent types will now be rejected"
3575
+ : `Unknown agent types will fall back to ${value}`);
3576
+ }
2250
3577
  else if (id === "outputTranscript") {
2251
3578
  const enabled = value === "on";
2252
- setOutputTranscript(enabled);
3579
+ setOutputTranscriptDefault(enabled);
2253
3580
  notifyApplied(ctx, `Output transcript ${enabled ? "enabled" : "disabled"} by default`);
2254
3581
  }
3582
+ else if (id === "worktreeIsolation") {
3583
+ const enabled = value === "on";
3584
+ setWorktreeIsolationEnabled(enabled);
3585
+ // The refusal is live, but the tool schema is built at registration, so
3586
+ // the isolation parameter only appears/disappears next session.
3587
+ notifyApplied(ctx, `Worktree isolation ${enabled ? "enabled" : "disabled"}. Tool parameter updates on next pi session.`);
3588
+ }
2255
3589
  else if (id === "toolDescriptionMode") {
2256
3590
  setToolDescriptionMode(value);
2257
3591
  notifyApplied(ctx, `Tool description set to ${value}. Takes effect on next pi session.`);
2258
3592
  }
3593
+ else if (id === "reportUsage") {
3594
+ const enabled = value === "on";
3595
+ setReportUsage(enabled);
3596
+ notifyApplied(ctx, enabled
3597
+ ? "Subagent usage now counted in this session's totals"
3598
+ : "Subagent usage no longer counted in this session's totals");
3599
+ }
3600
+ else if (id === "showCost") {
3601
+ const enabled = value === "on";
3602
+ setShowCost(enabled);
3603
+ notifyApplied(ctx, `Cost display ${enabled ? "enabled" : "disabled"}`);
3604
+ }
3605
+ else if (id === "showModel") {
3606
+ const enabled = value === "on";
3607
+ setShowModel(enabled);
3608
+ notifyApplied(ctx, `Model display ${enabled ? "enabled" : "disabled"}`);
3609
+ }
3610
+ else if (id === "viewerMarkdown") {
3611
+ setViewerMarkdown(value);
3612
+ notifyApplied(ctx, `Viewer markdown set to ${value}`);
3613
+ }
3614
+ else if (id === "fleetView") {
3615
+ const enabled = value === "on";
3616
+ setFleetViewEnabled(enabled);
3617
+ notifyApplied(ctx, `Fleet view ${enabled ? "enabled" : "disabled"}`);
3618
+ }
3619
+ else if (id === "agentMentions") {
3620
+ const mode = value;
3621
+ setAgentMentionMode(mode);
3622
+ notifyApplied(ctx, mode === "off"
3623
+ ? "Agent mentions disabled"
3624
+ : mode === "model"
3625
+ ? "Agent mentions on — a conversation clone starts a mentioned agent off-screen"
3626
+ : "Agent mentions on — a mentioned agent starts here, with no model call");
3627
+ }
3628
+ else if (id === "rememberAgents") {
3629
+ const enabled = value === "on";
3630
+ setRememberAgents(enabled);
3631
+ notifyApplied(ctx, `Remember agents ${enabled ? "enabled" : "disabled"}`);
3632
+ }
2259
3633
  else if (id === "widgetMode") {
2260
3634
  setWidgetMode(value);
2261
3635
  notifyApplied(ctx, `Widget set to ${value}`);
@@ -2298,14 +3672,22 @@ ${systemPrompt}
2298
3672
  if (result && NUMERIC_IDS.has(result)) {
2299
3673
  const current = result === "maxConcurrent"
2300
3674
  ? String(manager.getMaxConcurrent())
2301
- : result === "defaultMaxTurns"
2302
- ? String(getDefaultMaxTurns() ?? 0)
2303
- : String(getGraceTurns());
3675
+ : result === "maxConcurrentForeground"
3676
+ ? String(manager.getMaxConcurrentForeground())
3677
+ : result === "defaultMaxTurns"
3678
+ ? String(getDefaultMaxTurns() ?? 0)
3679
+ : result === "maxSubagentDepth"
3680
+ ? String(getMaxSubagentDepth())
3681
+ : String(getGraceTurns());
2304
3682
  const label = result === "maxConcurrent"
2305
3683
  ? "Max concurrency (1+)"
2306
- : result === "defaultMaxTurns"
2307
- ? "Default max turns (0 = unlimited)"
2308
- : "Grace turns (1+)";
3684
+ : result === "maxConcurrentForeground"
3685
+ ? "Max foreground concurrency (0 = unlimited)"
3686
+ : result === "defaultMaxTurns"
3687
+ ? "Default max turns (0 = unlimited)"
3688
+ : result === "maxSubagentDepth"
3689
+ ? "Nested depth (0/1 = nesting off)"
3690
+ : "Grace turns (1+)";
2309
3691
  // Loop until user enters a valid integer or cancels (Esc / null).
2310
3692
  // Silently trims whitespace; rejects non-numeric input by re-prompting.
2311
3693
  let input = await ctx.ui.input(label, current);
@@ -2326,6 +3708,23 @@ ${systemPrompt}
2326
3708
  // the right toast. Successful saves show info; persistence failures downgrade
2327
3709
  // to warning so users aren't silently reverted on restart. Event fires regardless
2328
3710
  // of outcome so listeners see the in-memory change.
3711
+ /**
3712
+ * Persist + broadcast the settings, silent on success — for a change whose
3713
+ * feedback is the UI it just changed: the viewer's `m` key, where a
3714
+ * notification per press would talk over the overlay it is describing.
3715
+ *
3716
+ * A *failed* write still speaks. Every other settings path warns when the
3717
+ * value is session-only, and swallowing it here would leave a preference
3718
+ * looking persisted when the next session will not have it.
3719
+ */
3720
+ function persistSettings(ctx, changeMsg) {
3721
+ const { message, level } = saveAndEmitChanged(snapshotSettings(), changeMsg, (event, payload) => pi.events.emit(event, payload));
3722
+ // `ctx` is absent only on the fleet path between sessions, where
3723
+ // `currentCtx` has been cleared and there is no UI to carry the warning to.
3724
+ // The write still happens.
3725
+ if (level === "warning")
3726
+ ctx?.ui.notify(message, level);
3727
+ }
2329
3728
  function notifyApplied(ctx, successMsg) {
2330
3729
  const { message, level } = saveAndEmitChanged(snapshotSettings(), successMsg, (event, payload) => pi.events.emit(event, payload));
2331
3730
  ctx.ui.notify(message, level);
@@ -2334,5 +3733,19 @@ ${systemPrompt}
2334
3733
  description: "Manage agents",
2335
3734
  handler: async (_args, ctx) => { await showAgentsMenu(ctx); },
2336
3735
  });
3736
+ /**
3737
+ * What `/agents → Workflows` and the fleet list's `workflow` rows need from
3738
+ * here. One object, built once: both entry points open the same inspector,
3739
+ * and handing them different views of the session would let the two drift.
3740
+ */
3741
+ const workflowMenuDeps = {
3742
+ tasks: workflowTasks,
3743
+ getRecord: id => manager.getRecord(id),
3744
+ viewAgentConversation,
3745
+ // Read lazily: `currentCtx` is rebound on every session_start, and the
3746
+ // fleet list may act between sessions, when there is none.
3747
+ getCtx: () => currentCtx,
3748
+ };
3749
+ fleet.setWorkflowSource(fleetWorkflows, id => openWorkflowFromFleet(id, workflowMenuDeps));
2337
3750
  }
2338
3751
  //# sourceMappingURL=index.js.map