@herbertgao/pi-subagents 0.17.1 → 0.18.1

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 (50) hide show
  1. package/CHANGELOG.md +12 -0
  2. package/README.md +427 -120
  3. package/docs/rpc.md +184 -0
  4. package/docs/workflows.md +466 -0
  5. package/examples/agent-tool-description.md +6 -6
  6. package/examples/workflows/compose.js +52 -0
  7. package/examples/workflows/fan-out-audit.js +56 -0
  8. package/examples/workflows/gated-fix.js +60 -0
  9. package/examples/workflows/lib/count-child.js +30 -0
  10. package/examples/workflows/review-panel.js +68 -0
  11. package/examples/workflows/structured-findings.js +81 -0
  12. package/package.json +12 -10
  13. package/src/agent-file-toggle.ts +52 -12
  14. package/src/agent-manager.ts +837 -146
  15. package/src/agent-runner.ts +213 -39
  16. package/src/cross-extension-rpc.ts +73 -14
  17. package/src/custom-agents.ts +101 -47
  18. package/src/index.ts +2249 -914
  19. package/src/invocation-config.ts +13 -0
  20. package/src/mention-clone.ts +215 -0
  21. package/src/mention.ts +147 -0
  22. package/src/model-resolver.ts +9 -1
  23. package/src/nested-tools.ts +40 -26
  24. package/src/output-file.ts +18 -8
  25. package/src/prompts.ts +46 -9
  26. package/src/schedule.ts +21 -16
  27. package/src/settings.ts +137 -7
  28. package/src/structured-output.ts +136 -0
  29. package/src/types.ts +126 -8
  30. package/src/ui/agent-mention.ts +274 -0
  31. package/src/ui/agent-widget.ts +20 -5
  32. package/src/ui/conversation-viewer.ts +10 -4
  33. package/src/ui/fleet-list.ts +167 -22
  34. package/src/ui/workflow-card.ts +555 -0
  35. package/src/ui/workflow-dialog.ts +1304 -0
  36. package/src/ui/workflow-menu.ts +226 -0
  37. package/src/workflow/collisions.ts +122 -0
  38. package/src/workflow/entry.ts +47 -0
  39. package/src/workflow/host.ts +463 -0
  40. package/src/workflow/journal.ts +164 -0
  41. package/src/workflow/json-schema.ts +142 -0
  42. package/src/workflow/meta.ts +401 -0
  43. package/src/workflow/progress.ts +622 -0
  44. package/src/workflow/runtime.ts +1399 -0
  45. package/src/workflow/saved.ts +230 -0
  46. package/src/workflow/task.ts +333 -0
  47. package/src/workflow/tool-description.ts +200 -0
  48. package/src/workflow/worker-source.ts +781 -0
  49. package/src/worktree.ts +97 -95
  50. package/src/xml.ts +13 -0
package/src/index.ts CHANGED
@@ -10,8 +10,14 @@
10
10
  * /agents — Interactive agent management menu
11
11
  */
12
12
 
13
- import { existsSync, mkdirSync, readFileSync, unlinkSync } from "node:fs"
14
- import { join } from "node:path"
13
+ import {
14
+ existsSync,
15
+ mkdirSync,
16
+ readFileSync,
17
+ unlinkSync,
18
+ writeFileSync,
19
+ } from "node:fs"
20
+ import { isAbsolute, join } from "node:path"
15
21
  import {
16
22
  defineTool,
17
23
  type ExtensionAPI,
@@ -36,22 +42,24 @@ import {
36
42
  buildNewAgentFile,
37
43
  disableInContent,
38
44
  enableInContent,
39
- findAgentFile,
40
45
  isEmptyStub,
46
+ locateAgentFile,
41
47
  personalAgentsDir,
42
48
  projectAgentsDir,
43
49
  serializeAgentFile,
44
50
  } from "./agent-file-toggle.js"
45
- import { AgentManager } from "./agent-manager.js"
51
+ import { AgentManager, isTopLevelAgent } from "./agent-manager.js"
46
52
  import {
47
53
  getAgentConversation,
48
54
  getDefaultMaxTurns,
49
55
  getGraceTurns,
56
+ getRememberAgents,
50
57
  normalizeMaxTurns,
51
58
  resolveEffectiveMaxTurns,
52
59
  SUBAGENT_TOOL_NAMES,
53
60
  setDefaultMaxTurns,
54
61
  setGraceTurns,
62
+ setRememberAgents,
55
63
  steerAgent,
56
64
  } from "./agent-runner.js"
57
65
  import {
@@ -59,6 +67,7 @@ import {
59
67
  getAgentConfig,
60
68
  getAllTypes,
61
69
  getAvailableTypes,
70
+ getConfig,
62
71
  getFallbackSubagent,
63
72
  isDefaultsDisabled,
64
73
  NO_FALLBACK,
@@ -77,6 +86,15 @@ import {
77
86
  resolveAgentInvocationConfig,
78
87
  resolveJoinMode,
79
88
  } from "./invocation-config.js"
89
+ import {
90
+ describeMention,
91
+ handleBase,
92
+ isReservedHandle,
93
+ parseMention,
94
+ resolveHandleToType,
95
+ stripAgentPrefix,
96
+ } from "./mention.js"
97
+ import { runMentionClone } from "./mention-clone.js"
80
98
  import {
81
99
  describeModel,
82
100
  type ModelRegistry,
@@ -92,6 +110,7 @@ import {
92
110
  createOutputFilePath,
93
111
  ensureOutputFile,
94
112
  getOutputTranscriptDefault,
113
+ sessionTaskDir,
95
114
  setOutputTranscriptDefault,
96
115
  streamToOutputFile,
97
116
  writeInitialEntry,
@@ -113,6 +132,7 @@ import {
113
132
  import {
114
133
  type AgentConfig,
115
134
  type AgentInvocation,
135
+ type AgentMentionMode,
116
136
  type AgentRecord,
117
137
  type JoinMode,
118
138
  type NotificationDetails,
@@ -120,6 +140,11 @@ import {
120
140
  type ViewerMarkdownMode,
121
141
  type WidgetMode,
122
142
  } from "./types.js"
143
+ import {
144
+ createMentionProvider,
145
+ mentionRoster,
146
+ type TypeInfo,
147
+ } from "./ui/agent-mention.js"
123
148
  import {
124
149
  type AgentActivity,
125
150
  type AgentDetails,
@@ -138,9 +163,22 @@ import {
138
163
  type Theme,
139
164
  type UICtx,
140
165
  } from "./ui/agent-widget.js"
141
- import { FleetList, type FleetUICtx } from "./ui/fleet-list.js"
166
+ import {
167
+ FleetList,
168
+ type FleetUICtx,
169
+ type FleetWorkflow,
170
+ } from "./ui/fleet-list.js"
142
171
  import { showSchedulesMenu } from "./ui/schedule-menu.js"
143
172
  import { selectItem } from "./ui/select-item.js"
173
+ import {
174
+ renderWorkflowCard,
175
+ renderWorkflowEntryCard,
176
+ } from "./ui/workflow-card.js"
177
+ import {
178
+ openWorkflowFromFleet,
179
+ showWorkflowsMenu,
180
+ type WorkflowMenuDeps,
181
+ } from "./ui/workflow-menu.js"
144
182
  import {
145
183
  getLifetimeCost,
146
184
  getLifetimeTotal,
@@ -149,10 +187,46 @@ import {
149
187
  PendingUsagePool,
150
188
  toReportedUsage,
151
189
  } from "./usage.js"
190
+ import {
191
+ decideWorkflowCollision,
192
+ FOREIGN_WORKFLOW_TOOL_NAMES,
193
+ } from "./workflow/collisions.js"
194
+ import {
195
+ WORKFLOW_ENTRY_TYPE,
196
+ type WorkflowEntryData,
197
+ workflowEntryData,
198
+ } from "./workflow/entry.js"
199
+ import { createWorkflowHost } from "./workflow/host.js"
200
+ import {
201
+ appendJournal,
202
+ readJournal,
203
+ type WorkflowJournalEntry,
204
+ } from "./workflow/journal.js"
205
+ import {
206
+ extractMeta,
207
+ type WorkflowMeta,
208
+ workflowCallName,
209
+ } from "./workflow/meta.js"
210
+ import { elapsedMs } from "./workflow/progress.js"
211
+ import { runWorkflow } from "./workflow/runtime.js"
212
+ import { resolveWorkflowScript } from "./workflow/saved.js"
213
+ import {
214
+ completeWorkflowTask,
215
+ createWorkflowTask,
216
+ failWorkflowTask,
217
+ formatWorkflowNotification,
218
+ resolveResumeTarget,
219
+ updateWorkflowProgressBatch,
220
+ type WorkflowTask,
221
+ workflowResultText,
222
+ workflowRunId,
223
+ } from "./workflow/task.js"
224
+ import { fullWorkflowToolDescription } from "./workflow/tool-description.js"
152
225
  import {
153
226
  isWorktreeIsolationEnabled,
154
227
  setWorktreeIsolationEnabled,
155
228
  } from "./worktree.js"
229
+ import { escapeXml } from "./xml.js"
156
230
 
157
231
  // ---- Shared helpers ----
158
232
 
@@ -231,6 +305,8 @@ function createActivityTracker(maxTurns?: number, onStreamUpdate?: () => void) {
231
305
  onSessionCreated: (session: any) => {
232
306
  state.session = session
233
307
  },
308
+ // Spend is accumulated on the AgentRecord (agent-manager), which is what
309
+ // every surface reads; this callback exists here only to repaint on it.
234
310
  onAssistantUsage: (_usage: LifetimeUsage) => {
235
311
  onStreamUpdate?.()
236
312
  },
@@ -272,11 +348,6 @@ function getStatusLabel(status: string, error?: string): string {
272
348
  }
273
349
  }
274
350
 
275
- /** Escape XML special characters to prevent injection in structured notifications. */
276
- function escapeXml(s: string): string {
277
- return s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;")
278
- }
279
-
280
351
  /** Format a structured task notification matching Claude Code's <task-notification> XML. */
281
352
  function formatTaskNotification(
282
353
  record: AgentRecord,
@@ -296,6 +367,8 @@ function formatTaskNotification(
296
367
  const compactXml = record.compactionCount
297
368
  ? `<compactions>${record.compactionCount}</compactions>`
298
369
  : ""
370
+ // Only under `showCost`: this is LLM context, and a figure the orchestrator
371
+ // did not ask for is a figure it may start reporting unprompted.
299
372
  const cost = showCost ? getLifetimeCost(record.lifetimeUsage) : 0
300
373
  const costXml =
301
374
  cost > 0
@@ -351,6 +424,9 @@ function buildDetails(
351
424
  ...base,
352
425
  toolUses: record.toolUses,
353
426
  tokens: formatLifetimeTokens(record),
427
+ // Raw, and unconditional: `tokens` is preformatted because it is one stat,
428
+ // but a cost is joined by "·" in one surface, "," in another and "|" in a
429
+ // third — so it travels as a number and each renderer punctuates its own.
354
430
  cost: getLifetimeCost(record.lifetimeUsage),
355
431
  turnCount: activity?.turnCount,
356
432
  maxTurns: activity?.maxTurns,
@@ -378,6 +454,9 @@ function buildNotificationDetails(
378
454
  turnCount: activity?.turnCount ?? 0,
379
455
  maxTurns: activity?.maxTurns,
380
456
  totalTokens,
457
+ // Carried unconditionally; the renderer gates on the setting. Details are
458
+ // data, and a notification rendered before a mid-session toggle should not
459
+ // be stuck with the old answer.
381
460
  totalCost: getLifetimeCost(record.lifetimeUsage),
382
461
  durationMs: record.completedAt ? record.completedAt - record.startedAt : 0,
383
462
  outputFile: record.outputFile,
@@ -427,6 +506,21 @@ export function formatToolsSuffix(cfg: AgentConfig | undefined): string {
427
506
  return isFullSet ? "*" : tools.join(", ")
428
507
  }
429
508
 
509
+ /** CLI flag that runs a workflow script at session start. */
510
+ export const WORKFLOW_FILE_FLAG = "subagents-workflow-file"
511
+
512
+ /**
513
+ * Re-exported from where they now live, because this is where they were
514
+ * defined and a consumer (or a test) that matched a session entry on
515
+ * {@link WORKFLOW_ENTRY_TYPE} imports it from here.
516
+ */
517
+ export {
518
+ FOREIGN_WORKFLOW_TOOL_NAMES,
519
+ WORKFLOW_ENTRY_TYPE,
520
+ type WorkflowEntryData,
521
+ workflowEntryData,
522
+ }
523
+
430
524
  export default function (pi: ExtensionAPI) {
431
525
  // Child AgentSessions load normal extensions. Re-entering this extension there
432
526
  // would create another manager and leak handlers. Nested orchestration is
@@ -493,12 +587,16 @@ export default function (pi: ExtensionAPI) {
493
587
 
494
588
  const all = [d, ...(d.others ?? [])]
495
589
  const rendered = all.map(renderOne)
590
+ // A group of agents lands as one notification, and the number a user wants
591
+ // from it is what the batch cost — not four figures to add up by hand.
592
+ // Derived from the per-agent details rather than carried alongside them:
593
+ // one source, so the total can never disagree with the rows above it.
496
594
  if (showCost && all.length > 1) {
497
595
  const total = formatCost(
498
- all.reduce((sum, item) => sum + (item.totalCost ?? 0), 0),
596
+ all.reduce((sum, a) => sum + (a.totalCost ?? 0), 0),
499
597
  )
500
598
  if (total) {
501
- const tokens = all.reduce((sum, item) => sum + item.totalTokens, 0)
599
+ const tokens = all.reduce((sum, a) => sum + a.totalTokens, 0)
502
600
  rendered.unshift(
503
601
  theme.fg(
504
602
  "dim",
@@ -511,8 +609,29 @@ export default function (pi: ExtensionAPI) {
511
609
  },
512
610
  )
513
611
 
514
- // This setting controls the initial load, which runs before the normal settings
515
- // application below. Later per-call reloads deliberately remain tolerant.
612
+ // ---- Workflow run rendered as a session entry ----
613
+ // A workflow launched from the CLI flag has no tool call to hang its result
614
+ // card on, so it renders here instead — through the SAME layout the tool
615
+ // result uses, not a second one. Custom entries with no registered renderer
616
+ // are silently dropped by the host, which is why this is registered at
617
+ // activation rather than lazily.
618
+ pi.registerEntryRenderer<WorkflowEntryData>(
619
+ WORKFLOW_ENTRY_TYPE,
620
+ (entry, _options, theme) => renderWorkflowEntryCard(entry.data, theme),
621
+ )
622
+
623
+ // Registered at activation; READ from session_start. The host applies CLI
624
+ // values after every extension factory has run, so `getFlag` here would only
625
+ // ever hand back the registered default (see the read site below).
626
+ pi.registerFlag(WORKFLOW_FILE_FLAG, {
627
+ type: "string",
628
+ description:
629
+ `Run a workflow script at startup: --${WORKFLOW_FILE_FLAG}=<path>. ` +
630
+ "Use the `=` form — the space form consumes the next argument, which would swallow a following prompt.",
631
+ })
632
+
633
+ // Read directly rather than waiting for applyAndEmitLoaded below: this decides
634
+ // the initial load, which happens hundreds of lines before settings are applied.
516
635
  let strictAgentFiles = loadSettings(process.cwd()).strictAgentFiles === true
517
636
 
518
637
  /** Reload agents from project/global custom agent dirs and merge with defaults (called on init and each Agent invocation). */
@@ -521,39 +640,50 @@ export default function (pi: ExtensionAPI) {
521
640
  registerAgents(userAgents)
522
641
  }
523
642
 
524
- // Initial load — the only strict one.
643
+ // Initial load — the only strict one. A bad edit mid-session must not kill the
644
+ // session on the next unrelated spawn, so every later reload keeps warning.
525
645
  reloadCustomAgents(strictAgentFiles)
526
646
 
527
647
  // ---- Agent activity tracking + widget ----
528
648
  const agentActivity = new Map<string, AgentActivity>()
529
649
 
530
- // ---- Usage reporting ----
650
+ // ---- Usage reporting (both off by default; see SubagentsSettings) ----
651
+ /** Attach subagent spend to tool results, so the parent session counts it. */
531
652
  let reportUsage = false
532
653
  function isReportUsageEnabled(): boolean {
533
654
  return reportUsage
534
655
  }
535
- const pendingUsage = new PendingUsagePool()
536
- function setReportUsage(enabled: boolean): void {
537
- reportUsage = enabled
538
- if (!enabled) pendingUsage.drain()
656
+ function setReportUsage(b: boolean): void {
657
+ reportUsage = b
658
+ // Whatever accumulated while it was on is stale the moment it goes off:
659
+ // draining it later would bill the parent for a window the user opted out
660
+ // of, in one lump, on some unrelated later tool call.
661
+ if (!b) pendingUsage.drain()
539
662
  }
663
+ /** Show `~$X` next to token counts in the subagent surfaces. */
540
664
  let showCost = false
541
665
  function isShowCostEnabled(): boolean {
542
666
  return showCost
543
667
  }
544
- function setShowCost(enabled: boolean): void {
545
- showCost = enabled
668
+ function setShowCost(b: boolean): void {
669
+ showCost = b
546
670
  widget.update()
547
671
  fleet.update()
548
672
  }
673
+ /** Name the model and thinking level on the widget's running rows. */
549
674
  let showModel = false
550
675
  function isShowModelEnabled(): boolean {
551
676
  return showModel
552
677
  }
553
- function setShowModel(enabled: boolean): void {
554
- showModel = enabled
678
+ function setShowModel(b: boolean): void {
679
+ showModel = b
555
680
  widget.update()
556
681
  }
682
+ /**
683
+ * How much of the conversation viewer renders as Markdown. Read through a
684
+ * getter by the viewer rather than captured like `showCost`, because the
685
+ * viewer's `m` key writes back here while the overlay is on screen.
686
+ */
557
687
  let viewerMarkdown: ViewerMarkdownMode = "assistant"
558
688
  function getViewerMarkdown(): ViewerMarkdownMode {
559
689
  return viewerMarkdown
@@ -561,6 +691,20 @@ export default function (pi: ExtensionAPI) {
561
691
  function setViewerMarkdown(mode: ViewerMarkdownMode): void {
562
692
  viewerMarkdown = mode
563
693
  }
694
+ /**
695
+ * The viewer's `m` key, from either entry point: set the mode and persist it,
696
+ * so the key and `/agents → Settings` stay one setting rather than one per
697
+ * entry point. `ctx` carries only the warning a failed write notifies with,
698
+ * and the fleet list may be acting without one.
699
+ */
700
+ function chooseViewerMarkdown(
701
+ mode: ViewerMarkdownMode,
702
+ ctx?: ExtensionCommandContext,
703
+ ): void {
704
+ setViewerMarkdown(mode)
705
+ persistSettings(ctx, `Viewer markdown set to ${mode}`)
706
+ }
707
+ const pendingUsage = new PendingUsagePool()
564
708
 
565
709
  // ---- Cancellable pending notifications ----
566
710
  // Holds notifications briefly so get_subagent_result can cancel them
@@ -688,6 +832,17 @@ export default function (pi: ExtensionAPI) {
688
832
  const total = getLifetimeTotal(u)
689
833
  const tokens =
690
834
  total > 0 ? { input: u.input, output: u.output, total } : undefined
835
+ // The whole run's spend as a pi `Usage` — pi's convention for handing spend
836
+ // to a consumer, so `usage.cost.total` and `usage.cacheRead` are where a
837
+ // listener already expects them and anything pi adds to `Usage` arrives
838
+ // without a change here. Omitted when nothing was spent, so "spent nothing"
839
+ // and "never ran" stay distinguishable. Ungated by `showCost`: that setting
840
+ // governs what a human is shown, not what the event carries.
841
+ //
842
+ // `tokens` above is the other convention, kept as it shipped: a flat view
843
+ // model like pi's own `SessionStats`, carrying the DISPLAY total, which
844
+ // excludes cacheRead (#38). The two answer different questions and neither
845
+ // derives from the other.
691
846
  const usage = toReportedUsage(u)
692
847
  return {
693
848
  id: record.id,
@@ -706,9 +861,11 @@ export default function (pi: ExtensionAPI) {
706
861
  // Background completion: route through group join or send individual nudge
707
862
  const manager = new AgentManager(
708
863
  (record) => {
709
- // Nested children report only through their owning parent's scoped tools.
710
- // Keep them out of top-level lifecycle, transcript, notification, and UI channels.
711
- if (record.parentAgentId) return
864
+ // Owned children — nested, or a workflow's — report only through their
865
+ // owner: the parent's scoped tools, or the workflow's card, notification
866
+ // and dialog. Keep them out of top-level lifecycle, transcript,
867
+ // notification, and UI channels.
868
+ if (!isTopLevelAgent(record)) return
712
869
 
713
870
  // Emit lifecycle event based on terminal status
714
871
  const isError =
@@ -760,7 +917,7 @@ export default function (pi: ExtensionAPI) {
760
917
  },
761
918
  undefined,
762
919
  (record) => {
763
- if (record.parentAgentId) return
920
+ if (!isTopLevelAgent(record)) return
764
921
  // Agent-tool spawns refresh these surfaces in their tool handler, but RPC
765
922
  // and scheduler spawns enter through the manager directly.
766
923
  if (currentCtx?.hasUI) {
@@ -777,7 +934,7 @@ export default function (pi: ExtensionAPI) {
777
934
  })
778
935
  },
779
936
  (record, info) => {
780
- if (record.parentAgentId) return
937
+ if (!isTopLevelAgent(record)) return
781
938
  // Emit compacted event when agent's session compacts (preserves count on record).
782
939
  pi.events.emit("subagents:compacted", {
783
940
  id: record.id,
@@ -789,12 +946,17 @@ export default function (pi: ExtensionAPI) {
789
946
  })
790
947
  },
791
948
  (_record, usage) => {
949
+ // Every assistant message from every agent — nested included, exactly once.
950
+ // Parked here until a tool result can carry it back to the parent session;
951
+ // see `PendingUsagePool`. Skipped entirely when the feature is off, so no
952
+ // pool grows in a session that will never drain it.
792
953
  if (reportUsage) pendingUsage.add(usage)
793
954
  },
794
955
  )
795
956
 
796
957
  // Expose manager via Symbol.for() global registry for cross-package access.
797
958
  // Standard Node.js pattern for cross-package singletons (used by OpenTelemetry, etc.).
959
+ // Documented for callers in docs/rpc.md ("The manager registry").
798
960
  //
799
961
  // Claim the slot only if it's free: subagent sessions re-activate this
800
962
  // extension in the same process (session.bindExtensions in agent-runner.ts),
@@ -805,23 +967,17 @@ export default function (pi: ExtensionAPI) {
805
967
  const MANAGER_KEY = Symbol.for("pi-subagents:manager")
806
968
  // Process-external callers may supply arbitrary options. Nested ownership and
807
969
  // config-root metadata are internal capabilities issued only by scoped tools.
808
- const spawnTopLevel = (
970
+ /**
971
+ * Resolve the agent type and spawn. Trusts its options — every caller must
972
+ * either be in-process or have gone through `spawnTopLevel` first.
973
+ */
974
+ const spawnResolved = (
809
975
  piRef: any,
810
976
  ctxRef: any,
811
977
  type: string,
812
978
  prompt: string,
813
979
  options: any,
814
980
  ) => {
815
- const safeOptions = { ...(options ?? {}) }
816
- delete safeOptions.parentAgentId
817
- delete safeOptions.depth
818
- delete safeOptions.maxSubagentDepth
819
- delete safeOptions.configCwd
820
- // Also internal: it names a transcript directory, so a forged value would
821
- // be a path-traversal primitive.
822
- delete safeOptions.rootSessionId
823
- // Every call through this registry is detached, never a blocking tool call.
824
- delete safeOptions.blocking
825
981
  // Cross-extension callers get the same dispatch contract as the LLM (#183).
826
982
  // The RPC layer already throws for an unresolvable model rather than falling
827
983
  // back silently; a bad agent type should not be quieter. Throws become error
@@ -830,23 +986,89 @@ export default function (pi: ExtensionAPI) {
830
986
  reloadCustomAgents()
831
987
  const dispatch = resolveSpawnType(type)
832
988
  if (!dispatch.ok) throw new Error(dispatch.message)
989
+ // Every programmatic spawn lands here — cross-extension RPC, both `@handle`
990
+ // mention paths, and the `Symbol.for("pi-subagents:manager")` registry — and
991
+ // none came through the Agent tool, which is where the UI activity tracker is
992
+ // otherwise created. Without one the widget and FleetView have no tool name
993
+ // and no turn count, so the row reads `thinking…` for the agent's whole life
994
+ // while the header's tool-use count climbs beside it (#181). Double-tracking
995
+ // is not possible: the Agent tool calls `manager.spawn` directly. The tracker
996
+ // callbacks are the funnel's own — a caller's are not honoured, since a
997
+ // half-wired tracker renders worse than none.
998
+ //
999
+ // The turn limit is resolved rather than read off `options`, which a mention
1000
+ // spawn deliberately omits so the agent's own config can decide: a tracker
1001
+ // built with `undefined` renders `↻3` where the Agent tool renders `↻3≤20`.
1002
+ // Like the tool's own, it is a prediction — editing the agent file mid-run
1003
+ // leaves the displayed ceiling stale.
833
1004
  const { state, callbacks } = createActivityTracker(
834
- resolveEffectiveMaxTurns(dispatch.type, safeOptions.maxTurns),
1005
+ resolveEffectiveMaxTurns(dispatch.type, options?.maxTurns),
835
1006
  )
1007
+ // Repaints are left to the manager's `onStart` callback, which already starts
1008
+ // the widget/fleet timers for agents that enter this way.
836
1009
  const id = manager.spawn(piRef, ctxRef, dispatch.type, prompt, {
837
- ...safeOptions,
1010
+ ...options,
838
1011
  ...callbacks,
839
1012
  })
840
1013
  agentActivity.set(id, state)
841
1014
  return id
842
1015
  }
1016
+
1017
+ const spawnTopLevel = (
1018
+ piRef: any,
1019
+ ctxRef: any,
1020
+ type: string,
1021
+ prompt: string,
1022
+ options: any,
1023
+ ) => {
1024
+ const safeOptions = { ...(options ?? {}) }
1025
+ delete safeOptions.parentAgentId
1026
+ // Internal too: a forged value would hide an RPC-spawned agent inside
1027
+ // someone else's workflow, and take it out of the concurrency pool with it.
1028
+ delete safeOptions.workflowId
1029
+ delete safeOptions.depth
1030
+ delete safeOptions.maxSubagentDepth
1031
+ delete safeOptions.configCwd
1032
+ // Also internal: it names a transcript directory, so a forged value would
1033
+ // be a path-traversal primitive.
1034
+ delete safeOptions.rootSessionId
1035
+ // Worse than rootSessionId: this one names a file to OPEN and replay as a
1036
+ // conversation. Only the mention dispatcher may set it, and only from a
1037
+ // path this extension itself recorded — never from anything a caller sent.
1038
+ delete safeOptions.resumeSessionFile
1039
+ // Bypasses handle allocation, so a forged value would duplicate a live
1040
+ // agent's name and make `@handle` ambiguous. Same rule: dispatcher only.
1041
+ delete safeOptions.reclaim
1042
+ // Every spawn through here is DETACHED — the caller gets an id back and
1043
+ // awaits nothing. A forged `blocking` would charge it to the foreground
1044
+ // pool and could defer it behind a queue whose gate nobody is holding.
1045
+ delete safeOptions.blocking
1046
+ return spawnResolved(piRef, ctxRef, type, prompt, safeOptions)
1047
+ }
1048
+
1049
+ /**
1050
+ * Resolve a tool's `agent_id` as an id OR a handle, so the model addresses
1051
+ * agents by the same names the user types. Ids are tried first, keeping the
1052
+ * existing behaviour exact — a handle is only consulted when the string is
1053
+ * not an id at all. Only live records: a tombstone has nothing to steer and
1054
+ * no result to read. Callers still enforce the nested-ownership rejection.
1055
+ */
1056
+ const resolveAgentRef = (ref: string): AgentRecord | undefined => {
1057
+ const byId = manager.getRecord(ref)
1058
+ if (byId) return byId
1059
+ const resolved = manager.resolveMention(ref)
1060
+ return resolved?.kind === "live" ? resolved.record : undefined
1061
+ }
1062
+
843
1063
  const registryEntry = {
844
1064
  waitForAll: () => manager.waitForAll(),
845
1065
  hasRunning: () => manager.hasRunning(),
846
1066
  spawn: spawnTopLevel,
847
1067
  getRecord: (id: string) => {
848
1068
  const record = manager.getRecord(id)
849
- return record?.parentAgentId ? undefined : record
1069
+ return record !== undefined && isTopLevelAgent(record)
1070
+ ? record
1071
+ : undefined
850
1072
  },
851
1073
  }
852
1074
  const ownsManagerRegistry = (globalThis as any)[MANAGER_KEY] === undefined
@@ -864,6 +1086,8 @@ export default function (pi: ExtensionAPI) {
864
1086
  // (currentCtx would stay undefined → spawn always "No active session"). Gating
865
1087
  // here makes a filtered session behave like an absent one (#142).
866
1088
  let rpcHandle: RpcHandle | undefined
1089
+ /** Whether the `@handle` autocomplete wrapper has been stacked on pi's provider. */
1090
+ let mentionProviderRegistered = false
867
1091
 
868
1092
  // ---- Subagent scheduler ----
869
1093
  // Session-scoped: store is constructed inside session_start once sessionId
@@ -908,20 +1132,20 @@ export default function (pi: ExtensionAPI) {
908
1132
  getCtx: () => currentCtx,
909
1133
  manager: {
910
1134
  spawn: spawnTopLevel,
911
- abort: (id) => {
912
- const record = manager.getRecord(id)
913
- return !record?.parentAgentId && manager.abort(id)
914
- },
1135
+ awaitStartup: (id) => manager.awaitStartup(id),
1136
+ getRecord: (id) => manager.getRecord(id),
1137
+ // Unguarded on purpose: the stop handler now runs the top-level check
1138
+ // itself off `getRecord`, and reports the refusal instead of the
1139
+ // "Agent not found" a false from here used to be read as.
1140
+ abort: (id) => manager.abort(id),
915
1141
  consumeResult: (id) => {
916
- const record = manager.getRecord(id)
917
- if (
918
- !record ||
919
- record.parentAgentId ||
920
- record.status === "running" ||
921
- record.status === "queued"
922
- ) {
1142
+ const record = resolveAgentRef(id)
1143
+ // Same guard as get_subagent_result: a running agent has no result
1144
+ // to consume, and its notification is still the caller's only
1145
+ // signal that it finished.
1146
+ if (!record || record.parentAgentId) return false
1147
+ if (record.status === "running" || record.status === "queued")
923
1148
  return false
924
- }
925
1149
  record.resultConsumed = true
926
1150
  cancelNudge(record.id)
927
1151
  return true
@@ -934,6 +1158,309 @@ export default function (pi: ExtensionAPI) {
934
1158
  pi.events.emit("subagents:ready", {})
935
1159
  }
936
1160
  if (isSchedulingEnabled() && !scheduler.isActive()) startScheduler(ctx)
1161
+ // Stack `@handle` suggestions on pi's built-in autocomplete. Registered at
1162
+ // most once per activation: pi appends wrappers to a list it never prunes,
1163
+ // so a second call would layer a duplicate provider on the first. TUI only
1164
+ // — print mode has no such method, and RPC mode's is a no-op.
1165
+ if (ctx.mode === "tui" && !mentionProviderRegistered) {
1166
+ mentionProviderRegistered = true
1167
+ ctx.ui.addAutocompleteProvider((current) =>
1168
+ createMentionProvider(
1169
+ current,
1170
+ // Plain text, not renderAgentName: the same label FleetView and the
1171
+ // widget show, but the autocomplete description cannot carry ANSI.
1172
+ () =>
1173
+ mentionRoster(
1174
+ manager,
1175
+ mentionTypes(),
1176
+ (type) => getConfig(type).displayName,
1177
+ ),
1178
+ isAgentMentionsEnabled,
1179
+ ),
1180
+ )
1181
+ }
1182
+ // Last, and only here: CLI flag values are applied by the host AFTER every
1183
+ // extension factory has run, so this is the earliest point the real value
1184
+ // exists. Detached inside — a workflow must not hold up session startup.
1185
+ resolveWorkflowCollisions(ctx)
1186
+ runWorkflowFlag(ctx)
1187
+ })
1188
+
1189
+ /** Agent types `@` can start, in the shape the roster wants. */
1190
+ const mentionTypes = (): TypeInfo[] =>
1191
+ getAvailableTypes().map((name) => ({
1192
+ name,
1193
+ description: getAgentConfig(name)?.description ?? name,
1194
+ }))
1195
+
1196
+ /**
1197
+ * `@handle message` typed at the prompt addresses that agent instead of the
1198
+ * main model — Claude Code's prompt mention, same grammar (see mention.ts).
1199
+ *
1200
+ * The handle names the *agent*, not one process, so one syntax covers its
1201
+ * whole lifecycle: message it while it runs, resume it once it has finished,
1202
+ * start it if it never ran. Everything that isn't an agent mention falls
1203
+ * through untouched, which is what keeps `@src/foo.ts summarize this`, a bare
1204
+ * `@handle`, and ordinary prose working. A delivered mention costs no
1205
+ * main-model turn; the answer arrives through the ordinary completion
1206
+ * notification either way.
1207
+ */
1208
+ pi.on("input", async (event, ctx) => {
1209
+ // Never hijack text the extension layer itself submitted (pi.sendMessage,
1210
+ // scheduled prompts) — only something a person typed can be a mention.
1211
+ if (event.source === "extension" || !isAgentMentionsEnabled())
1212
+ return { action: "continue" }
1213
+ // Claiming the turn is TUI only, matching the `@` completion that teaches
1214
+ // the syntax. Pi defaults `session.prompt()` to source "interactive", so a
1215
+ // headless `pi -p "@explore …"` reaches here too — and claiming it would
1216
+ // answer with silence, which the background hold cannot fix: `handled`
1217
+ // returns from prompt() before any turn starts, so the loop that patch wraps
1218
+ // never runs (it holds subagents spawned by the Agent tool MID-turn, a
1219
+ // different path). The agent would detach, `ctx.ui.notify` is a no-op
1220
+ // outside the TUI, and print mode would exit having printed nothing.
1221
+ //
1222
+ // `model` mode has none of that problem: it queues a reminder and lets the
1223
+ // turn run, so the answer is the model's own, printed as usual. It is the
1224
+ // only branch allowed to act headlessly; everything else falls through to
1225
+ // the main model exactly as it did before mentions existed.
1226
+ const canDispatchDirectly = ctx.mode === "tui"
1227
+ if (!canDispatchDirectly && getAgentMentionMode() !== "model")
1228
+ return { action: "continue" }
1229
+
1230
+ const mention = parseMention(event.text)
1231
+ if (!mention) return { action: "continue" }
1232
+
1233
+ // `@main` addresses the main conversation, never a subagent — the one name
1234
+ // `assignHandle` refuses to allocate. An explicit escape hatch for text
1235
+ // that would otherwise read as a mention, so the prefix is dropped and the
1236
+ // rest goes to the model with its attachments intact.
1237
+ if (isReservedHandle(mention.handle)) {
1238
+ return {
1239
+ action: "transform",
1240
+ text: mention.message,
1241
+ ...(event.images && { images: event.images }),
1242
+ }
1243
+ }
1244
+
1245
+ // As typed first, so an agent actually called `agent-foo` wins over Claude
1246
+ // Code's `@agent-` + `foo` spelling rather than being shadowed by it.
1247
+ const alias = stripAgentPrefix(mention.handle)
1248
+ const resolved =
1249
+ manager.resolveMention(mention.handle) ??
1250
+ (alias ? manager.resolveMention(alias) : undefined)
1251
+
1252
+ // Steering and resuming are direct in every mode, so headless they are not
1253
+ // available at all. Falling through here rather than dropping to the start
1254
+ // path below matters: the handle names an agent that already exists, and
1255
+ // asking the model to start another one is not what was typed.
1256
+ if (resolved && !canDispatchDirectly) return { action: "continue" }
1257
+
1258
+ if (resolved?.kind === "live") {
1259
+ const record = resolved.record
1260
+ const target = `@${record.alias ?? record.handle ?? mention.handle}`
1261
+
1262
+ if (record.status === "running" || record.status === "queued") {
1263
+ // Steering interrupts after the current tool call, exactly like the
1264
+ // steer_subagent tool. Un-consume the result so the agent's reply to
1265
+ // this message is still relayed even if the LLM read its last answer.
1266
+ record.resultConsumed = false
1267
+ manager.steer(record.id, mention.message)
1268
+ pi.events.emit("subagents:steered", {
1269
+ id: record.id,
1270
+ message: mention.message,
1271
+ })
1272
+ ctx.ui.notify(`Sent to ${target}`, "info")
1273
+ return { action: "handled" }
1274
+ }
1275
+
1276
+ if (record.session) {
1277
+ // Both derived from the record's OWN type: a mention names an existing
1278
+ // agent, so its frontmatter is what governs — `output_transcript: false`
1279
+ // must keep holding, since record.outputFile is the sole gate every
1280
+ // downstream consumer keys off and a resume must not re-open it.
1281
+ const config = getAgentConfig(record.type)
1282
+ const resumedRecord = await startBackgroundResume(
1283
+ ctx,
1284
+ record,
1285
+ mention.message,
1286
+ {
1287
+ outputTranscript:
1288
+ config?.outputTranscript ?? getOutputTranscriptDefault(),
1289
+ maxTurns: normalizeMaxTurns(
1290
+ config?.maxTurns ?? getDefaultMaxTurns(),
1291
+ ),
1292
+ },
1293
+ )
1294
+ ctx.ui.notify(
1295
+ resumedRecord
1296
+ ? `Resuming ${target}`
1297
+ : `Could not resume ${target} — it is still running.`,
1298
+ resumedRecord ? "info" : "warning",
1299
+ )
1300
+ return { action: "handled" }
1301
+ }
1302
+ // A live record with no session never got far enough to continue, so it
1303
+ // falls through to the start-fresh path below, like Claude's
1304
+ // `no_transcript`.
1305
+ }
1306
+
1307
+ // Evicted, but its conversation is still on disk: reopen it. This is an
1308
+ // ordinary spawn carrying a session file, so the new record picks up the
1309
+ // widget, fleet row, transcript and completion notification unchanged —
1310
+ // and `reclaim` hands it back the names the tombstone was holding.
1311
+ if (resolved?.kind === "tombstone") {
1312
+ const entry = resolved.entry
1313
+ const target = `@${entry.alias ?? entry.handle}`
1314
+
1315
+ // Checked here rather than left to SessionManager.open: that runs inside
1316
+ // runAgent, whose rejection lands on the record as an agent error, not in
1317
+ // the catch below. A `/new` in another pi window or a manual delete makes
1318
+ // the conversation unrecoverable (Claude Code's `not_reachable`), so drop
1319
+ // the entry — a row that can only ever fail is worse than none — and say
1320
+ // so rather than quietly sending this message to an unrelated agent.
1321
+ if (!existsSync(entry.sessionFile)) {
1322
+ manager.dropTombstone(entry.handle)
1323
+ ctx.ui.notify(
1324
+ `Could not resume ${target} — its session is gone.`,
1325
+ "warning",
1326
+ )
1327
+ return { action: "handled" }
1328
+ }
1329
+
1330
+ // The Agent tool deliberately falls back to general-purpose for a type it
1331
+ // cannot resolve (#183), which covers a deleted file AND a merely
1332
+ // disabled one. A resume must not inherit that: reopening this
1333
+ // conversation under a different agent's prompt and tools is not
1334
+ // continuing it, and the new record would re-tombstone under the
1335
+ // substitute, so the handle would never find its way back.
1336
+ reloadCustomAgents()
1337
+ const dispatch = resolveSpawnType(entry.type)
1338
+ if (!dispatch.ok || dispatch.fellBackFrom !== undefined) {
1339
+ // The tombstone stays: re-enabling the agent makes the handle work
1340
+ // again, which a drop would foreclose.
1341
+ ctx.ui.notify(
1342
+ `Could not resume ${target} — the ${entry.type} agent is no longer available.`,
1343
+ "warning",
1344
+ )
1345
+ return { action: "handled" }
1346
+ }
1347
+
1348
+ try {
1349
+ // spawnResolved, not spawnTopLevel: the latter strips
1350
+ // `resumeSessionFile` and `reclaim` as untrusted. This path is the
1351
+ // exception — both come from a tombstone this extension wrote.
1352
+ const id = spawnResolved(pi, ctx, dispatch.type, mention.message, {
1353
+ description: entry.description,
1354
+ reclaim: { handle: entry.handle, alias: entry.alias },
1355
+ resumeSessionFile: entry.sessionFile,
1356
+ isBackground: true,
1357
+ })
1358
+ // The agent may still be starting — wait, so a startup failure lands in
1359
+ // the catch below instead of being announced as a resume.
1360
+ await manager.awaitStartup(id)
1361
+ // The tombstone deliberately stays. `resolveMention` prefers the live
1362
+ // record holding these same names, so it cannot shadow the resume — and
1363
+ // if this run dies before establishing its own session, the original
1364
+ // transcript is still the right thing for the next mention to reopen.
1365
+ // Once the resumed record is evicted it overwrites this entry in place,
1366
+ // keyed by the same handle, so nothing accumulates.
1367
+ ctx.ui.notify(`Resuming ${target}`, "info")
1368
+ } catch (err) {
1369
+ // The type is already settled above, so what is left is a spawn-time
1370
+ // failure: a strict worktree-isolation error, an unusable cwd.
1371
+ ctx.ui.notify(
1372
+ `Could not resume ${target}: ${err instanceof Error ? err.message : String(err)}`,
1373
+ "warning",
1374
+ )
1375
+ }
1376
+ return { action: "handled" }
1377
+ }
1378
+
1379
+ // No agent under that handle — but the name may still be an agent type, in
1380
+ // which case the mention starts one.
1381
+ const typeHandle = mention.handle
1382
+ const type =
1383
+ resolveHandleToType(typeHandle, getAvailableTypes()) ??
1384
+ (alias ? resolveHandleToType(alias, getAvailableTypes()) : undefined)
1385
+ if (!type) return { action: "continue" }
1386
+
1387
+ // Claude Code never starts the agent itself: `@agent-<type>` becomes an
1388
+ // attachment asking the main model to do it, and the model writes the
1389
+ // agent's prompt from the conversation rather than forwarding the typed
1390
+ // text. That buys a real `Agent` tool call — transcript, per-tool widget
1391
+ // detail, tool-use-id correlation, join grouping — and a prompt with the
1392
+ // context a cold spawn lacks.
1393
+ //
1394
+ // It also costs a visible turn, spent narrating a decision the user already
1395
+ // made by typing the handle. So the turn is taken by a clone of this
1396
+ // conversation instead (mention-clone.ts): same messages, same system
1397
+ // prompt, off-screen, holding only the `Agent` tool. Nothing reaches the
1398
+ // chat, and what it starts is an ordinary top-level agent.
1399
+ if (getAgentMentionMode() === "model") {
1400
+ const label = `@${handleBase(type)}`
1401
+ // "Prompting", not "Starting": in this mode nothing starts until the
1402
+ // off-screen clone has taken a whole model turn writing the agent's
1403
+ // prompt, and that wait is the one thing the chat cannot show. `direct`
1404
+ // says "Started" because by then it has. The distinction tells the user
1405
+ // which of the two they are waiting on.
1406
+ ctx.ui.notify(`Prompting ${label}…`, "info")
1407
+ // Not awaited: the clone runs a full model turn, and prompt() is blocked
1408
+ // until this hook returns. The user gets their prompt back immediately
1409
+ // and the agent appears in the widget when it starts.
1410
+ void runMentionClone({
1411
+ ctx,
1412
+ type,
1413
+ message: mention.message,
1414
+ agentTool: registeredAgentTool,
1415
+ }).then(async (result) => {
1416
+ if (result.spawned) return
1417
+ // A clone that could not run must not swallow the mention: start the
1418
+ // agent the direct way rather than leaving the user with a toast and
1419
+ // nothing running.
1420
+ try {
1421
+ const id = spawnTopLevel(pi, ctx, type, mention.message, {
1422
+ description: describeMention(mention.message),
1423
+ isBackground: true,
1424
+ })
1425
+ // Same reason as the direct path below: the agent may still be
1426
+ // starting, and a failure there must reach this catch.
1427
+ await manager.awaitStartup(id)
1428
+ ctx.ui.notify(
1429
+ `Started ${label} directly — ${result.error}`,
1430
+ "warning",
1431
+ )
1432
+ } catch (err) {
1433
+ ctx.ui.notify(
1434
+ `Could not start ${label}: ${err instanceof Error ? err.message : String(err)}`,
1435
+ "error",
1436
+ )
1437
+ }
1438
+ })
1439
+ return { action: "handled" }
1440
+ }
1441
+
1442
+ try {
1443
+ // Nothing else to pass: runAgent resolves model, thinking and max turns
1444
+ // from the agent's own config when the spawn omits them, and the
1445
+ // manager's onStart/onComplete callbacks own the widget, the fleet list
1446
+ // and the completion notification — the same contract the scheduler and
1447
+ // cross-extension RPC spawns run under.
1448
+ const id = spawnTopLevel(pi, ctx, type, mention.message, {
1449
+ description: describeMention(mention.message),
1450
+ isBackground: true,
1451
+ })
1452
+ // The agent may still be starting (a worktree copy is an awaited git
1453
+ // call) — report a failure that lands there as a failed start, not as a
1454
+ // "Started" toast for an agent that never ran.
1455
+ await manager.awaitStartup(id)
1456
+ ctx.ui.notify(`Started @${handleBase(type)}`, "info")
1457
+ } catch (err) {
1458
+ ctx.ui.notify(
1459
+ `Could not start @${handleBase(type)}: ${err instanceof Error ? err.message : String(err)}`,
1460
+ "error",
1461
+ )
1462
+ }
1463
+ return { action: "handled" }
937
1464
  })
938
1465
 
939
1466
  pi.on("session_before_switch", () => {
@@ -959,11 +1486,19 @@ export default function (pi: ExtensionAPI) {
959
1486
  delete (globalThis as any)[MANAGER_KEY]
960
1487
  }
961
1488
  scheduler.stop()
1489
+ // Before abortAll, and not folded into it: a workflow owns a worker thread
1490
+ // as well as its children, and only its own signal terminates that.
1491
+ for (const task of workflowTasks.values()) task.abortController.abort()
1492
+ workflowTasks.clear()
962
1493
  manager.abortAll()
963
1494
  for (const timer of pendingNudges.values()) clearTimeout(timer)
964
1495
  pendingNudges.clear()
965
1496
  fleet.dispose()
966
- await manager.dispose()
1497
+ // Awaited: it emits `session_shutdown` into every retained child session so
1498
+ // extensions bound there can release what they armed in `session_start` (#242).
1499
+ // pi awaits this handler, and the process exits right after — unawaited, those
1500
+ // handlers would never run. Internally bounded, so a hung one can't strand quit.
1501
+ await manager.dispose(pi)
967
1502
  })
968
1503
 
969
1504
  // Live widget: show running agents above editor.
@@ -988,7 +1523,21 @@ export default function (pi: ExtensionAPI) {
988
1523
  }
989
1524
 
990
1525
  // Claude Code-style FleetView: navigable list of main + subagents below the editor.
991
- const fleet = new FleetList(manager, agentActivity, isShowCostEnabled)
1526
+ // The last two arguments keep a conversation overlay opened here identical to
1527
+ // one opened from `/agents`: same setting on the way in, same persist out.
1528
+ const fleet = new FleetList(
1529
+ manager,
1530
+ agentActivity,
1531
+ isShowCostEnabled,
1532
+ getViewerMarkdown,
1533
+ (mode) =>
1534
+ chooseViewerMarkdown(
1535
+ mode,
1536
+ // SAFETY: FleetList keeps the live ExtensionContext; this cast only
1537
+ // narrows its structural UI surface to the optional warning context.
1538
+ currentCtx as unknown as ExtensionCommandContext | undefined,
1539
+ ),
1540
+ )
992
1541
  let fleetViewEnabled = true
993
1542
  function isFleetViewEnabled(): boolean {
994
1543
  return fleetViewEnabled
@@ -998,6 +1547,24 @@ export default function (pi: ExtensionAPI) {
998
1547
  fleet.setEnabled(b)
999
1548
  }
1000
1549
 
1550
+ // Claude Code-style `@handle message` prompt mentions. Read live by both the
1551
+ // `input` hook and the stacked autocomplete provider, so the toggle applies
1552
+ // immediately — the provider itself can never be unregistered (pi's wrapper
1553
+ // list is append-only), it just delegates everything when this is off.
1554
+ let agentMentionMode: AgentMentionMode = "model"
1555
+ function getAgentMentionMode(): AgentMentionMode {
1556
+ return agentMentionMode
1557
+ }
1558
+ function setAgentMentionMode(mode: AgentMentionMode): void {
1559
+ agentMentionMode = mode
1560
+ }
1561
+ // `model` and `direct` differ only in who starts a not-yet-running agent, so
1562
+ // everything that just asks "are mentions live at all" — the suggestion list,
1563
+ // the steer and resume branches — reads this instead of the mode.
1564
+ function isAgentMentionsEnabled(): boolean {
1565
+ return agentMentionMode !== "off"
1566
+ }
1567
+
1001
1568
  // Project/global default for writing the subagent .output transcript lives in
1002
1569
  // output-file.ts (both spawn paths read it). A custom agent's
1003
1570
  // `output_transcript` frontmatter overrides it per spawn; when the frontmatter
@@ -1012,12 +1579,15 @@ export default function (pi: ExtensionAPI) {
1012
1579
  defaultJoinMode = mode
1013
1580
  }
1014
1581
 
1582
+ // What an unqualified top-level spawn means. Defaults to background,
1583
+ // following Claude Code; `backgroundByDefault: false` restores the previous
1584
+ // foreground default. Nested spawns ignore this — see nested-tools.ts.
1015
1585
  let backgroundByDefault = true
1016
1586
  function getBackgroundByDefault(): boolean {
1017
1587
  return backgroundByDefault
1018
1588
  }
1019
- function setBackgroundByDefault(enabled: boolean): void {
1020
- backgroundByDefault = enabled
1589
+ function setBackgroundByDefault(b: boolean) {
1590
+ backgroundByDefault = b
1021
1591
  }
1022
1592
 
1023
1593
  // Master switch for the schedule subagent feature. Defaults to enabled.
@@ -1034,6 +1604,30 @@ export default function (pi: ExtensionAPI) {
1034
1604
  schedulingEnabled = b
1035
1605
  }
1036
1606
 
1607
+ // Master switch for scripted workflows. Defaults to ON. Off means the
1608
+ // `SubagentWorkflow` tool is never registered: the model is not told the
1609
+ // feature exists (zero context cost) and has nothing to call. The
1610
+ // `/agents → Workflows` view and `--subagents-workflow-file` are refused too, so
1611
+ // there is no second door into the same machinery.
1612
+ //
1613
+ // `workflowsPinned` records that the answer came from the user — a boolean in
1614
+ // subagents.json, or the settings toggle — rather than from this default. It
1615
+ // is what `resolveWorkflowCollisions` checks before yielding to another
1616
+ // extension's workflow tool: a default may be overridden by what else is
1617
+ // loaded, an explicit choice may not.
1618
+ let workflowsEnabled = true
1619
+ let workflowsPinned = false
1620
+ function isWorkflowsEnabled(): boolean {
1621
+ return workflowsEnabled
1622
+ }
1623
+ function isWorkflowsPinned(): boolean {
1624
+ return workflowsPinned
1625
+ }
1626
+ function setWorkflowsEnabled(b: boolean) {
1627
+ workflowsEnabled = b
1628
+ workflowsPinned = true
1629
+ }
1630
+
1037
1631
  // ---- Disable default agents configuration ----
1038
1632
  // When enabled, the three hardcoded default agents (general-purpose, Explore,
1039
1633
  // Plan) are not registered. User-defined agents from project/global custom
@@ -1103,10 +1697,117 @@ export default function (pi: ExtensionAPI) {
1103
1697
  }
1104
1698
  }
1105
1699
 
1700
+ /**
1701
+ * Launch a detached resume of an existing agent and wire everything a
1702
+ * re-running agent needs: transcript anchoring, activity tracking, join-mode
1703
+ * batching, the widget/fleet refresh, and the `subagents:created` event.
1704
+ *
1705
+ * Shared by the Agent tool's `resume` + `run_in_background` branch and the
1706
+ * `@handle message` prompt mention — they differ only in how they report the
1707
+ * outcome. Returns the record, or undefined when the manager refused because
1708
+ * the agent is still running (see AgentManager.resume).
1709
+ *
1710
+ * Callers must have already established that the record has a session.
1711
+ */
1712
+ async function startBackgroundResume(
1713
+ ctx: ExtensionContext,
1714
+ existing: AgentRecord,
1715
+ prompt: string,
1716
+ opts: { outputTranscript: boolean; maxTurns?: number; toolCallId?: string },
1717
+ ): Promise<AgentRecord | undefined> {
1718
+ const id = existing.id
1719
+ const joinMode = resolveJoinMode(defaultJoinMode, true)
1720
+ // Assigned unconditionally: the completion notification carries this as
1721
+ // `<tool-use-id>`, so a mention-resume (which passes none) has to CLEAR the
1722
+ // id left by the spawn that created the record. Keeping it would point the
1723
+ // orchestrator's new result at a tool call that was answered runs ago.
1724
+ existing.toolCallId = opts.toolCallId
1725
+ if (joinMode) existing.joinMode = joinMode
1726
+ // Reuse the agent's transcript rather than starting a fresh one: the
1727
+ // path is deterministic per agent+session, so writing an initial entry
1728
+ // would truncate the previous run's turns (see ensureOutputFile).
1729
+ if (opts.outputTranscript) {
1730
+ existing.outputFile = createOutputFilePath(
1731
+ ctx.cwd,
1732
+ id,
1733
+ ctx.sessionManager.getSessionId(),
1734
+ )
1735
+ ensureOutputFile(existing.outputFile)
1736
+ }
1737
+ // Anchor streaming past the turns already on disk, captured BEFORE the
1738
+ // run starts. The resumed prompt lands as an ordinary user message at
1739
+ // this index, so it is written exactly once.
1740
+ const transcriptAnchor = existing.session?.messages.length ?? 0
1741
+
1742
+ const { state: bgState, callbacks: bgCallbacks } = createActivityTracker(
1743
+ opts.maxTurns,
1744
+ )
1745
+ // resumeAgent has no onSessionCreated — the session predates this run —
1746
+ // so seed it directly, or the widget shows no context % for the agent.
1747
+ bgState.session = existing.session
1748
+
1749
+ // No `signal`: a background spawn deliberately omits it, and a detached
1750
+ // resume must behave the same. Passing it would abort this agent when
1751
+ // the parent turn is interrupted (user Esc), while agents started with
1752
+ // run_in_background in that same turn keep going.
1753
+ const record = await manager.resume(id, prompt, undefined, {
1754
+ isBackground: true,
1755
+ onToolActivity: bgCallbacks.onToolActivity,
1756
+ onAssistantUsage: bgCallbacks.onAssistantUsage,
1757
+ // Fires when the run actually starts — immediately, or on queue
1758
+ // drain. Wiring it here (rather than after resume() returns) means a
1759
+ // resume stopped while still queued never started streaming, so
1760
+ // there is no subscription left behind for a later run to trip over.
1761
+ onStarted: () => {
1762
+ const rec = manager.getRecord(id)
1763
+ if (rec?.session && rec.outputFile) {
1764
+ rec.outputCleanup = streamToOutputFile(
1765
+ rec.session,
1766
+ rec.outputFile,
1767
+ id,
1768
+ ctx.cwd,
1769
+ transcriptAnchor,
1770
+ )
1771
+ }
1772
+ },
1773
+ })
1774
+ if (!record) return undefined
1775
+
1776
+ if (joinMode != null && joinMode !== "async") {
1777
+ currentBatchAgents.push({ id, joinMode })
1778
+ if (batchFinalizeTimer) clearTimeout(batchFinalizeTimer)
1779
+ batchFinalizeTimer = setTimeout(finalizeBatch, 100)
1780
+ }
1781
+
1782
+ agentActivity.set(id, bgState)
1783
+ // This agent already finished once, so the widget holds a finished-age
1784
+ // for it that is past the linger limit — without clearing it, the
1785
+ // resumed run's ✓/✗ line never renders and the agent just vanishes.
1786
+ widget.markRunning(id)
1787
+ widget.ensureTimer()
1788
+ widget.update()
1789
+ fleet.ensureTimer()
1790
+ fleet.update()
1791
+
1792
+ // Resume ignores subagent_type (the record keeps the type it was
1793
+ // spawned with), so report the record's own identity — a "created"
1794
+ // event carrying the caller's type would re-register the agent under
1795
+ // the wrong one in cross-extension mirrors keyed by id.
1796
+ pi.events.emit("subagents:created", {
1797
+ id,
1798
+ type: existing.type,
1799
+ description: existing.description,
1800
+ isBackground: true,
1801
+ })
1802
+
1803
+ return record
1804
+ }
1805
+
1106
1806
  // Grab UI context from first tool execution + clear lingering widget on new turn
1107
1807
  pi.on("tool_execution_start", async (_event, ctx) => {
1108
1808
  widget.setUICtx(ctx.ui as UICtx)
1109
- // SAFETY: both UI adapters receive the same Pi ExtensionContext UI surface.
1809
+ // SAFETY: Pi's UI context implements the FleetUICtx structural subset;
1810
+ // this assertion exposes only the methods FleetList calls.
1110
1811
  fleet.setUICtx(ctx.ui as unknown as FleetUICtx)
1111
1812
  widget.onTurnStart()
1112
1813
  })
@@ -1163,15 +1864,18 @@ export default function (pi: ExtensionAPI) {
1163
1864
  setBackgroundByDefault,
1164
1865
  setSchedulingEnabled,
1165
1866
  setScopeModels: setScopeModelsEnabled,
1166
- setStrictAgentFiles: (enabled) => {
1167
- strictAgentFiles = enabled
1867
+ setStrictAgentFiles: (b) => {
1868
+ strictAgentFiles = b
1168
1869
  },
1169
1870
  setDisableDefaultAgents: setDisableDefaultAgents,
1170
1871
  setToolDescriptionMode: setToolDescriptionMode,
1171
1872
  setFleetView: setFleetViewEnabled,
1873
+ setAgentMentions: setAgentMentionMode,
1874
+ setRememberAgents,
1172
1875
  setWidgetMode: setWidgetMode,
1173
1876
  setOutputTranscript: setOutputTranscriptDefault,
1174
1877
  setWorktreeIsolation: setWorktreeIsolationEnabled,
1878
+ setWorkflowsEnabled: setWorkflowsEnabled,
1175
1879
  setMaxSubagentDepth: setMaxSubagentDepth,
1176
1880
  setFallbackSubagent: setFallbackSubagent,
1177
1881
  setReportUsage,
@@ -1206,11 +1910,19 @@ export default function (pi: ExtensionAPI) {
1206
1910
  ? `\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.`
1207
1911
  : ""
1208
1912
 
1913
+ // Same trade as scheduleParam/scheduleGuideline above: `isolationParam` drops
1914
+ // the field from the schema when the project set `worktreeIsolation: false`,
1915
+ // so the prose has to go with it. Left in, it would teach the model to pass a
1916
+ // parameter that isn't declared — accepted (TypeBox sets no
1917
+ // `additionalProperties: false`) and then silently dropped by the resolver.
1918
+ // With no per-result note by design, the model would have every reason to go
1919
+ // on reporting a `pi-agent-*` branch that was never created.
1209
1920
  const isolationGuideline = isWorktreeIsolationEnabled()
1210
- ? `\n- Use isolation: "worktree" to give the agent its own git worktree; leave it unset, or pass "off", for none. A worktree cannot see uncommitted or staged changes in the main checkout.`
1921
+ ? `\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 normally removed when the agent finishes and changes land on a named branch. If preservation fails, the worktree is kept and its recovery path is reported.`
1211
1922
  : ""
1923
+
1212
1924
  const isolationCompactGuideline = isWorktreeIsolationEnabled()
1213
- ? `\n- isolation: "worktree" gives the agent its own git worktree; "off" leaves it in the current checkout.`
1925
+ ? `\n- isolation: "worktree" uses a temporary git worktree. Changes land on a branch; a preservation failure keeps the worktree and reports its path.`
1214
1926
  : ""
1215
1927
 
1216
1928
  // Compact Agent tool description (#91, `toolDescriptionMode: "compact"`) —
@@ -1224,7 +1936,7 @@ Custom agents: .pi/agents/<name>.md (project) or ${getAgentDir()}/agents/<name>.
1224
1936
  Notes:
1225
1937
  - description: 3-5 words (shown in UI). Prompts must be self-contained — the agent has not seen this conversation.
1226
1938
  - Parallel work: one message, multiple Agent calls — they run concurrently.
1227
- - Subagents run in the background by default; you'll be notified when one completes. Pass run_in_background: false only when your next action depends on its result.
1939
+ - 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.
1228
1940
  - The result is not shown to the user — summarize it for them. Verify an agent's claimed code changes before reporting work done.
1229
1941
  - resume continues a previous agent by ID; steer_subagent messages a running one.${isolationCompactGuideline}`
1230
1942
 
@@ -1244,12 +1956,12 @@ If the target is already known, use a direct tool — \`read\` for a known path,
1244
1956
  ## Usage notes
1245
1957
 
1246
1958
  - Always include a short (3-5 word) description summarizing what the agent will do (shown in UI).
1247
- - When you launch multiple agents for independent work, send them in a single message with multiple tool uses so they run concurrently.
1959
+ - 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.
1248
1960
  - 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.
1249
- - 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.
1250
- - Agents run in the background by default. You will be notified when one completes — do NOT poll or sleep waiting for it.
1251
- - Pass \`run_in_background: false\` only when your very next action depends on the result and nothing else could usefully happen while it runs.
1252
- - Never fabricate or predict a pending agent's results; if asked before completion, say it is still running.
1961
+ - 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.
1962
+ - 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.
1963
+ - **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.
1964
+ - **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.
1253
1965
  - 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.
1254
1966
  - Use steer_subagent to send mid-run messages to a running background agent.
1255
1967
  - 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.
@@ -1260,7 +1972,7 @@ If the target is already known, use a direct tool — \`read\` for a known path,
1260
1972
 
1261
1973
  ## Writing the prompt
1262
1974
 
1263
- 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.
1975
+ 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.
1264
1976
  - Explain what you're trying to accomplish and why.
1265
1977
  - Describe what you've already learned or ruled out.
1266
1978
  - Give enough context about the surrounding problem that the agent can make judgment calls rather than just following a narrow instruction.
@@ -1325,879 +2037,1371 @@ Terse command-style prompts produce shallow, generic work.
1325
2037
  return fullAgentToolDescription
1326
2038
  })()
1327
2039
 
1328
- function registerToolReportingUsage(tool: any): void {
1329
- pi.registerTool({
1330
- ...tool,
1331
- execute: async (toolCallId: string | undefined, ...args: any[]) => {
1332
- const result = await tool.execute(toolCallId, ...args)
1333
- if (!reportUsage || !toolCallId) return result
1334
- const usage = pendingUsage.drain()
1335
- return usage ? { ...result, usage } : result
1336
- },
1337
- })
1338
- }
1339
-
1340
- registerToolReportingUsage(
1341
- defineTool({
1342
- name: SUBAGENT_TOOL_NAMES.AGENT,
1343
- label: "Agent",
1344
- description: agentToolDescription,
1345
- promptSnippet:
1346
- "Launch autonomous sub-agents for complex multi-step tasks",
1347
- promptGuidelines: [
1348
- "Use Agent with specialized agents when the task matches an agent type's description. Subagents are valuable for parallelizing independent queries or for protecting the main context window from excessive results, but should not be used excessively when not needed. Importantly, avoid duplicating work that subagents are already doing — if you delegate research to a subagent, do not also perform the same searches yourself.",
1349
- "For broad codebase exploration or research, spawn Agent with an appropriate subagent_type (e.g. Explore). Otherwise use direct tools (read, grep, find) when the target is already known.",
1350
- "When an agent runs in the background, you will be notified on completion — do not poll or sleep waiting for it. Continue with other work instead.",
1351
- "Trust but verify: an agent's summary describes intent, not outcome. When an agent writes or edits code, check the actual changes before reporting work as done.",
1352
- ],
1353
- parameters: Type.Object({
1354
- prompt: Type.String({
1355
- description: "The task for the agent to perform.",
2040
+ // Held rather than registered inline: the mention clone reuses this exact
2041
+ // definition, so the agent it starts is an ordinary top-level spawn instead
2042
+ // of a second implementation that has to be kept in step with this one.
2043
+ const agentTool = defineTool({
2044
+ name: SUBAGENT_TOOL_NAMES.AGENT,
2045
+ label: "Agent",
2046
+ description: agentToolDescription,
2047
+ promptSnippet: "Launch autonomous sub-agents for complex multi-step tasks",
2048
+ promptGuidelines: [
2049
+ "Use Agent with specialized agents when the task matches an agent type's description. Subagents are valuable for parallelizing independent queries or for protecting the main context window from excessive results, but should not be used excessively when not needed. Importantly, avoid duplicating work that subagents are already doing — if you delegate research to a subagent, do not also perform the same searches yourself.",
2050
+ "For broad codebase exploration or research, spawn Agent with an appropriate subagent_type (e.g. Explore). Otherwise use direct tools (read, grep, find) when the target is already known.",
2051
+ "When an agent runs in the background, you will be notified on completion — do not poll or sleep waiting for it. Continue with other work instead.",
2052
+ "Trust but verify: an agent's summary describes intent, not outcome. When an agent writes or edits code, check the actual changes before reporting work as done.",
2053
+ ],
2054
+ parameters: Type.Object({
2055
+ prompt: Type.String({
2056
+ description: "The task for the agent to perform.",
2057
+ }),
2058
+ description: Type.String({
2059
+ description:
2060
+ "A short (3-5 word) description of the task (shown in UI).",
2061
+ }),
2062
+ name: Type.Optional(
2063
+ Type.String({
2064
+ description:
2065
+ '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.',
1356
2066
  }),
1357
- description: Type.String({
2067
+ ),
2068
+ subagent_type: Type.String({
2069
+ 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.`,
2070
+ }),
2071
+ model: Type.Optional(
2072
+ Type.String({
1358
2073
  description:
1359
- "A short (3-5 word) description of the task (shown in UI).",
2074
+ 'Optional model override. Accepts "provider/modelId" or fuzzy name (e.g. "haiku", "sonnet"). Omit to use the agent type\'s default.',
1360
2075
  }),
1361
- subagent_type: Type.String({
1362
- 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.`,
2076
+ ),
2077
+ thinking: Type.Optional(
2078
+ Type.String({
2079
+ description: `Thinking level: ${THINKING_LEVELS.join(", ")}. Overrides agent default.`,
1363
2080
  }),
1364
- model: Type.Optional(
1365
- Type.String({
1366
- description:
1367
- 'Optional model override. Accepts "provider/modelId" or fuzzy name (e.g. "haiku", "sonnet"). Omit to use the agent type\'s default.',
1368
- }),
1369
- ),
1370
- thinking: Type.Optional(
1371
- Type.String({
1372
- description: `Thinking level: ${THINKING_LEVELS.join(", ")}. Overrides agent default.`,
1373
- }),
1374
- ),
1375
- max_turns: Type.Optional(
1376
- Type.Number({
1377
- description:
1378
- "Maximum number of agentic turns before stopping. Omit for unlimited (default).",
1379
- minimum: 1,
1380
- }),
1381
- ),
1382
- run_in_background: Type.Optional(
1383
- Type.Boolean({
1384
- description:
1385
- "Defaults to true: returns the agent ID immediately and notifies on completion. Set false to block and return the full output inline.",
1386
- }),
1387
- ),
1388
- resume: Type.Optional(
1389
- Type.String({
1390
- description:
1391
- "Optional agent ID to resume from. Resumes detached by default; pass run_in_background: false to block and get the result inline. An agent can only be resumed after its current run finishes.",
1392
- }),
1393
- ),
1394
- isolated: Type.Optional(
1395
- Type.Boolean({
1396
- description:
1397
- "If true, agent gets no extension/MCP tools — only built-in tools.",
1398
- }),
1399
- ),
1400
- inherit_context: Type.Optional(
1401
- Type.Boolean({
1402
- description:
1403
- "If true, fork parent conversation into the agent. Default: false (fresh context).",
1404
- }),
1405
- ),
1406
- ...isolationParam(isWorktreeIsolationEnabled()),
1407
- ...scheduleParam,
1408
- }),
2081
+ ),
2082
+ max_turns: Type.Optional(
2083
+ Type.Number({
2084
+ description:
2085
+ "Maximum number of agentic turns before stopping. Omit for unlimited (default).",
2086
+ minimum: 1,
2087
+ }),
2088
+ ),
2089
+ run_in_background: Type.Optional(
2090
+ Type.Boolean({
2091
+ description:
2092
+ "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.",
2093
+ }),
2094
+ ),
2095
+ resume: Type.Optional(
2096
+ Type.String({
2097
+ description:
2098
+ "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.",
2099
+ }),
2100
+ ),
2101
+ isolated: Type.Optional(
2102
+ Type.Boolean({
2103
+ description:
2104
+ "If true, agent gets no extension/MCP tools — only built-in tools.",
2105
+ }),
2106
+ ),
2107
+ inherit_context: Type.Optional(
2108
+ Type.Boolean({
2109
+ description:
2110
+ "If true, fork parent conversation into the agent. Default: false (fresh context).",
2111
+ }),
2112
+ ),
2113
+ ...isolationParam(isWorktreeIsolationEnabled()),
2114
+ ...scheduleParam,
2115
+ }),
1409
2116
 
1410
- // ---- Custom rendering: Claude Code style ----
1411
-
1412
- renderCall(args, theme, context) {
1413
- // A badge closes its own background, which would clear the tool block's row tint
1414
- // for the rest of the line, so the badge restores it. The tint is opened here too:
1415
- // the TUI's Box paints it, but HTML export takes it from CSS, and restoring a
1416
- // background the line never opened is what banded the export before. The line is
1417
- // deliberately left open — Box.applyBackgroundToLine pads to width and *then*
1418
- // wraps, so closing here would leave that padding untinted, and HTML export closes
1419
- // any open span per line anyway. No badge means no tint, so an uncolored agent
1420
- // renders exactly the line it always did.
1421
- const rowBackground = hasAgentBadge(args.subagent_type)
1422
- ? theme.getBgAnsi(
1423
- context.isPartial
1424
- ? "toolPendingBg"
1425
- : context.isError
1426
- ? "toolErrorBg"
1427
- : "toolSuccessBg",
1428
- )
1429
- : ""
1430
- const desc = args.description ?? ""
1431
- const name = renderAgentName(args.subagent_type, theme, {
1432
- fallbackColor: "toolTitle",
1433
- restoreBackground: rowBackground,
1434
- bold: true,
1435
- })
2117
+ // ---- Custom rendering: Claude Code style ----
2118
+
2119
+ renderCall(args, theme, context) {
2120
+ // A badge closes its own background, which would clear the tool block's row tint
2121
+ // for the rest of the line, so the badge restores it. The tint is opened here too:
2122
+ // the TUI's Box paints it, but HTML export takes it from CSS, and restoring a
2123
+ // background the line never opened is what banded the export before. The line is
2124
+ // deliberately left open — Box.applyBackgroundToLine pads to width and *then*
2125
+ // wraps, so closing here would leave that padding untinted, and HTML export closes
2126
+ // any open span per line anyway. No badge means no tint, so an uncolored agent
2127
+ // renders exactly the line it always did.
2128
+ const rowBackground = hasAgentBadge(args.subagent_type)
2129
+ ? theme.getBgAnsi(
2130
+ context.isPartial
2131
+ ? "toolPendingBg"
2132
+ : context.isError
2133
+ ? "toolErrorBg"
2134
+ : "toolSuccessBg",
2135
+ )
2136
+ : ""
2137
+ const desc = args.description ?? ""
2138
+ const name = renderAgentName(args.subagent_type, theme, {
2139
+ fallbackColor: "toolTitle",
2140
+ restoreBackground: rowBackground,
2141
+ bold: true,
2142
+ })
2143
+ return new Text(
2144
+ rowBackground +
2145
+ "▸ " +
2146
+ name +
2147
+ (desc ? " " + theme.fg("muted", desc) : ""),
2148
+ 0,
2149
+ 0,
2150
+ )
2151
+ },
2152
+
2153
+ renderResult(result, { expanded, isPartial }, theme, renderContext) {
2154
+ const details = result.details as AgentDetails | undefined
2155
+ const text =
2156
+ result.content[0]?.type === "text" ? result.content[0].text : ""
2157
+ // Pi reports pre-execution failures (extension block, abort, argument
2158
+ // validation) as `{ content: [reason], details: {} }` with isError set —
2159
+ // no status to render, so show the reason instead of inventing one (#199).
2160
+ if (renderContext.isError || !details?.status) {
2161
+ return new Text(text, 0, 0)
2162
+ }
2163
+
2164
+ // Helper: build "haiku · thinking: high · ↻5≤30 · 3 tool uses · 33.8k tokens" stats string
2165
+ const stats = (d: AgentDetails) => {
2166
+ const parts: string[] = []
2167
+ if (d.modelName) parts.push(d.modelName)
2168
+ if (d.tags) parts.push(...d.tags)
2169
+ if (d.turnCount != null && d.turnCount > 0) {
2170
+ parts.push(formatTurns(d.turnCount, d.maxTurns))
2171
+ }
2172
+ if (d.toolUses > 0)
2173
+ parts.push(`${d.toolUses} tool use${d.toolUses === 1 ? "" : "s"}`)
2174
+ if (d.tokens) parts.push(d.tokens)
2175
+ if (showCost) {
2176
+ const costText = formatCost(d.cost ?? 0)
2177
+ if (costText) parts.push(costText)
2178
+ }
2179
+ return parts
2180
+ .map((p) => fgPreservingNestedStyles(theme, "dim", p))
2181
+ .join(" " + theme.fg("dim", "·") + " ")
2182
+ }
2183
+
2184
+ // ---- While running (streaming) ----
2185
+ if (isPartial || details.status === "running") {
2186
+ const frame = SPINNER[details.spinnerFrame ?? 0]
2187
+ const s = stats(details)
2188
+ return renderRunningAgentStatus(
2189
+ frame,
2190
+ s,
2191
+ details.activity ?? "thinking…",
2192
+ theme,
2193
+ )
2194
+ }
2195
+
2196
+ // ---- Background agent launched ----
2197
+ if (details.status === "background") {
1436
2198
  return new Text(
1437
- rowBackground +
1438
- "▸ " +
1439
- name +
1440
- (desc ? " " + theme.fg("muted", desc) : ""),
2199
+ theme.fg(
2200
+ "dim",
2201
+ ` ⎿ Running in background (ID: ${details.agentId})`,
2202
+ ),
1441
2203
  0,
1442
2204
  0,
1443
2205
  )
1444
- },
2206
+ }
1445
2207
 
1446
- renderResult(result, { expanded, isPartial }, theme, renderContext) {
1447
- const resultText =
1448
- result.content[0]?.type === "text" ? result.content[0].text : ""
1449
- const details = result.details as AgentDetails | undefined
1450
- if (renderContext?.isError || !details?.status) {
1451
- return new Text(resultText, 0, 0)
1452
- }
2208
+ // ---- Completed / Steered ----
2209
+ if (details.status === "completed" || details.status === "steered") {
2210
+ const duration = formatMs(details.durationMs)
2211
+ const isSteered = details.status === "steered"
2212
+ const icon = isSteered
2213
+ ? theme.fg("warning", "✓")
2214
+ : theme.fg("success", "✓")
2215
+ const s = stats(details)
2216
+ let line = icon + (s ? " " + s : "")
2217
+ line += " " + theme.fg("dim", "·") + " " + theme.fg("dim", duration)
1453
2218
 
1454
- // Helper: build "haiku · thinking: high · ↻5≤30 · 3 tool uses · 33.8k tokens" stats string
1455
- const stats = (d: AgentDetails) => {
1456
- const parts: string[] = []
1457
- if (d.modelName) parts.push(d.modelName)
1458
- if (d.tags) parts.push(...d.tags)
1459
- if (d.turnCount != null && d.turnCount > 0) {
1460
- parts.push(formatTurns(d.turnCount, d.maxTurns))
1461
- }
1462
- if (d.toolUses > 0)
1463
- parts.push(`${d.toolUses} tool use${d.toolUses === 1 ? "" : "s"}`)
1464
- if (d.tokens) parts.push(d.tokens)
1465
- if (showCost) {
1466
- const costText = formatCost(d.cost ?? 0)
1467
- if (costText) parts.push(costText)
2219
+ if (expanded) {
2220
+ const resultText =
2221
+ result.content[0]?.type === "text" ? result.content[0].text : ""
2222
+ if (resultText) {
2223
+ const lines = resultText.split("\n").slice(0, 50)
2224
+ for (const l of lines) {
2225
+ line += "\n" + theme.fg("dim", ` ${l}`)
2226
+ }
2227
+ if (resultText.split("\n").length > 50) {
2228
+ line +=
2229
+ "\n" +
2230
+ theme.fg(
2231
+ "muted",
2232
+ " ... (use get_subagent_result with verbose for full output)",
2233
+ )
2234
+ }
1468
2235
  }
1469
- return parts
1470
- .map((p) => fgPreservingNestedStyles(theme, "dim", p))
1471
- .join(" " + theme.fg("dim", "·") + " ")
2236
+ } else {
2237
+ const doneText = isSteered ? "Wrapped up (turn limit)" : "Done"
2238
+ line += "\n" + theme.fg("dim", ` ⎿ ${doneText}`)
1472
2239
  }
2240
+ return new Text(line, 0, 0)
2241
+ }
2242
+
2243
+ // ---- Stopped (user-initiated abort) ----
2244
+ if (details.status === "stopped") {
2245
+ const s = stats(details)
2246
+ let line = theme.fg("dim", "■") + (s ? " " + s : "")
2247
+ line += "\n" + theme.fg("dim", " ⎿ Stopped")
2248
+ return new Text(line, 0, 0)
2249
+ }
2250
+
2251
+ // Anything left ("queued", or a status added later) has no rendering of
2252
+ // its own — the turn-limit wording below must not be the catch-all.
2253
+ if (details.status !== "error" && details.status !== "aborted") {
2254
+ return new Text(text, 0, 0)
2255
+ }
2256
+
2257
+ // ---- Error / Aborted (hard max_turns) ----
2258
+ const s = stats(details)
2259
+ let line = theme.fg("error", "✗") + (s ? " " + s : "")
2260
+
2261
+ if (details.status === "error") {
2262
+ line +=
2263
+ "\n" + theme.fg("error", ` ⎿ Error: ${details.error ?? "unknown"}`)
2264
+ } else {
2265
+ line += "\n" + theme.fg("warning", " ⎿ Aborted (max turns exceeded)")
2266
+ }
2267
+
2268
+ return new Text(line, 0, 0)
2269
+ },
2270
+
2271
+ // ---- Execute ----
2272
+
2273
+ execute: async (toolCallId, params, signal, onUpdate, ctx) => {
2274
+ // Ensure we have UI context for widget rendering
2275
+ widget.setUICtx(ctx.ui as UICtx)
1473
2276
 
1474
- // ---- While running (streaming) ----
1475
- if (isPartial || details.status === "running") {
1476
- const frame = SPINNER[details.spinnerFrame ?? 0]
1477
- const s = stats(details)
1478
- return renderRunningAgentStatus(
1479
- frame,
1480
- s,
1481
- details.activity ?? "thinking…",
1482
- theme,
2277
+ // Reload custom agents so new project/global .md files are picked up without restart
2278
+ reloadCustomAgents()
2279
+
2280
+ const rawType = params.subagent_type as SubagentType
2281
+ // Single decision point for dispatch (#183): unknown, disabled and
2282
+ // case-ambiguous types are refused here, BEFORE anything spawns, so a
2283
+ // background or scheduled call can't start running the wrong agent while
2284
+ // the caller is still unaware. `fallbackSubagent` decides whether an
2285
+ // unresolvable type falls back or fails closed.
2286
+ const dispatch = resolveSpawnType(rawType)
2287
+ // `resume` replays a stored session and ignores `subagent_type` entirely,
2288
+ // but the parameter is required by the schema — so gating it here would
2289
+ // make a live agent unresumable the moment its type is deleted, disabled,
2290
+ // or gains a case-clashing sibling. Only a real spawn is gated.
2291
+ if (!dispatch.ok && !params.resume) return textResult(dispatch.message)
2292
+ const subagentType = dispatch.ok ? dispatch.type : rawType
2293
+ // What the caller actually asked for, named once: `fellBackFrom` is "" for
2294
+ // a blank request, so reading it inline invites the `??`-vs-`||` slip that
2295
+ // once persisted an empty type into a scheduled job.
2296
+ const requestedType =
2297
+ (dispatch.ok && dispatch.fellBackFrom) || subagentType
2298
+ // Computed at resolution rather than after the run, so the background and
2299
+ // schedule branches carry it too — previously it existed only on the
2300
+ // foreground path. Resume deliberately doesn't: it replays the stored
2301
+ // session and ignores `subagent_type` entirely, so a note about type
2302
+ // substitution would be describing something that didn't happen.
2303
+ const fallbackNote =
2304
+ dispatch.ok && dispatch.fellBackFrom !== undefined
2305
+ ? `Note: Unknown agent type "${dispatch.fellBackFrom}" — using ${resolveType(subagentType) ? subagentType : "the fallback agent config"}.\n\n`
2306
+ : ""
2307
+
2308
+ const displayName = getDisplayName(subagentType)
2309
+
2310
+ // Get agent config (if any)
2311
+ const customConfig = getAgentConfig(subagentType)
2312
+
2313
+ const resolvedConfig = resolveAgentInvocationConfig(
2314
+ customConfig,
2315
+ params,
2316
+ {
2317
+ worktreeAllowed: isWorktreeIsolationEnabled(),
2318
+ defaultRunInBackground: getBackgroundByDefault(),
2319
+ },
2320
+ )
2321
+
2322
+ // Resolve model from agent config first; tool-call params only fill gaps.
2323
+ let model = ctx.model
2324
+ if (resolvedConfig.modelInput) {
2325
+ const resolved = resolveModel(
2326
+ resolvedConfig.modelInput,
2327
+ ctx.modelRegistry,
2328
+ )
2329
+ if (typeof resolved === "string") {
2330
+ if (resolvedConfig.modelFromParams) return textResult(resolved)
2331
+ // config-specified: silent fallback to parent
2332
+ } else {
2333
+ model = resolved
2334
+ }
2335
+ }
2336
+
2337
+ // Scope validation: the effective resolved model is checked against the
2338
+ // user's enabledModels list. Policy (hard error vs warn-and-proceed) lives
2339
+ // in model-scope.ts so the nested delegation tools apply the same rule.
2340
+ const scopeVerdict = checkModelScope({
2341
+ model,
2342
+ cwd: ctx.cwd,
2343
+ modelRegistry: ctx.modelRegistry,
2344
+ callerSupplied: resolvedConfig.modelFromParams,
2345
+ agentLabel: customConfig?.displayName ?? subagentType,
2346
+ modelInput: resolvedConfig.modelInput,
2347
+ })
2348
+ if (scopeVerdict.kind === "error") return textResult(scopeVerdict.message)
2349
+ if (scopeVerdict.kind === "warn")
2350
+ ctx.ui.notify(scopeVerdict.message, "warning")
2351
+
2352
+ const thinking = resolvedConfig.thinking
2353
+ const inheritContext = resolvedConfig.inheritContext
2354
+ const runInBackground = resolvedConfig.runInBackground
2355
+ const isolated = resolvedConfig.isolated
2356
+ const isolation = resolvedConfig.isolation
2357
+ // Whether this spawn writes its .output transcript. Per-agent
2358
+ // frontmatter (`output_transcript`) wins; otherwise the project/global
2359
+ // default applies. `attachTranscript` below is the SOLE gate — every
2360
+ // downstream consumer keys off record.outputFile being set, so no spawn
2361
+ // path can re-enable the transcript by accident.
2362
+ const outputTranscript =
2363
+ customConfig?.outputTranscript ?? getOutputTranscriptDefault()
2364
+ const attachTranscript = (
2365
+ rec: AgentRecord | undefined,
2366
+ agentId: string,
2367
+ ): void => {
2368
+ if (!rec || !outputTranscript) return
2369
+ rec.outputFile = createOutputFilePath(
2370
+ ctx.cwd,
2371
+ agentId,
2372
+ ctx.sessionManager.getSessionId(),
2373
+ )
2374
+ writeInitialEntry(rec.outputFile, agentId, params.prompt, ctx.cwd)
2375
+ }
2376
+
2377
+ // Unconditional, not "only when it differs from the parent": a thinking
2378
+ // level reads as a property of a model, and an agent that inherited the
2379
+ // parent's model used to show the level with nothing to attach it to.
2380
+ // This is the pre-session snapshot — agent-manager overwrites it with the
2381
+ // effective values the moment a session reports them.
2382
+ const { modelName, modelId } = model
2383
+ ? describeModel(model)
2384
+ : { modelName: undefined, modelId: undefined }
2385
+ // What the caller SPELLED, kept only if it names a different model than the
2386
+ // one that won. Model input is fuzzy — `"haiku"` and
2387
+ // `"anthropic/claude-haiku-4-5"` are the same model — so comparing the two
2388
+ // strings would disclose an override that never happened. A spelling that
2389
+ // resolves to nothing is still worth disclosing: it cannot have taken effect.
2390
+ const askedModel = ((asked: string | undefined) => {
2391
+ if (!asked) return undefined
2392
+ const resolvedAsked = resolveModel(asked, ctx.modelRegistry)
2393
+ if (typeof resolvedAsked === "string") return asked
2394
+ return resolvedAsked.provider === model?.provider &&
2395
+ resolvedAsked.id === model?.id
2396
+ ? undefined
2397
+ : asked
2398
+ })(resolvedConfig.overridden?.model)
2399
+ const effectiveMaxTurns = normalizeMaxTurns(
2400
+ resolvedConfig.maxTurns ?? getDefaultMaxTurns(),
2401
+ )
2402
+ const agentInvocation: AgentInvocation = {
2403
+ modelName,
2404
+ modelId,
2405
+ thinking,
2406
+ // Only set where the agent file outranked the caller, so the surfaces can
2407
+ // disclose a parameter that was accepted but could not take effect (#182).
2408
+ requestedThinking: resolvedConfig.overridden?.thinking,
2409
+ requestedModel: askedModel,
2410
+ // Explicit value only — the default fallback would just add noise.
2411
+ // Normalize so `0` (unlimited) doesn't surface as a misleading "max turns: 0".
2412
+ maxTurns: normalizeMaxTurns(resolvedConfig.maxTurns),
2413
+ isolated,
2414
+ inheritContext,
2415
+ runInBackground,
2416
+ isolation,
2417
+ }
2418
+ // Tool-result render shows the mode label too; viewer's header already does.
2419
+ const modeLabel = getPromptModeLabel(subagentType)
2420
+ const { tags: invocationTags } = buildInvocationTags(agentInvocation)
2421
+ const agentTags = modeLabel
2422
+ ? [modeLabel, ...invocationTags]
2423
+ : invocationTags
2424
+ const detailBase = {
2425
+ displayName,
2426
+ description: params.description,
2427
+ subagentType,
2428
+ modelName,
2429
+ tags: agentTags.length > 0 ? agentTags : undefined,
2430
+ }
2431
+
2432
+ /**
2433
+ * `detailBase` for a record that exists, which outranks it: the base is a
2434
+ * snapshot of what this call REQUESTED, and pi may have resolved a
2435
+ * different model or clamped the thinking level (agent-manager writes the
2436
+ * effective values back when the session reports them). Resume goes
2437
+ * further and ignores the model/thinking parameters outright — it runs on
2438
+ * the session it is reopening — so rendering the base there advertises
2439
+ * settings the run never used.
2440
+ *
2441
+ * The mode label is rebuilt rather than carried over: it hangs off the
2442
+ * agent TYPE, not the invocation, so tags taken straight from
2443
+ * buildInvocationTags would silently drop `twin`.
2444
+ */
2445
+ const detailBaseFor = (
2446
+ rec: AgentRecord | undefined,
2447
+ ): typeof detailBase => {
2448
+ if (!rec?.invocation) return detailBase
2449
+ const type = rec.type
2450
+ const { modelName: recModelName, tags } = buildInvocationTags(
2451
+ rec.invocation,
2452
+ )
2453
+ const recModeLabel = getPromptModeLabel(type)
2454
+ const recTags = recModeLabel ? [recModeLabel, ...tags] : tags
2455
+ return {
2456
+ displayName: getDisplayName(type),
2457
+ description: rec.description,
2458
+ subagentType: type,
2459
+ modelName: recModelName,
2460
+ tags: recTags.length > 0 ? recTags : undefined,
2461
+ }
2462
+ }
2463
+
2464
+ // ---- Schedule: register a job, don't spawn now ----
2465
+ if (params.schedule) {
2466
+ if (!isSchedulingEnabled()) {
2467
+ return textResult(
2468
+ "Scheduling is disabled in this project. Enable via /agents → Settings → Scheduling.",
2469
+ )
2470
+ }
2471
+ if (params.resume) {
2472
+ return textResult(
2473
+ "Cannot combine `schedule` with `resume` — schedules create fresh agents.",
2474
+ )
2475
+ }
2476
+ if (params.inherit_context) {
2477
+ return textResult(
2478
+ "Cannot combine `schedule` with `inherit_context` — there is no parent conversation at fire time.",
2479
+ )
2480
+ }
2481
+ if (params.run_in_background === false) {
2482
+ return textResult(
2483
+ "Cannot combine `schedule` with `run_in_background: false` — scheduled jobs always run in background.",
2484
+ )
2485
+ }
2486
+ if (!scheduler.isActive()) {
2487
+ return textResult(
2488
+ "Scheduler is not active in this session yet. Try again after the session has fully started.",
2489
+ )
2490
+ }
2491
+ try {
2492
+ const job = scheduler.addJob({
2493
+ name: params.description as string,
2494
+ description: params.description as string,
2495
+ schedule: params.schedule as string,
2496
+ // The caller's own name, not the substitute — the scheduler re-resolves
2497
+ // at fire time, and the original is what a user edits.
2498
+ subagent_type: requestedType,
2499
+ prompt: params.prompt as string,
2500
+ model: params.model as string | undefined,
2501
+ thinking: thinking,
2502
+ max_turns: effectiveMaxTurns,
2503
+ isolated: isolated,
2504
+ isolation: isolation,
2505
+ })
2506
+ const next = scheduler.getNextRun(job.id)
2507
+ return textResult(
2508
+ `${fallbackNote}Scheduled "${job.name}" (id: ${job.id}, type: ${job.scheduleType}). ` +
2509
+ `Next run: ${next ?? "(unknown)"}. ` +
2510
+ `Manage via /agents → Scheduled jobs.`,
1483
2511
  )
2512
+ } catch (err) {
2513
+ return textResult(err instanceof Error ? err.message : String(err))
1484
2514
  }
2515
+ }
1485
2516
 
1486
- // ---- Background agent launched ----
1487
- if (details.status === "background") {
1488
- return new Text(
1489
- theme.fg(
1490
- "dim",
1491
- ` ⎿ Running in background (ID: ${details.agentId})`,
1492
- ),
1493
- 0,
1494
- 0,
2517
+ // Resume existing agent
2518
+ if (params.resume) {
2519
+ const existing = manager.getRecord(params.resume)
2520
+ if (!existing || !isTopLevelAgent(existing)) {
2521
+ return textResult(
2522
+ `Agent not found: "${params.resume}". It may have been cleaned up.`,
2523
+ )
2524
+ }
2525
+ if (!existing.session) {
2526
+ return textResult(
2527
+ `Agent "${params.resume}" has no active session to resume.`,
1495
2528
  )
1496
2529
  }
1497
2530
 
1498
- // ---- Completed / Steered ----
1499
- if (details.status === "completed" || details.status === "steered") {
1500
- const duration = formatMs(details.durationMs)
1501
- const isSteered = details.status === "steered"
1502
- const icon = isSteered
1503
- ? theme.fg("warning", "✓")
1504
- : theme.fg("success", "✓")
1505
- const s = stats(details)
1506
- let line = icon + (s ? " " + s : "")
1507
- line += " " + theme.fg("dim", "·") + " " + theme.fg("dim", duration)
1508
-
1509
- if (expanded) {
1510
- if (resultText) {
1511
- const lines = resultText.split("\n").slice(0, 50)
1512
- for (const l of lines) {
1513
- line += "\n" + theme.fg("dim", ` ${l}`)
1514
- }
1515
- if (resultText.split("\n").length > 50) {
1516
- line +=
1517
- "\n" +
1518
- theme.fg(
1519
- "muted",
1520
- " ... (use get_subagent_result with verbose for full output)",
1521
- )
1522
- }
1523
- }
1524
- } else {
1525
- const doneText = isSteered ? "Wrapped up (turn limit)" : "Done"
1526
- line += "\n" + theme.fg("dim", ` ⎿ ${doneText}`)
2531
+ // Background resume: detached run that notifies on completion, mirroring
2532
+ // a background spawn. Previously run_in_background was silently ignored
2533
+ // on resume (this branch returned before the background branch below),
2534
+ // so a resumed agent always blocked the main loop until it finished.
2535
+ if (runInBackground) {
2536
+ const id = existing.id
2537
+ // A detached resume hands control back while the record stays
2538
+ // "running", so nothing stops the model from resuming the same agent
2539
+ // again mid-run. manager.resume() refuses that (it would orphan the
2540
+ // live run's abort controller); say why here, where the model can act
2541
+ // on it, instead of letting it read as a generic failure.
2542
+ if (existing.status === "running" || existing.status === "queued") {
2543
+ return textResult(
2544
+ `Agent "${params.resume}" is still ${existing.status} — it can only be resumed once its current run finishes.\n` +
2545
+ `Use steer_subagent to send it a message mid-run, or get_subagent_result to wait for it.`,
2546
+ )
2547
+ }
2548
+
2549
+ const record = await startBackgroundResume(
2550
+ ctx,
2551
+ existing,
2552
+ params.prompt,
2553
+ {
2554
+ outputTranscript,
2555
+ maxTurns: effectiveMaxTurns,
2556
+ toolCallId,
2557
+ },
2558
+ )
2559
+ if (!record) {
2560
+ return textResult(`Failed to resume agent "${params.resume}".`)
1527
2561
  }
1528
- return new Text(line, 0, 0)
2562
+
2563
+ const isQueued = record.status === "queued"
2564
+ return textResult(
2565
+ `Agent ${isQueued ? "queued" : "resumed"} in background.\n` +
2566
+ `Agent ID: ${id}\n` +
2567
+ `Type: ${existing.type}\n` +
2568
+ (record.outputFile ? `Output file: ${record.outputFile}\n` : "") +
2569
+ (isQueued
2570
+ ? `Position: queued (max ${manager.getMaxConcurrent()} concurrent)\n`
2571
+ : "") +
2572
+ `\nYou will be notified when this agent completes.\n` +
2573
+ `Use get_subagent_result to retrieve full results, or steer_subagent to send it messages.`,
2574
+ {
2575
+ ...detailBaseFor(record),
2576
+ toolUses: record.toolUses,
2577
+ tokens: "",
2578
+ durationMs: 0,
2579
+ status: "background" as const,
2580
+ agentId: id,
2581
+ },
2582
+ )
1529
2583
  }
1530
2584
 
1531
- // ---- Stopped (user-initiated abort) ----
1532
- if (details.status === "stopped") {
1533
- const s = stats(details)
1534
- let line = theme.fg("dim", "■") + (s ? " " + s : "")
1535
- line += "\n" + theme.fg("dim", " ⎿ Stopped")
1536
- return new Text(line, 0, 0)
2585
+ const record = await manager.resume(
2586
+ params.resume,
2587
+ params.prompt,
2588
+ signal,
2589
+ )
2590
+ if (!record) {
2591
+ return textResult(`Failed to resume agent "${params.resume}".`)
1537
2592
  }
2593
+ // A failed resume surfaces the error, plus any partial output THIS
2594
+ // resume produced (never the previous turn's answer, #144).
2595
+ if (record.status === "error") {
2596
+ return textResult(
2597
+ `Agent failed: ${record.error}${partialOutputSuffix(record)}`,
2598
+ buildDetails(detailBaseFor(record), record),
2599
+ )
2600
+ }
2601
+ return textResult(
2602
+ record.result?.trim() || "No output.",
2603
+ buildDetails(detailBaseFor(record), record),
2604
+ )
2605
+ }
1538
2606
 
1539
- if (details.status !== "error" && details.status !== "aborted") {
1540
- return new Text(resultText, 0, 0)
2607
+ // Background execution
2608
+ if (runInBackground) {
2609
+ const { state: bgState, callbacks: bgCallbacks } =
2610
+ createActivityTracker(effectiveMaxTurns)
2611
+
2612
+ // Wrap onSessionCreated to wire output file streaming.
2613
+ // The callback lazily reads record.outputFile (set right after spawn)
2614
+ // rather than closing over a value that doesn't exist yet.
2615
+ let id: string
2616
+ const origBgOnSession = bgCallbacks.onSessionCreated
2617
+ bgCallbacks.onSessionCreated = (session: any) => {
2618
+ origBgOnSession(session)
2619
+ const rec = manager.getRecord(id)
2620
+ if (rec?.outputFile) {
2621
+ rec.outputCleanup = streamToOutputFile(
2622
+ session,
2623
+ rec.outputFile,
2624
+ id,
2625
+ ctx.cwd,
2626
+ )
2627
+ }
1541
2628
  }
1542
2629
 
1543
- // ---- Error / Aborted (hard max_turns) ----
1544
- const s = stats(details)
1545
- let line = theme.fg("error", "✗") + (s ? " " + s : "")
2630
+ // A throw here means the agent never started. Let it out: pi marks a
2631
+ // tool call failed only when execute throws, and a returned message
2632
+ // reads to the model as a subagent that ran and reported this (#179).
2633
+ id = manager.spawn(pi, ctx, subagentType, params.prompt, {
2634
+ description: params.description,
2635
+ name: params.name as string | undefined,
2636
+ model,
2637
+ maxTurns: effectiveMaxTurns,
2638
+ isolated,
2639
+ inheritContext,
2640
+ thinkingLevel: thinking,
2641
+ isBackground: true,
2642
+ isolation,
2643
+ invocation: agentInvocation,
2644
+ rootSessionId: ctx.sessionManager.getSessionId(),
2645
+ ...bgCallbacks,
2646
+ })
1546
2647
 
1547
- if (details.status === "error") {
1548
- line +=
1549
- "\n" +
1550
- theme.fg("error", ` ⎿ Error: ${details.error ?? "unknown"}`)
1551
- } else {
1552
- line +=
1553
- "\n" + theme.fg("warning", " ⎿ Aborted (max turns exceeded)")
2648
+ // Set output file + join mode synchronously after spawn, before the
2649
+ // event loop yields — onSessionCreated is async so this is safe.
2650
+ const joinMode = resolveJoinMode(defaultJoinMode, true)
2651
+ const record = manager.getRecord(id)
2652
+ if (record && joinMode) {
2653
+ record.joinMode = joinMode
2654
+ record.toolCallId = toolCallId
2655
+ attachTranscript(record, id)
1554
2656
  }
1555
2657
 
1556
- return new Text(line, 0, 0)
1557
- },
2658
+ // With isolation: "worktree" the agent isn't running yet — the repo
2659
+ // copy is an awaited git call. Wait for it here, after the synchronous
2660
+ // wiring above, so a strict-isolation failure still fails THIS tool
2661
+ // call instead of being reported as a subagent that ran (#179).
2662
+ await manager.awaitStartup(id)
1558
2663
 
1559
- // ---- Execute ----
2664
+ if (joinMode == null || joinMode === "async") {
2665
+ // Foreground/no join mode or explicit async — not part of any batch
2666
+ } else {
2667
+ // smart or group — add to current batch
2668
+ currentBatchAgents.push({ id, joinMode })
2669
+ // Debounce: reset timer on each new agent so parallel tool calls
2670
+ // dispatched across multiple event loop ticks are captured together
2671
+ if (batchFinalizeTimer) clearTimeout(batchFinalizeTimer)
2672
+ batchFinalizeTimer = setTimeout(finalizeBatch, 100)
2673
+ }
1560
2674
 
1561
- execute: async (toolCallId, params, signal, onUpdate, ctx) => {
1562
- // Ensure we have UI context for widget rendering
1563
- widget.setUICtx(ctx.ui as UICtx)
2675
+ agentActivity.set(id, bgState)
2676
+ widget.ensureTimer()
2677
+ widget.update()
2678
+ fleet.ensureTimer()
2679
+ fleet.update()
1564
2680
 
1565
- // Reload custom agents so new project/global .md files are picked up without restart
1566
- reloadCustomAgents()
2681
+ // Emit created event
2682
+ pi.events.emit("subagents:created", {
2683
+ id,
2684
+ type: subagentType,
2685
+ description: params.description,
2686
+ isBackground: true,
2687
+ })
1567
2688
 
1568
- const rawType = params.subagent_type as SubagentType
1569
- // Single decision point for dispatch (#183): unknown, disabled and
1570
- // case-ambiguous types are refused here, BEFORE anything spawns, so a
1571
- // background or scheduled call can't start running the wrong agent while
1572
- // the caller is still unaware. `fallbackSubagent` decides whether an
1573
- // unresolvable type falls back or fails closed.
1574
- const dispatch = resolveSpawnType(rawType)
1575
- // `resume` replays a stored session and ignores `subagent_type` entirely,
1576
- // but the parameter is required by the schema — so gating it here would
1577
- // make a live agent unresumable the moment its type is deleted, disabled,
1578
- // or gains a case-clashing sibling. Only a real spawn is gated.
1579
- if (!dispatch.ok && !params.resume) return textResult(dispatch.message)
1580
- const subagentType = dispatch.ok ? dispatch.type : rawType
1581
- // What the caller actually asked for, named once: `fellBackFrom` is "" for
1582
- // a blank request, so reading it inline invites the `??`-vs-`||` slip that
1583
- // once persisted an empty type into a scheduled job.
1584
- const requestedType =
1585
- (dispatch.ok && dispatch.fellBackFrom) || subagentType
1586
- // Computed at resolution rather than after the run, so the background and
1587
- // schedule branches carry it too — previously it existed only on the
1588
- // foreground path. Resume deliberately doesn't: it replays the stored
1589
- // session and ignores `subagent_type` entirely, so a note about type
1590
- // substitution would be describing something that didn't happen.
1591
- const fallbackNote =
1592
- dispatch.ok && dispatch.fellBackFrom !== undefined
1593
- ? `Note: Unknown agent type "${dispatch.fellBackFrom}" — using ${resolveType(subagentType) ? subagentType : "the fallback agent config"}.\n\n`
1594
- : ""
1595
-
1596
- const displayName = getDisplayName(subagentType)
1597
-
1598
- // Get agent config (if any)
1599
- const customConfig = getAgentConfig(subagentType)
1600
-
1601
- const resolvedConfig = resolveAgentInvocationConfig(
1602
- customConfig,
1603
- params,
2689
+ const isQueued = record?.status === "queued"
2690
+ return textResult(
2691
+ `${fallbackNote}Agent ${isQueued ? "queued" : "started"} in background.\n` +
2692
+ `Agent ID: ${id}\n` +
2693
+ `Type: ${displayName}\n` +
2694
+ `Description: ${params.description}\n` +
2695
+ (record?.outputFile ? `Output file: ${record.outputFile}\n` : "") +
2696
+ (isQueued
2697
+ ? `Position: queued (max ${manager.getMaxConcurrent()} concurrent)\n`
2698
+ : "") +
2699
+ `\nYou will be notified when this agent completes.\n` +
2700
+ `Use get_subagent_result to retrieve full results, or steer_subagent to send it messages.\n` +
2701
+ `Do not duplicate this agent's work.`,
1604
2702
  {
1605
- worktreeAllowed: isWorktreeIsolationEnabled(),
1606
- defaultRunInBackground: getBackgroundByDefault(),
2703
+ ...detailBaseFor(record),
2704
+ toolUses: 0,
2705
+ tokens: "",
2706
+ durationMs: 0,
2707
+ status: "background" as const,
2708
+ agentId: id,
1607
2709
  },
1608
2710
  )
2711
+ }
1609
2712
 
1610
- // Resolve model from agent config first; tool-call params only fill gaps.
1611
- let model = ctx.model
1612
- if (resolvedConfig.modelInput) {
1613
- const resolved = resolveModel(
1614
- resolvedConfig.modelInput,
1615
- ctx.modelRegistry,
1616
- )
1617
- if (typeof resolved === "string") {
1618
- if (resolvedConfig.modelFromParams) return textResult(resolved)
1619
- // config-specified: silent fallback to parent
1620
- } else {
1621
- model = resolved
1622
- }
2713
+ // Foreground (synchronous) execution — stream progress via onUpdate
2714
+ let spinnerFrame = 0
2715
+ const startedAt = Date.now()
2716
+ let fgId: string | undefined
2717
+ // Set only while the spawn is parked on a foreground concurrency slot
2718
+ // (maxConcurrentForeground); undefined the rest of the time, including
2719
+ // always when the limit is unset.
2720
+ let queuedAhead: number | undefined
2721
+
2722
+ const streamUpdate = () => {
2723
+ // Spend from the record, everything else from the live tracker. `fgId`
2724
+ // is set in onSessionCreated below, which fires before the first
2725
+ // assistant message — so nothing is spent while this reads zero.
2726
+ const fgRecord = fgId ? manager.getRecord(fgId) : undefined
2727
+ const details: AgentDetails = {
2728
+ ...detailBaseFor(fgRecord),
2729
+ toolUses: fgState.toolUses,
2730
+ tokens: fgRecord ? formatLifetimeTokens(fgRecord) : "",
2731
+ cost: fgRecord ? getLifetimeCost(fgRecord.lifetimeUsage) : 0,
2732
+ turnCount: fgState.turnCount,
2733
+ maxTurns: fgState.maxTurns,
2734
+ durationMs: Date.now() - startedAt,
2735
+ // Deliberately still "running" while queued: the renderer routes any
2736
+ // status it doesn't know to raw text (see the catch-all below), which
2737
+ // would drop the spinner and read as hung. Only the activity line
2738
+ // changes — "thinking…" would be a lie for an agent that has not
2739
+ // started and may not for minutes.
2740
+ status: "running",
2741
+ activity:
2742
+ queuedAhead === undefined
2743
+ ? describeActivity(fgState.activeTools, fgState.responseText)
2744
+ : `queued — waiting for a foreground slot${queuedAhead > 0 ? ` (${queuedAhead} ahead)` : ""}`,
2745
+ spinnerFrame: spinnerFrame % SPINNER.length,
1623
2746
  }
1624
-
1625
- // Scope validation: the effective resolved model is checked against the
1626
- // user's enabledModels list. Policy (hard error vs warn-and-proceed) lives
1627
- // in model-scope.ts so the nested delegation tools apply the same rule.
1628
- const scopeVerdict = checkModelScope({
1629
- model,
1630
- cwd: ctx.cwd,
1631
- modelRegistry: ctx.modelRegistry,
1632
- callerSupplied: resolvedConfig.modelFromParams,
1633
- agentLabel: customConfig?.displayName ?? subagentType,
1634
- modelInput: resolvedConfig.modelInput,
2747
+ onUpdate?.({
2748
+ content: [{ type: "text", text: `${fgState.toolUses} tool uses...` }],
2749
+ details: details as any,
1635
2750
  })
1636
- if (scopeVerdict.kind === "error")
1637
- return textResult(scopeVerdict.message)
1638
- if (scopeVerdict.kind === "warn")
1639
- ctx.ui.notify(scopeVerdict.message, "warning")
1640
-
1641
- const thinking = resolvedConfig.thinking
1642
- const inheritContext = resolvedConfig.inheritContext
1643
- const runInBackground = resolvedConfig.runInBackground
1644
- const isolated = resolvedConfig.isolated
1645
- const isolation = resolvedConfig.isolation
1646
- // Whether this spawn writes its .output transcript. Per-agent
1647
- // frontmatter (`output_transcript`) wins; otherwise the project/global
1648
- // default applies. `attachTranscript` below is the SOLE gate — every
1649
- // downstream consumer keys off record.outputFile being set, so no spawn
1650
- // path can re-enable the transcript by accident.
1651
- const outputTranscript =
1652
- customConfig?.outputTranscript ?? getOutputTranscriptDefault()
1653
- const attachTranscript = (
1654
- rec: AgentRecord | undefined,
1655
- agentId: string,
1656
- ): void => {
1657
- if (!rec || !outputTranscript) return
1658
- rec.outputFile = createOutputFilePath(
1659
- ctx.cwd,
1660
- agentId,
1661
- ctx.sessionManager.getSessionId(),
1662
- )
1663
- writeInitialEntry(rec.outputFile, agentId, params.prompt, ctx.cwd)
1664
- }
2751
+ }
1665
2752
 
1666
- const { modelName, modelId } = model
1667
- ? describeModel(model)
1668
- : { modelName: undefined, modelId: undefined }
1669
- const askedModel = ((asked: string | undefined) => {
1670
- if (!asked) return undefined
1671
- const resolvedAsked = resolveModel(asked, ctx.modelRegistry)
1672
- if (typeof resolvedAsked === "string") return asked
1673
- return resolvedAsked.provider === model?.provider &&
1674
- resolvedAsked.id === model?.id
1675
- ? undefined
1676
- : asked
1677
- })(resolvedConfig.overridden?.model)
1678
- const effectiveMaxTurns = normalizeMaxTurns(
1679
- resolvedConfig.maxTurns ?? getDefaultMaxTurns(),
1680
- )
1681
- const agentInvocation: AgentInvocation = {
1682
- modelName,
1683
- modelId,
1684
- thinking,
1685
- requestedThinking: resolvedConfig.overridden?.thinking,
1686
- requestedModel: askedModel,
1687
- // Explicit value only — the default fallback would just add noise.
1688
- // Normalize so `0` (unlimited) doesn't surface as a misleading "max turns: 0".
1689
- maxTurns: normalizeMaxTurns(resolvedConfig.maxTurns),
1690
- isolated,
1691
- inheritContext,
1692
- runInBackground,
1693
- isolation,
1694
- }
1695
- // Tool-result render shows the mode label too; viewer's header already does.
1696
- const modeLabel = getPromptModeLabel(subagentType)
1697
- const { tags: invocationTags } = buildInvocationTags(agentInvocation)
1698
- const agentTags = modeLabel
1699
- ? [modeLabel, ...invocationTags]
1700
- : invocationTags
1701
- const detailBase = {
1702
- displayName,
1703
- description: params.description,
1704
- subagentType,
1705
- modelName,
1706
- tags: agentTags.length > 0 ? agentTags : undefined,
2753
+ const { state: fgState, callbacks: fgCallbacks } = createActivityTracker(
2754
+ effectiveMaxTurns,
2755
+ streamUpdate,
2756
+ )
2757
+
2758
+ // Wire session creation: register in widget + stream to output file.
2759
+ // The output file path is set synchronously after spawn (below),
2760
+ // before onSessionCreated fires — same pattern as background agents.
2761
+ const origOnSession = fgCallbacks.onSessionCreated
2762
+ fgCallbacks.onSessionCreated = (session: any) => {
2763
+ origOnSession(session)
2764
+ // It really started — stop reporting it as queued, and repaint now
2765
+ // rather than leaving the stale line up for the next spinner tick.
2766
+ // Guarded, so a spawn that never queued emits no extra update.
2767
+ if (queuedAhead !== undefined) {
2768
+ queuedAhead = undefined
2769
+ streamUpdate()
1707
2770
  }
1708
- const detailBaseFor = (
1709
- rec: AgentRecord | undefined,
1710
- ): typeof detailBase => {
1711
- if (!rec?.invocation) return detailBase
1712
- const { modelName: recModelName, tags } = buildInvocationTags(
1713
- rec.invocation,
1714
- )
1715
- const recModeLabel = getPromptModeLabel(rec.type)
1716
- const recTags = recModeLabel ? [recModeLabel, ...tags] : tags
1717
- return {
1718
- displayName: getDisplayName(rec.type),
1719
- description: rec.description,
1720
- subagentType: rec.type,
1721
- modelName: recModelName,
1722
- tags: recTags.length > 0 ? recTags : undefined,
2771
+ for (const a of manager.listAgents()) {
2772
+ if (a.session === session) {
2773
+ fgId = a.id
2774
+ agentActivity.set(a.id, fgState)
2775
+ widget.ensureTimer()
2776
+ fleet.ensureTimer()
2777
+ fleet.update()
2778
+ break
1723
2779
  }
1724
2780
  }
1725
-
1726
- // ---- Schedule: register a job, don't spawn now ----
1727
- if (params.schedule) {
1728
- if (!isSchedulingEnabled()) {
1729
- return textResult(
1730
- "Scheduling is disabled in this project. Enable via /agents → Settings → Scheduling.",
1731
- )
1732
- }
1733
- if (params.resume) {
1734
- return textResult(
1735
- "Cannot combine `schedule` with `resume` — schedules create fresh agents.",
1736
- )
1737
- }
1738
- if (params.inherit_context) {
1739
- return textResult(
1740
- "Cannot combine `schedule` with `inherit_context` — there is no parent conversation at fire time.",
1741
- )
1742
- }
1743
- if (params.run_in_background === false) {
1744
- return textResult(
1745
- "Cannot combine `schedule` with `run_in_background: false` — scheduled jobs always run in background.",
1746
- )
1747
- }
1748
- if (!scheduler.isActive()) {
1749
- return textResult(
1750
- "Scheduler is not active in this session yet. Try again after the session has fully started.",
2781
+ // Stream conversation to output file (foreground agent logging)
2782
+ if (fgId) {
2783
+ const rec = manager.getRecord(fgId)
2784
+ if (rec?.outputFile) {
2785
+ rec.outputCleanup = streamToOutputFile(
2786
+ session,
2787
+ rec.outputFile,
2788
+ fgId,
2789
+ ctx.cwd,
1751
2790
  )
1752
2791
  }
1753
- try {
1754
- const job = scheduler.addJob({
1755
- name: params.description as string,
1756
- description: params.description as string,
1757
- schedule: params.schedule as string,
1758
- // The caller's own name, not the substitute — the scheduler re-resolves
1759
- // at fire time, and the original is what a user edits.
1760
- subagent_type: requestedType,
1761
- prompt: params.prompt as string,
1762
- model: params.model as string | undefined,
1763
- thinking: thinking,
1764
- max_turns: effectiveMaxTurns,
1765
- isolated: isolated,
1766
- isolation: isolation,
1767
- })
1768
- const next = scheduler.getNextRun(job.id)
1769
- return textResult(
1770
- `${fallbackNote}Scheduled "${job.name}" (id: ${job.id}, type: ${job.scheduleType}). ` +
1771
- `Next run: ${next ?? "(unknown)"}. ` +
1772
- `Manage via /agents → Scheduled jobs.`,
1773
- )
1774
- } catch (err) {
1775
- return textResult(err instanceof Error ? err.message : String(err))
1776
- }
1777
2792
  }
2793
+ }
1778
2794
 
1779
- // Resume existing agent
1780
- if (params.resume) {
1781
- const existing = manager.getRecord(params.resume)
1782
- if (!existing || existing.parentAgentId) {
1783
- return textResult(
1784
- `Agent not found: "${params.resume}". It may have been cleaned up.`,
1785
- )
1786
- }
1787
- if (!existing.session) {
1788
- return textResult(
1789
- `Agent "${params.resume}" has no active session to resume.`,
1790
- )
1791
- }
2795
+ // Animate spinner at ~80ms (smooth rotation through 10 braille frames)
2796
+ const spinnerInterval = setInterval(() => {
2797
+ spinnerFrame++
2798
+ streamUpdate()
2799
+ }, 80)
1792
2800
 
1793
- // Background resume: detached run that notifies on completion, mirroring
1794
- // a background spawn. Previously run_in_background was silently ignored
1795
- // on resume (this branch returned before the background branch below),
1796
- // so a resumed agent always blocked the main loop until it finished.
1797
- if (runInBackground) {
1798
- const id = existing.id
1799
- // A detached resume hands control back while the record stays
1800
- // "running", so nothing stops the model from resuming the same agent
1801
- // again mid-run. manager.resume() refuses that (it would orphan the
1802
- // live run's abort controller); say why here, where the model can act
1803
- // on it, instead of letting it read as a generic failure.
1804
- if (existing.status === "running" || existing.status === "queued") {
1805
- return textResult(
1806
- `Agent "${params.resume}" is still ${existing.status} — it can only be resumed once its current run finishes.\n` +
1807
- `Use steer_subagent to send it a message mid-run, or get_subagent_result to wait for it.`,
1808
- )
1809
- }
2801
+ streamUpdate()
1810
2802
 
1811
- const joinMode = resolveJoinMode(defaultJoinMode, true)
1812
- existing.toolCallId = toolCallId
1813
- if (joinMode) existing.joinMode = joinMode
1814
- // Reuse the agent's transcript rather than starting a fresh one: the
1815
- // path is deterministic per agent+session, so writing an initial entry
1816
- // would truncate the previous run's turns (see ensureOutputFile).
1817
- if (existing.outputFile) {
1818
- // Preserve the original transcript path across parent session
1819
- // switches; records intentionally survive those switches for
1820
- // resume. Only create a path for an older run that never wrote
1821
- // one and is now being resumed with transcripts enabled.
1822
- ensureOutputFile(existing.outputFile)
1823
- } else if (outputTranscript) {
1824
- existing.outputFile = createOutputFilePath(
1825
- ctx.cwd,
1826
- id,
1827
- ctx.sessionManager.getSessionId(),
1828
- )
1829
- ensureOutputFile(existing.outputFile)
1830
- }
1831
- // Anchor streaming past the turns already on disk, captured BEFORE the
1832
- // run starts. The resumed prompt lands as an ordinary user message at
1833
- // this index, so it is written exactly once.
1834
- const transcriptAnchor = existing.session.messages.length
1835
-
1836
- const { state: bgState, callbacks: bgCallbacks } =
1837
- createActivityTracker(effectiveMaxTurns)
1838
- // resumeAgent has no onSessionCreated — the session predates this run —
1839
- // so seed it directly, or the widget shows no context % for the agent.
1840
- bgState.session = existing.session
1841
-
1842
- // No `signal`: a background spawn deliberately omits it, and a detached
1843
- // resume must behave the same. Passing it would abort this agent when
1844
- // the parent turn is interrupted (user Esc), while agents started with
1845
- // run_in_background in that same turn keep going.
1846
- const record = await manager.resume(
1847
- params.resume,
1848
- params.prompt,
1849
- undefined,
1850
- {
1851
- isBackground: true,
1852
- onToolActivity: bgCallbacks.onToolActivity,
1853
- onTurnEnd: bgCallbacks.onTurnEnd,
1854
- onAssistantUsage: bgCallbacks.onAssistantUsage,
1855
- // Fires when the run actually starts — immediately, or on queue
1856
- // drain. Wiring it here (rather than after resume() returns) means a
1857
- // resume stopped while still queued never started streaming, so
1858
- // there is no subscription left behind for a later run to trip over.
1859
- onStarted: () => {
1860
- const rec = manager.getRecord(id)
1861
- if (rec?.session && rec.outputFile) {
1862
- rec.outputCleanup = streamToOutputFile(
1863
- rec.session,
1864
- rec.outputFile,
1865
- id,
1866
- ctx.cwd,
1867
- transcriptAnchor,
1868
- )
1869
- }
1870
- },
1871
- },
1872
- )
1873
- if (!record) {
1874
- return textResult(`Failed to resume agent "${params.resume}".`)
1875
- }
2803
+ let record: AgentRecord
2804
+ try {
2805
+ const fgResult = await manager.spawnAndWait(
2806
+ pi,
2807
+ ctx,
2808
+ subagentType,
2809
+ params.prompt,
2810
+ {
2811
+ description: params.description,
2812
+ name: params.name as string | undefined,
2813
+ model,
2814
+ maxTurns: effectiveMaxTurns,
2815
+ isolated,
2816
+ inheritContext,
2817
+ thinkingLevel: thinking,
2818
+ isolation,
2819
+ invocation: agentInvocation,
2820
+ signal,
2821
+ rootSessionId: ctx.sessionManager.getSessionId(),
2822
+ // Deliberately does NOT set fgId: that drives agentActivity, the
2823
+ // widget and the `finally` cleanup below, none of which should see an
2824
+ // agent that has no session and may never get one.
2825
+ onQueued: (_id, ahead) => {
2826
+ queuedAhead = ahead
2827
+ streamUpdate()
2828
+ },
2829
+ ...fgCallbacks,
2830
+ },
2831
+ (fgAgentId) => {
2832
+ // onSpawned: called synchronously after spawn, before onSessionCreated fires.
2833
+ // Set up the output file so streamToOutputFile can pick it up.
2834
+ const fgRec = manager.getRecord(fgAgentId)
2835
+ attachTranscript(fgRec, fgAgentId)
2836
+ },
2837
+ )
2838
+ record = fgResult.record
2839
+ } finally {
2840
+ // Runs on both paths, so a startup throw — which now propagates, see
2841
+ // the background spawn above (#179) — no longer leaves the spinner
2842
+ // ticking or a finished agent on the widget.
2843
+ clearInterval(spinnerInterval)
2844
+ if (fgId) {
2845
+ agentActivity.delete(fgId)
2846
+ widget.markFinished(fgId)
2847
+ fleet.onAgentFinished(fgId)
2848
+ }
2849
+ }
1876
2850
 
1877
- if (joinMode != null && joinMode !== "async") {
1878
- currentBatchAgents.push({ id, joinMode })
1879
- if (batchFinalizeTimer) clearTimeout(batchFinalizeTimer)
1880
- batchFinalizeTimer = setTimeout(finalizeBatch, 100)
1881
- }
2851
+ // Get final token count — from the record, like the cost below it, so the
2852
+ // two describe the same work when the agent delegated to nested children.
2853
+ const tokenText = formatLifetimeTokens(record)
2854
+
2855
+ const details = buildDetails(detailBaseFor(record), record, fgState, {
2856
+ tokens: tokenText,
2857
+ })
2858
+
2859
+ if (record.status === "error") {
2860
+ // Error headline + any partial output the run produced before failing.
2861
+ return textResult(
2862
+ `${fallbackNote}Agent failed: ${record.error}${partialOutputSuffix(record)}`,
2863
+ details,
2864
+ )
2865
+ }
2866
+
2867
+ const durationMs = (record.completedAt ?? Date.now()) - record.startedAt
2868
+ const statsParts = [`${record.toolUses} tool uses`]
2869
+ if (tokenText) statsParts.push(tokenText)
2870
+ if (showCost) {
2871
+ const costText = formatCost(getLifetimeCost(record.lifetimeUsage))
2872
+ if (costText) statsParts.push(costText)
2873
+ }
2874
+ return textResult(
2875
+ `${fallbackNote}Agent completed in ${formatMs(durationMs)} (${statsParts.join(", ")})${getForegroundOutcomeNote(record.status)}.\n\n` +
2876
+ (record.result?.trim() || "No output."),
2877
+ details,
2878
+ )
2879
+ },
2880
+ })
2881
+ /**
2882
+ * Wrap a tool so its results carry back whatever subagent spend the parent
2883
+ * session has not been told about yet (see `PendingUsagePool`).
2884
+ *
2885
+ * Pi copies `AgentToolResult.usage` onto the persisted tool-result message and
2886
+ * folds it into `getSessionStats()`, which is what the footer, the statusline
2887
+ * and `/cost` read — so this is the whole of "report usage to the parent".
2888
+ *
2889
+ * Nothing is attached to a call with no tool-call id. That is the `@handle`
2890
+ * mention path (`mention-clone.ts`), which invokes this tool from a fork of the
2891
+ * conversation that is discarded moments later: the result never becomes a
2892
+ * message in the real session, so usage hung on it would be spend the user paid
2893
+ * for and nobody counted. Skipping leaves it pending for the next real result.
2894
+ */
2895
+ function withUsageReporting<T extends { execute: (...args: any[]) => any }>(
2896
+ tool: T,
2897
+ ): T {
2898
+ return {
2899
+ ...tool,
2900
+ execute: async (toolCallId: string | undefined, ...rest: any[]) => {
2901
+ const result = await tool.execute(toolCallId, ...rest)
2902
+ if (!reportUsage || !toolCallId) return result
2903
+ const usage = pendingUsage.drain()
2904
+ return usage ? { ...result, usage } : result
2905
+ },
2906
+ }
2907
+ }
2908
+ function registerToolReportingUsage(tool: any): void {
2909
+ pi.registerTool(withUsageReporting(tool))
2910
+ }
2911
+
2912
+ // The mention path is handed THIS object, not the bare `agentTool` — see the
2913
+ // mention-clone header on why the clone must call the registered tool.
2914
+ const registeredAgentTool = withUsageReporting(agentTool)
2915
+ pi.registerTool(registeredAgentTool)
2916
+
2917
+ // ---- Workflow tool ----
2918
+
2919
+ /**
2920
+ * Live runs, by task id. The tool returns before the run finishes, so its
2921
+ * result card looks the task up here on every render rather than freezing a
2922
+ * snapshot into `details` — that is what makes the inline card follow a
2923
+ * background run.
2924
+ */
2925
+ const workflowTasks = new Map<string, WorkflowTask>()
2926
+
2927
+ /**
2928
+ * Workflow runs as the fleet list wants them.
2929
+ *
2930
+ * Mapped here rather than handing `WorkflowTask` over the seam: the list is
2931
+ * deliberately ignorant of the workflow engine, and a run's counters live in
2932
+ * the progress log rather than on the record, so they are derived per call
2933
+ * the same way the card derives them.
2934
+ */
2935
+ function fleetWorkflows(): FleetWorkflow[] {
2936
+ // Cached counters only, no derivation: the fleet list calls this on a
2937
+ // 200ms tick and reads the roster several times per update, so walking a
2938
+ // run's progress log here would put O(log) work in the render loop.
2939
+ return [...workflowTasks.values()].map((task) => ({
2940
+ id: task.id,
2941
+ name: task.meta?.name ?? task.workflowName ?? task.id,
2942
+ status: task.status,
2943
+ doneCount: task.doneCount,
2944
+ totalCount: task.agentCount,
2945
+ startedAt: task.startTime,
2946
+ ...(task.endTime !== undefined ? { completedAt: task.endTime } : {}),
2947
+ tokens: task.totalTokens,
2948
+ }))
2949
+ }
2950
+
2951
+ /**
2952
+ * Run a task to completion against the real manager, settling the record
2953
+ * either way. Never rejects: a run that cannot start (bad `meta`, oversized
2954
+ * source, non-JSON `args`) is a failed workflow, and both callers here are
2955
+ * detached — a rejection would surface as an unhandled one.
2956
+ */
2957
+ async function runWorkflowTask(
2958
+ ctx: ExtensionContext,
2959
+ task: WorkflowTask,
2960
+ ): Promise<void> {
2961
+ try {
2962
+ const result = await runWorkflow({
2963
+ script: task.script,
2964
+ args: task.args,
2965
+ signal: task.abortController.signal,
2966
+ host: createWorkflowHost({
2967
+ pi,
2968
+ ctx,
2969
+ manager,
2970
+ signal: task.abortController.signal,
2971
+ rootSessionId: ctx.sessionManager.getSessionId(),
2972
+ workflowId: task.id,
2973
+ }),
2974
+ onProgress: (entries) => updateWorkflowProgressBatch(task, entries),
2975
+ // The dialog's pause / skip / retry keys run through this; it is dropped
2976
+ // again when the task settles.
2977
+ onControl: (control) => {
2978
+ task.control = control
2979
+ },
2980
+ journal: {
2981
+ ...(task.replay !== undefined ? { entries: task.replay } : {}),
2982
+ ...(task.journalPath !== undefined
2983
+ ? {
2984
+ append: (entry: WorkflowJournalEntry) =>
2985
+ appendJournal(task.journalPath!, entry),
2986
+ }
2987
+ : {}),
2988
+ },
2989
+ })
2990
+ completeWorkflowTask(task, result)
2991
+ } catch (err) {
2992
+ failWorkflowTask(task, err instanceof Error ? err.message : String(err))
2993
+ }
2994
+ }
1882
2995
 
1883
- agentActivity.set(id, bgState)
1884
- // This agent already finished once, so the widget holds a finished-age
1885
- // for it that is past the linger limit — without clearing it, the
1886
- // resumed run's ✓/✗ line never renders and the agent just vanishes.
1887
- widget.markRunning(id)
1888
- widget.ensureTimer()
1889
- widget.update()
1890
- fleet.ensureTimer()
1891
- fleet.update()
2996
+ /**
2997
+ * Hand a finished run back to the model through the SAME channel a background
2998
+ * agent uses — held briefly by `scheduleNudge`, delivered as a follow-up that
2999
+ * triggers a turn, rendered by the existing `subagent-notification` renderer.
3000
+ */
3001
+ function notifyWorkflowFinished(task: WorkflowTask) {
3002
+ widget.update()
3003
+ fleet.update()
3004
+ const result = workflowResultText(task)
3005
+ scheduleNudge(task.id, () => {
3006
+ pi.sendMessage<NotificationDetails>(
3007
+ {
3008
+ customType: "subagent-notification",
3009
+ content: formatWorkflowNotification(task),
3010
+ display: true,
3011
+ details: {
3012
+ id: task.id,
3013
+ description: `Workflow ${task.workflowName ?? task.id}`,
3014
+ status:
3015
+ task.status === "completed"
3016
+ ? "completed"
3017
+ : task.status === "killed"
3018
+ ? "stopped"
3019
+ : "error",
3020
+ toolUses: task.totalToolCalls,
3021
+ // A workflow has agents, not turns; rendering "↻0" would be noise.
3022
+ turnCount: 0,
3023
+ totalTokens: task.totalTokens,
3024
+ durationMs: elapsedMs(task, Date.now()),
3025
+ error: task.error,
3026
+ resultPreview:
3027
+ result.length > 500 ? `${result.slice(0, 500)}…` : result,
3028
+ },
3029
+ },
3030
+ { deliverAs: "followUp", triggerTurn: true },
3031
+ )
3032
+ })
3033
+ }
1892
3034
 
1893
- // Resume ignores subagent_type (the record keeps the type it was
1894
- // spawned with), so report the record's own identity — a "created"
1895
- // event carrying the caller's type would re-register the agent under
1896
- // the wrong one in cross-extension mirrors keyed by id.
1897
- pi.events.emit("subagents:created", {
1898
- id,
1899
- type: existing.type,
1900
- description: existing.description,
1901
- isBackground: true,
1902
- })
3035
+ // Defined unconditionally, registered only when the feature is on — the same
3036
+ // shape the Agent tool uses. Keeping the definition out of the `if` means the
3037
+ // switch changes exactly one thing: whether pi is ever told about the tool.
3038
+ const workflowTool = defineTool({
3039
+ name: SUBAGENT_TOOL_NAMES.WORKFLOW,
3040
+ label: "SubagentWorkflow",
3041
+ description: renderToolDescriptionTemplate(fullWorkflowToolDescription),
3042
+ promptSnippet:
3043
+ "Run a deterministic script that orchestrates many subagents",
3044
+ promptGuidelines: [
3045
+ "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.",
3046
+ "Prefer `pipeline` over `parallel` — a barrier costs wall-clock whenever the stages are unevenly sized.",
3047
+ "A workflow runs in the background and notifies you when it finishes — do not poll or sleep waiting for it.",
3048
+ ],
3049
+ parameters: Type.Object({
3050
+ script: Type.Optional(
3051
+ Type.String({
3052
+ maxLength: 524288,
3053
+ description:
3054
+ "Inline workflow source. Must begin with `export const meta = { name, description }`.",
3055
+ }),
3056
+ ),
3057
+ scriptPath: Type.Optional(
3058
+ Type.String({
3059
+ description:
3060
+ "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.",
3061
+ }),
3062
+ ),
3063
+ name: Type.Optional(
3064
+ Type.String({
3065
+ description:
3066
+ "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.",
3067
+ }),
3068
+ ),
3069
+ args: Type.Optional(
3070
+ Type.Any({
3071
+ description:
3072
+ "Exposed to the script as the global `args`, verbatim. Must be JSON-shaped.",
3073
+ }),
3074
+ ),
3075
+ resumeFromRunId: Type.Optional(
3076
+ Type.String({
3077
+ pattern: "^wf_[a-z0-9-]{6,}$",
3078
+ description:
3079
+ "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.",
3080
+ }),
3081
+ ),
3082
+ // Accepted and ignored, as in Claude Code. Models reach for them because
3083
+ // every other tool has them, and a hard schema rejection would cost a
3084
+ // whole turn to re-emit a script that was already correct. The `meta`
3085
+ // block is the one place a workflow is named.
3086
+ title: Type.Optional(
3087
+ Type.String({
3088
+ description:
3089
+ "Ignored — set the workflow title in the script's `meta` block.",
3090
+ }),
3091
+ ),
3092
+ description: Type.Optional(
3093
+ Type.String({
3094
+ description:
3095
+ "Ignored — set the workflow description in the script's `meta` block.",
3096
+ }),
3097
+ ),
3098
+ }),
1903
3099
 
1904
- const isQueued = record.status === "queued"
1905
- return textResult(
1906
- `Agent ${isQueued ? "queued" : "resumed"} in background.\n` +
1907
- `Agent ID: ${id}\n` +
1908
- `Type: ${existing.type}\n` +
1909
- (record.outputFile
1910
- ? `Output file: ${record.outputFile}\n`
1911
- : "") +
1912
- (isQueued
1913
- ? `Position: queued (max ${manager.getMaxConcurrent()} concurrent)\n`
1914
- : "") +
1915
- `\nYou will be notified when this agent completes.\n` +
1916
- `Use get_subagent_result to retrieve full results, or steer_subagent to send it messages.`,
1917
- {
1918
- ...detailBaseFor(record),
1919
- toolUses: record.toolUses,
1920
- tokens: "",
1921
- durationMs: 0,
1922
- status: "background" as const,
1923
- agentId: id,
1924
- },
1925
- )
1926
- }
3100
+ renderCall(args, theme) {
3101
+ return new Text(
3102
+ `${theme.fg("toolTitle", "▸ ")}${theme.bold(theme.fg("toolTitle", "SubagentWorkflow"))} ${theme.fg("muted", workflowCallName(args))}`,
3103
+ 0,
3104
+ 0,
3105
+ )
3106
+ },
1927
3107
 
1928
- const record = await manager.resume(
1929
- params.resume,
1930
- params.prompt,
1931
- signal,
1932
- )
1933
- if (!record) {
1934
- return textResult(`Failed to resume agent "${params.resume}".`)
1935
- }
1936
- // A failed resume surfaces the error, plus any partial output THIS
1937
- // resume produced (never the previous turn's answer, #144).
1938
- if (record.status === "error") {
1939
- return textResult(
1940
- `Agent failed: ${record.error}${partialOutputSuffix(record)}`,
1941
- buildDetails(detailBaseFor(record), record),
1942
- )
1943
- }
1944
- return textResult(
1945
- record.result?.trim() || "No output.",
1946
- buildDetails(detailBaseFor(record), record),
1947
- )
1948
- }
3108
+ renderResult(result, _options, theme, renderContext) {
3109
+ const text =
3110
+ result.content[0]?.type === "text" ? result.content[0].text : ""
3111
+ const taskId = (result.details as { taskId?: string } | undefined)?.taskId
3112
+ const task = taskId !== undefined ? workflowTasks.get(taskId) : undefined
3113
+ // No task means the run predates this session (a reloaded transcript) or
3114
+ // the call never started one — show what `execute` said instead.
3115
+ if (renderContext.isError || !task) return new Text(text, 0, 0)
3116
+ return renderWorkflowCard(
3117
+ {
3118
+ progress: task.workflowProgress,
3119
+ task: {
3120
+ status: task.status,
3121
+ workflowName: task.workflowName,
3122
+ startTime: task.startTime,
3123
+ endTime: task.endTime,
3124
+ totalPausedMs: task.totalPausedMs,
3125
+ },
3126
+ meta: task.meta,
3127
+ agentCount: task.agentCount,
3128
+ totalTokens: task.totalTokens,
3129
+ },
3130
+ theme,
3131
+ )
3132
+ },
1949
3133
 
1950
- // Background execution
1951
- if (runInBackground) {
1952
- const { state: bgState, callbacks: bgCallbacks } =
1953
- createActivityTracker(effectiveMaxTurns)
1954
-
1955
- // Wrap onSessionCreated to wire output file streaming.
1956
- // The callback lazily reads record.outputFile (set right after spawn)
1957
- // rather than closing over a value that doesn't exist yet.
1958
- let id: string
1959
- const origBgOnSession = bgCallbacks.onSessionCreated
1960
- bgCallbacks.onSessionCreated = (session: any) => {
1961
- origBgOnSession(session)
1962
- const rec = manager.getRecord(id)
1963
- if (rec?.outputFile) {
1964
- rec.outputCleanup = streamToOutputFile(
1965
- session,
1966
- rec.outputFile,
1967
- id,
1968
- ctx.cwd,
1969
- )
1970
- }
1971
- }
3134
+ execute: async (toolCallId, params, _signal, _onUpdate, ctx) => {
3135
+ const resumeFrom = resolveResumeTarget(
3136
+ params.resumeFromRunId,
3137
+ workflowTasks,
3138
+ )
3139
+ if (resumeFrom !== undefined && !resumeFrom.ok)
3140
+ return textResult(resumeFrom.message)
3141
+
3142
+ // A resume with no source of its own re-runs what that run ran. The
3143
+ // common case is an edited script, but "run that again, cheaply" should
3144
+ // not require repeating a path the run already knows.
3145
+ const resolved = resolveWorkflowScript(
3146
+ params.script === undefined &&
3147
+ params.scriptPath === undefined &&
3148
+ params.name === undefined &&
3149
+ resumeFrom !== undefined
3150
+ ? { scriptPath: resumeFrom.scriptPath }
3151
+ : params,
3152
+ ctx.cwd,
3153
+ )
3154
+ if (!resolved.ok) return textResult(resolved.message)
1972
3155
 
1973
- // A throw here means the agent never started. Let it out: Pi marks a
1974
- // tool call failed only when execute throws, while a returned message
1975
- // reads to the model as a subagent that ran and reported this.
1976
- id = manager.spawn(pi, ctx, subagentType, params.prompt, {
1977
- description: params.description,
1978
- model,
1979
- maxTurns: effectiveMaxTurns,
1980
- isolated,
1981
- inheritContext,
1982
- thinkingLevel: thinking,
1983
- isBackground: true,
1984
- isolation,
1985
- invocation: agentInvocation,
1986
- rootSessionId: ctx.sessionManager.getSessionId(),
1987
- ...bgCallbacks,
1988
- })
3156
+ // Parsed before anything is scheduled: a bad `meta` is an authoring error
3157
+ // the model can fix immediately, and reporting it as a background run
3158
+ // that failed a second later would just cost a turn.
3159
+ let meta: WorkflowMeta
3160
+ try {
3161
+ meta = extractMeta(resolved.script).meta
3162
+ } catch (err) {
3163
+ return textResult(err instanceof Error ? err.message : String(err))
3164
+ }
1989
3165
 
1990
- // Set output file + join mode synchronously after spawn, before the
1991
- // event loop yields — onSessionCreated is async so this is safe.
1992
- const joinMode = resolveJoinMode(defaultJoinMode, true)
1993
- const record = manager.getRecord(id)
1994
- if (record && joinMode) {
1995
- record.joinMode = joinMode
1996
- record.toolCallId = toolCallId
1997
- attachTranscript(record, id)
1998
- }
3166
+ const runId = workflowRunId()
3167
+ // Every invocation lands on disk next to the agent transcripts, so
3168
+ // iterating is edit-the-file-then-rerun-with-scriptPath rather than
3169
+ // re-emitting the whole source. The journal sits beside it under the same
3170
+ // id, which is what makes a run id enough to resume from.
3171
+ let savedPath: string | undefined
3172
+ let journalPath: string | undefined
3173
+ try {
3174
+ const dir = sessionTaskDir(ctx.cwd, ctx.sessionManager.getSessionId())
3175
+ savedPath = join(dir, `${runId}.workflow.js`)
3176
+ writeFileSync(savedPath, resolved.script, "utf-8")
3177
+ journalPath = join(dir, `${runId}.workflow.jsonl`)
3178
+ } catch (err) {
3179
+ savedPath = undefined
3180
+ journalPath = undefined
3181
+ console.warn(
3182
+ `[pi-subagents] could not persist workflow script: ${err instanceof Error ? err.message : String(err)}`,
3183
+ )
3184
+ }
1999
3185
 
2000
- if (joinMode == null || joinMode === "async") {
2001
- // Foreground/no join mode or explicit async — not part of any batch
2002
- } else {
2003
- // smart or group — add to current batch
2004
- currentBatchAgents.push({ id, joinMode })
2005
- // Debounce: reset timer on each new agent so parallel tool calls
2006
- // dispatched across multiple event loop ticks are captured together
2007
- if (batchFinalizeTimer) clearTimeout(batchFinalizeTimer)
2008
- batchFinalizeTimer = setTimeout(finalizeBatch, 100)
2009
- }
3186
+ const replay =
3187
+ resumeFrom !== undefined
3188
+ ? readJournal(resumeFrom.journalPath)
3189
+ : undefined
3190
+
3191
+ const task = createWorkflowTask({
3192
+ id: runId,
3193
+ script: resolved.script,
3194
+ scriptPath: resolved.scriptPath ?? savedPath,
3195
+ args: params.args,
3196
+ meta,
3197
+ toolCallId,
3198
+ ...(journalPath !== undefined ? { journalPath } : {}),
3199
+ ...(replay !== undefined && replay.length > 0
3200
+ ? { replay, resumedFrom: resumeFrom!.runId }
3201
+ : {}),
3202
+ })
3203
+ workflowTasks.set(runId, task)
3204
+ // The run's own row has to appear now, not when it settles. Its agents
3205
+ // are owned by it, so their lifecycle callbacks no longer refresh these
3206
+ // surfaces — nothing else would register the widget for a run whose
3207
+ // first agent has not started yet.
3208
+ widget.update()
3209
+ fleet.update()
2010
3210
 
2011
- agentActivity.set(id, bgState)
2012
- widget.ensureTimer()
2013
- widget.update()
2014
- fleet.ensureTimer()
2015
- fleet.update()
3211
+ // Background, like Claude Code: the id comes back now and the run keeps
3212
+ // going without the tool call.
3213
+ void runWorkflowTask(ctx, task).then(() => notifyWorkflowFinished(task))
2016
3214
 
2017
- // Emit created event
2018
- pi.events.emit("subagents:created", {
2019
- id,
2020
- type: subagentType,
2021
- description: params.description,
2022
- isBackground: true,
2023
- })
3215
+ return {
3216
+ content: [
3217
+ {
3218
+ type: "text" as const,
3219
+ text:
3220
+ `Workflow "${meta.name}" started in the background.\n` +
3221
+ `Task ID: ${runId}\n` +
3222
+ (task.scriptPath ? `Script: ${task.scriptPath}\n` : "") +
3223
+ (task.resumedFrom !== undefined
3224
+ ? `Resuming ${task.resumedFrom}: ${task.replay?.length ?? 0} recorded call(s) available to replay.\n`
3225
+ : params.resumeFromRunId !== undefined
3226
+ ? `Nothing to replay from ${params.resumeFromRunId} — every agent runs live.\n`
3227
+ : "") +
3228
+ `\nYou will be notified when it finishes — do NOT poll or sleep waiting for it.\n` +
3229
+ `To iterate, edit the script file and call SubagentWorkflow again with scriptPath.`,
3230
+ },
3231
+ ],
3232
+ details: { taskId: runId },
3233
+ }
3234
+ },
3235
+ })
2024
3236
 
2025
- const isQueued = record?.status === "queued"
2026
- return textResult(
2027
- `${fallbackNote}Agent ${isQueued ? "queued" : "started"} in background.\n` +
2028
- `Agent ID: ${id}\n` +
2029
- `Type: ${displayName}\n` +
2030
- `Description: ${params.description}\n` +
2031
- (record?.outputFile
2032
- ? `Output file: ${record.outputFile}\n`
2033
- : "") +
2034
- (isQueued
2035
- ? `Position: queued (max ${manager.getMaxConcurrent()} concurrent)\n`
2036
- : "") +
2037
- `\nYou will be notified when this agent completes.\n` +
2038
- `Use get_subagent_result to retrieve full results, or steer_subagent to send it messages.\n` +
2039
- `Do not duplicate this agent's work.`,
2040
- {
2041
- ...detailBaseFor(record),
2042
- toolUses: 0,
2043
- tokens: "",
2044
- durationMs: 0,
2045
- status: "background" as const,
2046
- agentId: id,
2047
- },
2048
- )
2049
- }
3237
+ if (isWorkflowsEnabled()) pi.registerTool(workflowTool)
2050
3238
 
2051
- // Foreground (synchronous) execution — stream progress via onUpdate
2052
- let spinnerFrame = 0
2053
- const startedAt = Date.now()
2054
- let fgId: string | undefined
2055
- let queuedAhead: number | undefined
2056
-
2057
- const streamUpdate = () => {
2058
- const details: AgentDetails = {
2059
- ...detailBaseFor(fgId ? manager.getRecord(fgId) : undefined),
2060
- toolUses: fgState.toolUses,
2061
- tokens: fgId ? formatLifetimeTokens(manager.getRecord(fgId)!) : "",
2062
- cost: fgId
2063
- ? getLifetimeCost(manager.getRecord(fgId)?.lifetimeUsage)
2064
- : 0,
2065
- turnCount: fgState.turnCount,
2066
- maxTurns: fgState.maxTurns,
2067
- durationMs: Date.now() - startedAt,
2068
- status: "running",
2069
- activity:
2070
- queuedAhead === undefined
2071
- ? describeActivity(fgState.activeTools, fgState.responseText)
2072
- : `queued — waiting for a foreground slot${queuedAhead > 0 ? ` (${queuedAhead} ahead)` : ""}`,
2073
- spinnerFrame: spinnerFrame % SPINNER.length,
2074
- }
2075
- onUpdate?.({
2076
- content: [
2077
- { type: "text", text: `${fgState.toolUses} tool uses...` },
2078
- ],
2079
- details: details as any,
2080
- })
2081
- }
3239
+ /**
3240
+ * Act on {@link decideWorkflowCollision} — the half that needs the host.
3241
+ *
3242
+ * The policy (what counts as a conflict, what a pin changes, whether there is
3243
+ * anything left to withdraw) lives in `workflow/collisions.ts`; this is the
3244
+ * host-facing shell around it: read the registry, warn, and take our tool out
3245
+ * of the active set.
3246
+ *
3247
+ * ## Why this can only happen at session_start
3248
+ *
3249
+ * `getAllTools` throws during extension loading ("Action methods cannot be
3250
+ * called during extension loading"), and load order means a check at
3251
+ * registration time could not see an extension that has not loaded yet. So
3252
+ * the decision cannot gate `registerTool`; it has to undo it. `setActiveTools`
3253
+ * is what makes that real rather than cosmetic — pi rebuilds the system
3254
+ * prompt from the new set, and `session_start` runs before any turn, so the
3255
+ * model never sees a spec we withdrew. A later `_refreshToolRegistry` keeps
3256
+ * the active set it had and only adds names new to the registry, so ours does
3257
+ * not creep back.
3258
+ *
3259
+ * Best-effort and swallowed. A diagnostic that took the session down would be
3260
+ * worse than the collision it reports.
3261
+ */
3262
+ let collisionsChecked = false
3263
+ function resolveWorkflowCollisions(ctx: ExtensionContext): void {
3264
+ if (collisionsChecked) return
3265
+ collisionsChecked = true
3266
+
3267
+ const warn = (message: string) => {
3268
+ if (ctx.hasUI) ctx.ui.notify(message, "warning")
3269
+ else console.warn(`[pi-subagents] ${message}`)
3270
+ }
2082
3271
 
2083
- const { state: fgState, callbacks: fgCallbacks } =
2084
- createActivityTracker(effectiveMaxTurns, streamUpdate)
2085
-
2086
- // Wire session creation: register in widget + stream to output file.
2087
- // The output file path is set synchronously after spawn (below),
2088
- // before onSessionCreated fires — same pattern as background agents.
2089
- const origOnSession = fgCallbacks.onSessionCreated
2090
- fgCallbacks.onSessionCreated = (session: any) => {
2091
- origOnSession(session)
2092
- if (queuedAhead !== undefined) {
2093
- queuedAhead = undefined
2094
- streamUpdate()
2095
- }
2096
- for (const a of manager.listAgents()) {
2097
- if (a.session === session) {
2098
- fgId = a.id
2099
- agentActivity.set(a.id, fgState)
2100
- widget.ensureTimer()
2101
- fleet.ensureTimer()
2102
- fleet.update()
2103
- break
2104
- }
2105
- }
2106
- // Stream conversation to output file (foreground agent logging)
2107
- if (fgId) {
2108
- const rec = manager.getRecord(fgId)
2109
- if (rec?.outputFile) {
2110
- rec.outputCleanup = streamToOutputFile(
2111
- session,
2112
- rec.outputFile,
2113
- fgId,
2114
- ctx.cwd,
2115
- )
2116
- }
2117
- }
2118
- }
3272
+ try {
3273
+ if (!isWorkflowsEnabled()) return
3274
+
3275
+ const verdict = decideWorkflowCollision({
3276
+ tools: pi.getAllTools(),
3277
+ // Identifies our own registration: this extension does not know its
3278
+ // install path, and the description is the one field certainly ours.
3279
+ ownDescription: workflowTool.description,
3280
+ pinned: isWorkflowsPinned(),
3281
+ })
3282
+ if (verdict.kind === "none") return
3283
+ if (verdict.kind === "report") {
3284
+ warn(verdict.message)
3285
+ return
3286
+ }
2119
3287
 
2120
- // Animate spinner at ~80ms (smooth rotation through 10 braille frames)
2121
- const spinnerInterval = setInterval(() => {
2122
- spinnerFrame++
2123
- streamUpdate()
2124
- }, 80)
3288
+ workflowsEnabled = false // not setWorkflowsEnabled: this is not the user pinning it
3289
+ widget.update()
3290
+ fleet.update()
3291
+ warn(verdict.message)
3292
+
3293
+ if (!verdict.withdraw) return
3294
+ const active = pi.getActiveTools()
3295
+ if (active.includes(SUBAGENT_TOOL_NAMES.WORKFLOW)) {
3296
+ pi.setActiveTools(
3297
+ active.filter((name) => name !== SUBAGENT_TOOL_NAMES.WORKFLOW),
3298
+ )
3299
+ }
3300
+ } catch {
3301
+ // getAllTools/setActiveTools are unavailable in some hosts (print mode,
3302
+ // RPC). Not being able to check is not a reason to fail the session.
3303
+ }
3304
+ }
2125
3305
 
2126
- streamUpdate()
3306
+ /**
3307
+ * `--subagents-workflow-file=<path>` — run a script at startup, with no LLM
3308
+ * round-trip deciding whether to call the tool.
3309
+ *
3310
+ * Read here rather than at activation because that is the only place the real
3311
+ * value exists: the host activates extensions first and applies collected CLI
3312
+ * flags second, so `getFlag` during activation returns the registered default
3313
+ * and nothing else. `examples/extensions/ssh.ts` reads its flag from
3314
+ * session_start for exactly this reason.
3315
+ */
3316
+ let workflowFlagHandled = false
3317
+ function runWorkflowFlag(ctx: ExtensionContext): void {
3318
+ if (workflowFlagHandled) return
3319
+ const flag = pi.getFlag(WORKFLOW_FILE_FLAG)
3320
+ if (flag === undefined || flag === false) return
3321
+ workflowFlagHandled = true
3322
+
3323
+ const report = (message: string, level: "info" | "warning") => {
3324
+ if (ctx.hasUI) ctx.ui.notify(message, level)
3325
+ else console.warn(`[pi-subagents] ${message}`)
3326
+ }
2127
3327
 
2128
- let record: AgentRecord
2129
- try {
2130
- const fgResult = await manager.spawnAndWait(
2131
- pi,
2132
- ctx,
2133
- subagentType,
2134
- params.prompt,
2135
- {
2136
- description: params.description,
2137
- model,
2138
- maxTurns: effectiveMaxTurns,
2139
- isolated,
2140
- inheritContext,
2141
- thinkingLevel: thinking,
2142
- isolation,
2143
- invocation: agentInvocation,
2144
- signal,
2145
- rootSessionId: ctx.sessionManager.getSessionId(),
2146
- onQueued: (_id, ahead) => {
2147
- queuedAhead = ahead
2148
- streamUpdate()
2149
- },
2150
- ...fgCallbacks,
2151
- },
2152
- (fgAgentId) => {
2153
- // onSpawned: called synchronously after spawn, before onSessionCreated fires.
2154
- // Set up the output file so streamToOutputFile can pick it up.
2155
- const fgRec = manager.getRecord(fgAgentId)
2156
- attachTranscript(fgRec, fgAgentId)
2157
- },
2158
- )
2159
- record = fgResult.record
2160
- } finally {
2161
- // A startup throw propagates as a failed tool call without leaving
2162
- // the spinner running or a finished agent in the widget.
2163
- clearInterval(spinnerInterval)
2164
- if (fgId) {
2165
- agentActivity.delete(fgId)
2166
- widget.markFinished(fgId)
2167
- fleet.onAgentFinished(fgId)
2168
- }
2169
- }
3328
+ // The flag is the same machinery by another door, so the master switch has
3329
+ // to close it too — silently ignoring a flag the user typed would be worse
3330
+ // than saying why nothing ran.
3331
+ if (!isWorkflowsEnabled()) {
3332
+ report(
3333
+ `--${WORKFLOW_FILE_FLAG} ignored: workflows are off. Turn them on in /agents → Settings → Workflows, ` +
3334
+ 'or set `"workflowsEnabled": true` in .pi/subagents.json.',
3335
+ "warning",
3336
+ )
3337
+ return
3338
+ }
2170
3339
 
2171
- // Get final token count
2172
- const tokenText = formatLifetimeTokens(record)
3340
+ // A bare `--subagents-workflow-file` parses to boolean `true`. Say what was
3341
+ // missing rather than reading a file called "true".
3342
+ if (typeof flag !== "string" || flag.trim() === "") {
3343
+ report(
3344
+ `--${WORKFLOW_FILE_FLAG} needs a path: --${WORKFLOW_FILE_FLAG}=<path>`,
3345
+ "warning",
3346
+ )
3347
+ return
3348
+ }
2173
3349
 
2174
- const details = buildDetails(detailBaseFor(record), record, fgState, {
2175
- tokens: tokenText,
2176
- })
3350
+ const path = isAbsolute(flag.trim())
3351
+ ? flag.trim()
3352
+ : join(ctx.cwd, flag.trim())
3353
+ let script: string
3354
+ try {
3355
+ script = readFileSync(path, "utf-8")
3356
+ } catch (err) {
3357
+ report(
3358
+ `Could not read ${path}: ${err instanceof Error ? err.message : String(err)}`,
3359
+ "warning",
3360
+ )
3361
+ return
3362
+ }
2177
3363
 
2178
- if (record.status === "error") {
2179
- // Error headline + any partial output the run produced before failing.
2180
- return textResult(
2181
- `${fallbackNote}Agent failed: ${record.error}${partialOutputSuffix(record)}`,
2182
- details,
2183
- )
2184
- }
3364
+ let meta: WorkflowMeta | undefined
3365
+ try {
3366
+ meta = extractMeta(script).meta
3367
+ } catch (err) {
3368
+ report(err instanceof Error ? err.message : String(err), "warning")
3369
+ return
3370
+ }
2185
3371
 
2186
- const durationMs = (record.completedAt ?? Date.now()) - record.startedAt
2187
- const statsParts = [`${record.toolUses} tool uses`]
2188
- if (tokenText) statsParts.push(tokenText)
2189
- if (showCost) {
2190
- const costText = formatCost(getLifetimeCost(record.lifetimeUsage))
2191
- if (costText) statsParts.push(costText)
2192
- }
2193
- return textResult(
2194
- `${fallbackNote}Agent completed in ${formatMs(durationMs)} (${statsParts.join(", ")})${getForegroundOutcomeNote(record.status)}.\n\n` +
2195
- (record.result?.trim() || "No output."),
2196
- details,
2197
- )
2198
- },
2199
- }),
2200
- )
3372
+ const task = createWorkflowTask({
3373
+ id: workflowRunId(),
3374
+ script,
3375
+ scriptPath: path,
3376
+ meta,
3377
+ })
3378
+ workflowTasks.set(task.id, task)
3379
+ widget.update()
3380
+ fleet.update()
3381
+ report(`Running workflow ${meta.name}…`, "info")
3382
+
3383
+ // Detached: session_start is awaited by the host, and a workflow can run for
3384
+ // minutes — blocking here would hold the whole session's startup.
3385
+ void runWorkflowTask(ctx, task).then(() => {
3386
+ // No tool call to attach a result card to, so the card becomes a session
3387
+ // entry (same layout), and the outcome is handed to the model as context
3388
+ // for its next turn rather than forcing one.
3389
+ pi.appendEntry<WorkflowEntryData>(
3390
+ WORKFLOW_ENTRY_TYPE,
3391
+ workflowEntryData(task),
3392
+ )
3393
+ pi.sendMessage(
3394
+ {
3395
+ customType: "workflow-result",
3396
+ content: formatWorkflowNotification(task),
3397
+ display: false,
3398
+ },
3399
+ { deliverAs: "nextTurn" },
3400
+ )
3401
+ widget.update()
3402
+ fleet.update()
3403
+ })
3404
+ }
2201
3405
 
2202
3406
  // ---- get_subagent_result tool ----
2203
3407
 
@@ -2206,12 +3410,13 @@ Terse command-style prompts produce shallow, generic work.
2206
3410
  name: SUBAGENT_TOOL_NAMES.GET_RESULT,
2207
3411
  label: "Get Agent Result",
2208
3412
  description:
2209
- "Check status and retrieve results from a background agent. Use the agent ID returned by Agent with run_in_background.",
3413
+ "Check status and retrieve a background agent's full result — its completion notification carries only a preview. Use the agent ID returned by Agent.",
2210
3414
  promptSnippet:
2211
3415
  "Check status and retrieve results from a background agent",
2212
3416
  parameters: Type.Object({
2213
3417
  agent_id: Type.String({
2214
- description: "The agent ID to check.",
3418
+ description:
3419
+ "The agent ID to check. The agent's handle also works — its `name` if you gave it one, otherwise its type (`explore`, `explore-2`).",
2215
3420
  }),
2216
3421
  wait: Type.Optional(
2217
3422
  Type.Boolean({
@@ -2227,8 +3432,8 @@ Terse command-style prompts produce shallow, generic work.
2227
3432
  ),
2228
3433
  }),
2229
3434
  execute: async (_toolCallId, params, signal, _onUpdate, _ctx) => {
2230
- const record = manager.getRecord(params.agent_id)
2231
- if (!record || record.parentAgentId) {
3435
+ const record = resolveAgentRef(params.agent_id)
3436
+ if (!record || !isTopLevelAgent(record)) {
2232
3437
  return textResult(
2233
3438
  `Agent not found: "${params.agent_id}". It may have been cleaned up.`,
2234
3439
  )
@@ -2316,7 +3521,8 @@ Terse command-style prompts produce shallow, generic work.
2316
3521
  "Send a steering message to redirect a running background agent",
2317
3522
  parameters: Type.Object({
2318
3523
  agent_id: Type.String({
2319
- description: "The agent ID to steer (must be currently running).",
3524
+ description:
3525
+ "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`).",
2320
3526
  }),
2321
3527
  message: Type.String({
2322
3528
  description:
@@ -2324,8 +3530,8 @@ Terse command-style prompts produce shallow, generic work.
2324
3530
  }),
2325
3531
  }),
2326
3532
  execute: async (_toolCallId, params, _signal, _onUpdate, _ctx) => {
2327
- const record = manager.getRecord(params.agent_id)
2328
- if (!record || record.parentAgentId) {
3533
+ const record = resolveAgentRef(params.agent_id)
3534
+ if (!record || !isTopLevelAgent(record)) {
2329
3535
  return textResult(
2330
3536
  `Agent not found: "${params.agent_id}". It may have been cleaned up.`,
2331
3537
  )
@@ -2358,6 +3564,10 @@ Terse command-style prompts produce shallow, generic work.
2358
3564
  const contextPercent = getSessionContextPercent(record.session)
2359
3565
  const stateParts: string[] = []
2360
3566
  if (tokens) stateParts.push(tokens)
3567
+ if (showCost) {
3568
+ const costText = formatCost(getLifetimeCost(record.lifetimeUsage))
3569
+ if (costText) stateParts.push(costText)
3570
+ }
2361
3571
  stateParts.push(
2362
3572
  `${record.toolUses} tool ${record.toolUses === 1 ? "use" : "uses"}`,
2363
3573
  )
@@ -2417,7 +3627,7 @@ Terse command-style prompts produce shallow, generic work.
2417
3627
  const options: string[] = []
2418
3628
 
2419
3629
  // Running agents entry (only if there are active agents)
2420
- const agents = manager.listAgents().filter((a) => !a.parentAgentId)
3630
+ const agents = manager.listAgents().filter(isTopLevelAgent)
2421
3631
  if (agents.length > 0) {
2422
3632
  const running = agents.filter(
2423
3633
  (a) => a.status === "running" || a.status === "queued",
@@ -2441,6 +3651,12 @@ Terse command-style prompts produce shallow, generic work.
2441
3651
  options.push(`Scheduled jobs (${jobCount})`)
2442
3652
  }
2443
3653
 
3654
+ // Workflow runs, on the same terms as scheduled jobs: shown only when the
3655
+ // feature is on, so the menu never advertises something switched off.
3656
+ if (isWorkflowsEnabled()) {
3657
+ options.push(`Workflows (${workflowTasks.size})`)
3658
+ }
3659
+
2444
3660
  // Actions
2445
3661
  options.push("Create new agent")
2446
3662
  options.push("Settings")
@@ -2468,6 +3684,9 @@ Terse command-style prompts produce shallow, generic work.
2468
3684
  } else if (choice.startsWith("Scheduled jobs (")) {
2469
3685
  await showSchedulesMenu(ctx, scheduler)
2470
3686
  await showAgentsMenu(ctx)
3687
+ } else if (choice.startsWith("Workflows (")) {
3688
+ await showWorkflowsMenu(ctx, workflowMenuDeps)
3689
+ await showAgentsMenu(ctx)
2471
3690
  } else if (choice === "Create new agent") {
2472
3691
  await showCreateWizard(ctx)
2473
3692
  } else if (choice === "Settings") {
@@ -2555,7 +3774,7 @@ Terse command-style prompts produce shallow, generic work.
2555
3774
  }
2556
3775
 
2557
3776
  async function showRunningAgents(ctx: ExtensionCommandContext) {
2558
- const agents = manager.listAgents().filter((a) => !a.parentAgentId)
3777
+ const agents = manager.listAgents().filter(isTopLevelAgent)
2559
3778
  if (agents.length === 0) {
2560
3779
  ctx.ui.notify("No agents.", "info")
2561
3780
  return
@@ -2611,10 +3830,7 @@ Terse command-style prompts produce shallow, generic work.
2611
3830
  (message: string) => manager.steer(record.id, message),
2612
3831
  showCost,
2613
3832
  getViewerMarkdown,
2614
- (mode) => {
2615
- setViewerMarkdown(mode)
2616
- persistSettings(ctx, `Viewer markdown set to ${mode}`)
2617
- },
3833
+ (mode) => chooseViewerMarkdown(mode, ctx),
2618
3834
  )
2619
3835
  },
2620
3836
  {
@@ -2635,7 +3851,7 @@ Terse command-style prompts produce shallow, generic work.
2635
3851
  return
2636
3852
  }
2637
3853
 
2638
- const file = findAgentFile(name)
3854
+ const file = locateAgentFile(name, cfg.sourcePath)
2639
3855
  const isDefault = cfg.isDefault === true
2640
3856
  const disabled = cfg.enabled === false
2641
3857
 
@@ -2735,7 +3951,7 @@ Terse command-style prompts produce shallow, generic work.
2735
3951
 
2736
3952
  /** Disable an agent: set enabled: false in its .md file, or create a stub for built-in defaults. */
2737
3953
  async function disableAgent(ctx: ExtensionCommandContext, name: string) {
2738
- const file = findAgentFile(name)
3954
+ const file = locateAgentFile(name, getAgentConfig(name)?.sourcePath)
2739
3955
  if (file) {
2740
3956
  // Existing file — set enabled: false in frontmatter (idempotent)
2741
3957
  const content = readFileSync(file.path, "utf-8")
@@ -2781,7 +3997,7 @@ Terse command-style prompts produce shallow, generic work.
2781
3997
 
2782
3998
  /** Enable a disabled agent by removing enabled: false from its frontmatter. */
2783
3999
  async function enableAgent(ctx: ExtensionCommandContext, name: string) {
2784
- const file = findAgentFile(name)
4000
+ const file = locateAgentFile(name, getAgentConfig(name)?.sourcePath)
2785
4001
  if (!file) return
2786
4002
 
2787
4003
  const content = readFileSync(file.path, "utf-8")
@@ -2861,7 +4077,6 @@ The file format is a markdown file with YAML frontmatter and a system prompt bod
2861
4077
 
2862
4078
  \`\`\`markdown
2863
4079
  ---
2864
- name: <optional UI display name; Claude Code-compatible alias for display_name>
2865
4080
  description: <one-line description shown in UI>
2866
4081
  color: <optional agent name badge color: red, blue, green, yellow, purple, orange, pink, cyan, an Agency Agents alias, or quoted "#RRGGBB">
2867
4082
  tools: <comma-separated built-in tools: read, bash, edit, write, grep, find, ls. Use "none" for no tools. Omit for all tools>
@@ -2873,12 +4088,16 @@ extensions: <true (inherit all MCP/extension tools), false (none), or comma-sepa
2873
4088
  skills: <true (inherit all), false (none), or comma-separated skill names to preload into prompt. Default: true>
2874
4089
  disallowed_tools: <comma-separated tool names to block, even if otherwise available. Omit for none>
2875
4090
  inherit_context: <true to fork parent conversation into agent so it sees chat history. Default: false>
2876
- run_in_background: <pin this agent to background (true) or foreground (false). Omit to follow backgroundByDefault>
4091
+ run_in_background: <pin this agent to background (true) or foreground (false). Omit to follow the backgroundByDefault setting, which is background>
2877
4092
  output_transcript: <false to write no transcript file or path for this agent. Independent of persist_session. Default: true>
2878
4093
  isolated: <true for no extension/MCP tools, only built-in tools. Default: false>
2879
4094
  memory: <"user" (global), "project" (per-project), or "local" (gitignored per-project) for persistent memory. Omit for none>${
4095
+ // Offering the field on a project that turned worktrees off would bake a
4096
+ // request that is refused at spawn time into a file that outlives the
4097
+ // session — the #231 pathology (models fill the fields they are shown)
4098
+ // one layer up. Built per invocation, so this read is live.
2880
4099
  isWorktreeIsolationEnabled()
2881
- ? `\nisolation: <"worktree" to run in an isolated git worktree; "off" to refuse one. Omit for normal>`
4100
+ ? `\nisolation: <"worktree" to run in isolated git worktree; "off" to refuse one even when the caller asks. Omit for normal>`
2882
4101
  : ""
2883
4102
  }
2884
4103
  ---
@@ -2906,6 +4125,11 @@ Write the file using the write tool. Only write the file, nothing else.`
2906
4125
  {
2907
4126
  description: `Generate ${name} agent`,
2908
4127
  maxTurns: 5,
4128
+ // Exempt from maxConcurrentForeground. This runs from a modal wizard, not
4129
+ // a tool call: it passes no signal, and Esc in `ctx.ui` never reaches the
4130
+ // manager — so a user waiting behind a full pool would have no way to
4131
+ // cancel at all. It is also one human action that cannot fan out, which
4132
+ // is what the limit exists to bound. It still counts once started.
2909
4133
  bypassQueue: true,
2910
4134
  },
2911
4135
  )
@@ -3031,6 +4255,7 @@ Write the file using the write tool. Only write the file, nothing else.`
3031
4255
  function snapshotSettings() {
3032
4256
  return {
3033
4257
  maxConcurrent: manager.getMaxConcurrent(),
4258
+ // 0 = unlimited, and the default — see SubagentsSettings.
3034
4259
  maxConcurrentForeground: manager.getMaxConcurrentForeground(),
3035
4260
  // 0 = unlimited — per SubagentsSettings.defaultMaxTurns docstring and
3036
4261
  // normalizeMaxTurns() in agent-runner.ts (which maps 0 → undefined).
@@ -3044,9 +4269,19 @@ Write the file using the write tool. Only write the file, nothing else.`
3044
4269
  disableDefaultAgents: isDefaultsDisabled(),
3045
4270
  toolDescriptionMode: getToolDescriptionMode(),
3046
4271
  fleetView: isFleetViewEnabled(),
4272
+ agentMentions: getAgentMentionMode(),
4273
+ rememberAgents: getRememberAgents(),
3047
4274
  widgetMode: getWidgetMode(),
3048
4275
  outputTranscript: getOutputTranscriptDefault(),
3049
4276
  worktreeIsolation: isWorktreeIsolationEnabled(),
4277
+ // The user's answer, not the effective one. A stand-down for another
4278
+ // extension's workflow tool is scoped to the session it was detected in;
4279
+ // writing it here would let an unrelated settings change three menus away
4280
+ // freeze it into the file as an explicit `false`, which then survives
4281
+ // uninstalling the extension it was deferring to. undefined is dropped by
4282
+ // JSON.stringify, so unset stays unset — same reasoning as
4283
+ // `fallbackSubagent` below.
4284
+ workflowsEnabled: isWorkflowsPinned() ? isWorkflowsEnabled() : undefined,
3050
4285
  maxSubagentDepth: getMaxSubagentDepth(),
3051
4286
  // Deliberately NOT `?? "general-purpose"`: every settings change writes the
3052
4287
  // whole snapshot, and materializing the implicit default would turn it into
@@ -3109,7 +4344,7 @@ Write the file using the write tool. Only write the file, nothing else.`
3109
4344
  id: "maxConcurrentForeground",
3110
4345
  label: "Max foreground concurrency",
3111
4346
  description:
3112
- "Max concurrent blocking agents (0 = unlimited, Enter to type)",
4347
+ "Max concurrent foreground (blocking) agents (0 = unlimited, Enter to type)",
3113
4348
  currentValue: String(mcf),
3114
4349
  values: [String(mcf)],
3115
4350
  },
@@ -3147,7 +4382,7 @@ Write the file using the write tool. Only write the file, nothing else.`
3147
4382
  id: "backgroundByDefault",
3148
4383
  label: "Background by default",
3149
4384
  description:
3150
- "Unqualified top-level Agent calls run detached (off = block and return inline)",
4385
+ "An Agent call that doesn't say runs detached (off = blocks the turn and returns inline)",
3151
4386
  currentValue: getBackgroundByDefault() ? "on" : "off",
3152
4387
  values: ["on", "off"],
3153
4388
  },
@@ -3159,6 +4394,15 @@ Write the file using the write tool. Only write the file, nothing else.`
3159
4394
  currentValue: isSchedulingEnabled() ? "on" : "off",
3160
4395
  values: ["on", "off"],
3161
4396
  },
4397
+ {
4398
+ id: "workflowsEnabled",
4399
+ label: "Workflows",
4400
+ description:
4401
+ "Scripted workflows, on unless another extension provides a workflow tool " +
4402
+ "(off keeps the SubagentWorkflow tool out of the tool spec; applies on next pi session)",
4403
+ currentValue: isWorkflowsEnabled() ? "on" : "off",
4404
+ values: ["on", "off"],
4405
+ },
3162
4406
  {
3163
4407
  id: "scopeModels",
3164
4408
  label: "Scope models",
@@ -3171,7 +4415,7 @@ Write the file using the write tool. Only write the file, nothing else.`
3171
4415
  id: "strictAgentFiles",
3172
4416
  label: "Strict agent files",
3173
4417
  description:
3174
- "Fail startup on an unreadable or unparseable agent .md instead of skipping it with a warning",
4418
+ "Fail startup on an unreadable/unparseable agent .md instead of skipping it with a warning",
3175
4419
  currentValue: strictAgentFiles ? "on" : "off",
3176
4420
  values: ["on", "off"],
3177
4421
  },
@@ -3202,28 +4446,31 @@ Write the file using the write tool. Only write the file, nothing else.`
3202
4446
  id: "worktreeIsolation",
3203
4447
  label: "Worktree isolation",
3204
4448
  description:
3205
- "Allow isolation: worktree (off removes the Agent parameter on next pi session)",
4449
+ "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.",
3206
4450
  currentValue: isWorktreeIsolationEnabled() ? "on" : "off",
3207
4451
  values: ["on", "off"],
3208
4452
  },
3209
4453
  {
3210
4454
  id: "reportUsage",
3211
- label: "Report usage",
3212
- description: "Include subagent spend in parent session accounting",
4455
+ label: "Report usage to session",
4456
+ description:
4457
+ "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.",
3213
4458
  currentValue: isReportUsageEnabled() ? "on" : "off",
3214
4459
  values: ["on", "off"],
3215
4460
  },
3216
4461
  {
3217
4462
  id: "showCost",
3218
4463
  label: "Show cost",
3219
- description: "Show estimated USD cost beside subagent token counts",
4464
+ description:
4465
+ "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.",
3220
4466
  currentValue: isShowCostEnabled() ? "on" : "off",
3221
4467
  values: ["on", "off"],
3222
4468
  },
3223
4469
  {
3224
4470
  id: "showModel",
3225
4471
  label: "Show model",
3226
- description: "Show model and thinking level in running widget rows",
4472
+ description:
4473
+ "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.",
3227
4474
  currentValue: isShowModelEnabled() ? "on" : "off",
3228
4475
  values: ["on", "off"],
3229
4476
  },
@@ -3231,7 +4478,7 @@ Write the file using the write tool. Only write the file, nothing else.`
3231
4478
  id: "viewerMarkdown",
3232
4479
  label: "Viewer markdown",
3233
4480
  description:
3234
- "Conversation Markdown: assistant (default), all tool results, or off; press m in the viewer to cycle",
4481
+ "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+).",
3235
4482
  currentValue: getViewerMarkdown(),
3236
4483
  values: ["off", "assistant", "all"],
3237
4484
  },
@@ -3243,6 +4490,22 @@ Write the file using the write tool. Only write the file, nothing else.`
3243
4490
  currentValue: isFleetViewEnabled() ? "on" : "off",
3244
4491
  values: ["on", "off"],
3245
4492
  },
4493
+ {
4494
+ id: "agentMentions",
4495
+ label: "Agent mentions",
4496
+ description:
4497
+ "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.",
4498
+ currentValue: getAgentMentionMode(),
4499
+ values: ["model", "direct", "off"],
4500
+ },
4501
+ {
4502
+ id: "rememberAgents",
4503
+ label: "Remember agents",
4504
+ description:
4505
+ "Persist subagent sessions so `@handle` can resume one long after it finished (they also appear in /resume)",
4506
+ currentValue: getRememberAgents() ? "on" : "off",
4507
+ values: ["on", "off"],
4508
+ },
3246
4509
  {
3247
4510
  id: "widgetMode",
3248
4511
  label: "Widget",
@@ -3270,6 +4533,7 @@ Write the file using the write tool. Only write the file, nothing else.`
3270
4533
  notifyApplied(ctx, `Max concurrency set to ${n}`)
3271
4534
  }
3272
4535
  } else if (id === "maxConcurrentForeground") {
4536
+ // 0 is meaningful here, unlike maxConcurrent above: it means unlimited.
3273
4537
  const n = parseInt(value, 10)
3274
4538
  if (n >= 0) {
3275
4539
  manager.setMaxConcurrentForeground(n)
@@ -3315,8 +4579,8 @@ Write the file using the write tool. Only write the file, nothing else.`
3315
4579
  notifyApplied(
3316
4580
  ctx,
3317
4581
  enabled
3318
- ? "Agent calls run in the background unless explicitly set false"
3319
- : "Agent calls block unless explicitly set true",
4582
+ ? "Agent calls run in the background unless they pass run_in_background: false"
4583
+ : "Agent calls block and return inline unless they pass run_in_background: true",
3320
4584
  )
3321
4585
  } else if (id === "schedulingEnabled") {
3322
4586
  const enabled = value === "on"
@@ -3333,6 +4597,23 @@ Write the file using the write tool. Only write the file, nothing else.`
3333
4597
  `Scheduling ${enabled ? "enabled" : "disabled"}. Tool spec change takes effect on next pi session.`,
3334
4598
  )
3335
4599
  }
4600
+ } else if (id === "workflowsEnabled") {
4601
+ const enabled = value === "on"
4602
+ if (enabled === isWorkflowsEnabled()) {
4603
+ ctx.ui.notify(
4604
+ `Workflows already ${enabled ? "enabled" : "disabled"}.`,
4605
+ "info",
4606
+ )
4607
+ } else {
4608
+ setWorkflowsEnabled(enabled)
4609
+ // Runs already in flight keep going: the switch governs whether the
4610
+ // tool is offered, and killing live agents on a settings toggle would
4611
+ // lose work the user never asked to discard.
4612
+ notifyApplied(
4613
+ ctx,
4614
+ `Workflows ${enabled ? "enabled" : "disabled"}. Tool spec change takes effect on next pi session.`,
4615
+ )
4616
+ }
3336
4617
  } else if (id === "scopeModels") {
3337
4618
  const enabled = value === "on"
3338
4619
  setScopeModelsEnabled(enabled)
@@ -3369,16 +4650,26 @@ Write the file using the write tool. Only write the file, nothing else.`
3369
4650
  } else if (id === "worktreeIsolation") {
3370
4651
  const enabled = value === "on"
3371
4652
  setWorktreeIsolationEnabled(enabled)
4653
+ // The refusal is live, but the tool schema is built at registration, so
4654
+ // the isolation parameter only appears/disappears next session.
3372
4655
  notifyApplied(
3373
4656
  ctx,
3374
4657
  `Worktree isolation ${enabled ? "enabled" : "disabled"}. Tool parameter updates on next pi session.`,
3375
4658
  )
4659
+ } else if (id === "toolDescriptionMode") {
4660
+ setToolDescriptionMode(value as ToolDescriptionMode)
4661
+ notifyApplied(
4662
+ ctx,
4663
+ `Tool description set to ${value}. Takes effect on next pi session.`,
4664
+ )
3376
4665
  } else if (id === "reportUsage") {
3377
4666
  const enabled = value === "on"
3378
4667
  setReportUsage(enabled)
3379
4668
  notifyApplied(
3380
4669
  ctx,
3381
- `Usage reporting ${enabled ? "enabled" : "disabled"}`,
4670
+ enabled
4671
+ ? "Subagent usage now counted in this session's totals"
4672
+ : "Subagent usage no longer counted in this session's totals",
3382
4673
  )
3383
4674
  } else if (id === "showCost") {
3384
4675
  const enabled = value === "on"
@@ -3391,16 +4682,28 @@ Write the file using the write tool. Only write the file, nothing else.`
3391
4682
  } else if (id === "viewerMarkdown") {
3392
4683
  setViewerMarkdown(value as ViewerMarkdownMode)
3393
4684
  notifyApplied(ctx, `Viewer markdown set to ${value}`)
3394
- } else if (id === "toolDescriptionMode") {
3395
- setToolDescriptionMode(value as ToolDescriptionMode)
3396
- notifyApplied(
3397
- ctx,
3398
- `Tool description set to ${value}. Takes effect on next pi session.`,
3399
- )
3400
4685
  } else if (id === "fleetView") {
3401
4686
  const enabled = value === "on"
3402
4687
  setFleetViewEnabled(enabled)
3403
4688
  notifyApplied(ctx, `Fleet view ${enabled ? "enabled" : "disabled"}`)
4689
+ } else if (id === "agentMentions") {
4690
+ const mode = value as AgentMentionMode
4691
+ setAgentMentionMode(mode)
4692
+ notifyApplied(
4693
+ ctx,
4694
+ mode === "off"
4695
+ ? "Agent mentions disabled"
4696
+ : mode === "model"
4697
+ ? "Agent mentions on — a conversation clone starts a mentioned agent off-screen"
4698
+ : "Agent mentions on — a mentioned agent starts here, with no model call",
4699
+ )
4700
+ } else if (id === "rememberAgents") {
4701
+ const enabled = value === "on"
4702
+ setRememberAgents(enabled)
4703
+ notifyApplied(
4704
+ ctx,
4705
+ `Remember agents ${enabled ? "enabled" : "disabled"}`,
4706
+ )
3404
4707
  } else if (id === "widgetMode") {
3405
4708
  setWidgetMode(value as WidgetMode)
3406
4709
  notifyApplied(ctx, `Widget set to ${value}`)
@@ -3501,16 +4804,28 @@ Write the file using the write tool. Only write the file, nothing else.`
3501
4804
  // the right toast. Successful saves show info; persistence failures downgrade
3502
4805
  // to warning so users aren't silently reverted on restart. Event fires regardless
3503
4806
  // of outcome so listeners see the in-memory change.
4807
+ /**
4808
+ * Persist + broadcast the settings, silent on success — for a change whose
4809
+ * feedback is the UI it just changed: the viewer's `m` key, where a
4810
+ * notification per press would talk over the overlay it is describing.
4811
+ *
4812
+ * A *failed* write still speaks. Every other settings path warns when the
4813
+ * value is session-only, and swallowing it here would leave a preference
4814
+ * looking persisted when the next session will not have it.
4815
+ */
3504
4816
  function persistSettings(
3505
- ctx: ExtensionCommandContext,
3506
- successMsg: string,
4817
+ ctx: ExtensionCommandContext | undefined,
4818
+ changeMsg: string,
3507
4819
  ): void {
3508
4820
  const { message, level } = saveAndEmitChanged(
3509
4821
  snapshotSettings(),
3510
- successMsg,
4822
+ changeMsg,
3511
4823
  (event, payload) => pi.events.emit(event, payload),
3512
4824
  )
3513
- if (level === "warning") ctx.ui.notify(message, level)
4825
+ // `ctx` is absent only on the fleet path between sessions, where
4826
+ // `currentCtx` has been cleared and there is no UI to carry the warning to.
4827
+ // The write still happens.
4828
+ if (level === "warning") ctx?.ui.notify(message, level)
3514
4829
  }
3515
4830
 
3516
4831
  function notifyApplied(ctx: ExtensionCommandContext, successMsg: string) {
@@ -3528,4 +4843,24 @@ Write the file using the write tool. Only write the file, nothing else.`
3528
4843
  await showAgentsMenu(ctx)
3529
4844
  },
3530
4845
  })
4846
+
4847
+ /**
4848
+ * What `/agents → Workflows` and the fleet list's `workflow` rows need from
4849
+ * here. One object, built once: both entry points open the same inspector,
4850
+ * and handing them different views of the session would let the two drift.
4851
+ */
4852
+ const workflowMenuDeps: WorkflowMenuDeps = {
4853
+ tasks: workflowTasks,
4854
+ getRecord: (id) => manager.getRecord(id),
4855
+ viewAgentConversation,
4856
+ // Read lazily: `currentCtx` is rebound on every session_start, and the
4857
+ // fleet list may act between sessions, when there is none.
4858
+ // SAFETY: ExtensionContext and ExtensionCommandContext share the runtime
4859
+ // UI/session fields used by persistSettings; the value can also be absent.
4860
+ getCtx: () => currentCtx as unknown as ExtensionCommandContext | undefined,
4861
+ }
4862
+
4863
+ fleet.setWorkflowSource(fleetWorkflows, (id) =>
4864
+ openWorkflowFromFleet(id, workflowMenuDeps),
4865
+ )
3531
4866
  }