@herbertgao/pi-subagents 0.17.0 → 0.18.0
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.
- package/CHANGELOG.md +12 -0
- package/README.md +427 -120
- package/docs/rpc.md +184 -0
- package/docs/workflows.md +466 -0
- package/examples/agent-tool-description.md +6 -6
- package/examples/workflows/compose.js +52 -0
- package/examples/workflows/fan-out-audit.js +56 -0
- package/examples/workflows/gated-fix.js +60 -0
- package/examples/workflows/lib/count-child.js +30 -0
- package/examples/workflows/review-panel.js +68 -0
- package/examples/workflows/structured-findings.js +81 -0
- package/package.json +12 -9
- package/src/agent-file-toggle.ts +52 -12
- package/src/agent-manager.ts +837 -146
- package/src/agent-runner.ts +213 -39
- package/src/cross-extension-rpc.ts +73 -14
- package/src/custom-agents.ts +101 -47
- package/src/index.ts +2249 -914
- package/src/invocation-config.ts +13 -0
- package/src/mention-clone.ts +215 -0
- package/src/mention.ts +147 -0
- package/src/model-resolver.ts +9 -1
- package/src/nested-tools.ts +40 -26
- package/src/output-file.ts +18 -8
- package/src/prompts.ts +46 -9
- package/src/schedule.ts +21 -16
- package/src/settings.ts +137 -7
- package/src/structured-output.ts +136 -0
- package/src/types.ts +126 -8
- package/src/ui/agent-mention.ts +274 -0
- package/src/ui/agent-widget.ts +20 -5
- package/src/ui/conversation-viewer.ts +14 -1
- package/src/ui/fleet-list.ts +167 -22
- package/src/ui/workflow-card.ts +555 -0
- package/src/ui/workflow-dialog.ts +1304 -0
- package/src/ui/workflow-menu.ts +226 -0
- package/src/workflow/collisions.ts +122 -0
- package/src/workflow/entry.ts +47 -0
- package/src/workflow/host.ts +463 -0
- package/src/workflow/journal.ts +164 -0
- package/src/workflow/json-schema.ts +142 -0
- package/src/workflow/meta.ts +401 -0
- package/src/workflow/progress.ts +622 -0
- package/src/workflow/runtime.ts +1399 -0
- package/src/workflow/saved.ts +230 -0
- package/src/workflow/task.ts +333 -0
- package/src/workflow/tool-description.ts +200 -0
- package/src/workflow/worker-source.ts +781 -0
- package/src/worktree.ts +97 -95
- 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 {
|
|
14
|
-
|
|
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 {
|
|
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, "&").replace(/</g, "<").replace(/>/g, ">")
|
|
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,
|
|
596
|
+
all.reduce((sum, a) => sum + (a.totalCost ?? 0), 0),
|
|
499
597
|
)
|
|
500
598
|
if (total) {
|
|
501
|
-
const tokens = all.reduce((sum,
|
|
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
|
-
//
|
|
515
|
-
//
|
|
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
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
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(
|
|
545
|
-
showCost =
|
|
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(
|
|
554
|
-
showModel =
|
|
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
|
-
//
|
|
710
|
-
//
|
|
711
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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,
|
|
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
|
-
...
|
|
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
|
|
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
|
-
|
|
912
|
-
|
|
913
|
-
|
|
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 =
|
|
917
|
-
|
|
918
|
-
|
|
919
|
-
|
|
920
|
-
|
|
921
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
1020
|
-
backgroundByDefault =
|
|
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:
|
|
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: (
|
|
1167
|
-
strictAgentFiles =
|
|
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.
|
|
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"
|
|
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
|
|
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.
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
1329
|
-
|
|
1330
|
-
|
|
1331
|
-
|
|
1332
|
-
|
|
1333
|
-
|
|
1334
|
-
|
|
1335
|
-
|
|
1336
|
-
|
|
1337
|
-
|
|
1338
|
-
|
|
1339
|
-
|
|
1340
|
-
|
|
1341
|
-
|
|
1342
|
-
|
|
1343
|
-
|
|
1344
|
-
|
|
1345
|
-
|
|
1346
|
-
|
|
1347
|
-
|
|
1348
|
-
|
|
1349
|
-
|
|
1350
|
-
|
|
1351
|
-
|
|
1352
|
-
|
|
1353
|
-
|
|
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
|
-
|
|
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
|
-
"
|
|
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
|
-
|
|
1362
|
-
|
|
2076
|
+
),
|
|
2077
|
+
thinking: Type.Optional(
|
|
2078
|
+
Type.String({
|
|
2079
|
+
description: `Thinking level: ${THINKING_LEVELS.join(", ")}. Overrides agent default.`,
|
|
1363
2080
|
}),
|
|
1364
|
-
|
|
1365
|
-
|
|
1366
|
-
|
|
1367
|
-
|
|
1368
|
-
|
|
1369
|
-
|
|
1370
|
-
|
|
1371
|
-
|
|
1372
|
-
|
|
1373
|
-
|
|
1374
|
-
|
|
1375
|
-
|
|
1376
|
-
|
|
1377
|
-
|
|
1378
|
-
|
|
1379
|
-
|
|
1380
|
-
|
|
1381
|
-
|
|
1382
|
-
|
|
1383
|
-
|
|
1384
|
-
|
|
1385
|
-
|
|
1386
|
-
|
|
1387
|
-
|
|
1388
|
-
|
|
1389
|
-
|
|
1390
|
-
|
|
1391
|
-
|
|
1392
|
-
|
|
1393
|
-
|
|
1394
|
-
|
|
1395
|
-
|
|
1396
|
-
|
|
1397
|
-
|
|
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
|
-
|
|
1411
|
-
|
|
1412
|
-
|
|
1413
|
-
|
|
1414
|
-
|
|
1415
|
-
|
|
1416
|
-
|
|
1417
|
-
|
|
1418
|
-
|
|
1419
|
-
|
|
1420
|
-
|
|
1421
|
-
|
|
1422
|
-
|
|
1423
|
-
|
|
1424
|
-
|
|
1425
|
-
|
|
1426
|
-
|
|
1427
|
-
|
|
1428
|
-
|
|
1429
|
-
|
|
1430
|
-
|
|
1431
|
-
|
|
1432
|
-
|
|
1433
|
-
|
|
1434
|
-
|
|
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
|
-
|
|
1438
|
-
"
|
|
1439
|
-
|
|
1440
|
-
|
|
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
|
-
|
|
1447
|
-
|
|
1448
|
-
|
|
1449
|
-
const
|
|
1450
|
-
|
|
1451
|
-
|
|
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
|
-
|
|
1455
|
-
|
|
1456
|
-
|
|
1457
|
-
if (
|
|
1458
|
-
|
|
1459
|
-
|
|
1460
|
-
|
|
1461
|
-
|
|
1462
|
-
|
|
1463
|
-
|
|
1464
|
-
|
|
1465
|
-
|
|
1466
|
-
|
|
1467
|
-
|
|
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
|
-
|
|
1470
|
-
|
|
1471
|
-
|
|
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
|
-
|
|
1475
|
-
|
|
1476
|
-
|
|
1477
|
-
|
|
1478
|
-
|
|
1479
|
-
|
|
1480
|
-
|
|
1481
|
-
|
|
1482
|
-
|
|
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
|
-
|
|
1487
|
-
|
|
1488
|
-
|
|
1489
|
-
|
|
1490
|
-
|
|
1491
|
-
|
|
1492
|
-
|
|
1493
|
-
|
|
1494
|
-
|
|
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
|
-
//
|
|
1499
|
-
|
|
1500
|
-
|
|
1501
|
-
|
|
1502
|
-
|
|
1503
|
-
|
|
1504
|
-
|
|
1505
|
-
|
|
1506
|
-
|
|
1507
|
-
|
|
1508
|
-
|
|
1509
|
-
if (
|
|
1510
|
-
|
|
1511
|
-
|
|
1512
|
-
|
|
1513
|
-
|
|
1514
|
-
|
|
1515
|
-
|
|
1516
|
-
|
|
1517
|
-
|
|
1518
|
-
|
|
1519
|
-
|
|
1520
|
-
|
|
1521
|
-
|
|
1522
|
-
|
|
1523
|
-
|
|
1524
|
-
|
|
1525
|
-
|
|
1526
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1532
|
-
|
|
1533
|
-
|
|
1534
|
-
|
|
1535
|
-
|
|
1536
|
-
|
|
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
|
-
|
|
1540
|
-
|
|
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
|
-
//
|
|
1544
|
-
|
|
1545
|
-
|
|
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
|
-
|
|
1548
|
-
|
|
1549
|
-
|
|
1550
|
-
|
|
1551
|
-
|
|
1552
|
-
|
|
1553
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1562
|
-
|
|
1563
|
-
widget.
|
|
2675
|
+
agentActivity.set(id, bgState)
|
|
2676
|
+
widget.ensureTimer()
|
|
2677
|
+
widget.update()
|
|
2678
|
+
fleet.ensureTimer()
|
|
2679
|
+
fleet.update()
|
|
1564
2680
|
|
|
1565
|
-
//
|
|
1566
|
-
|
|
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
|
|
1569
|
-
|
|
1570
|
-
|
|
1571
|
-
|
|
1572
|
-
|
|
1573
|
-
|
|
1574
|
-
|
|
1575
|
-
|
|
1576
|
-
|
|
1577
|
-
|
|
1578
|
-
|
|
1579
|
-
|
|
1580
|
-
|
|
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
|
-
|
|
1606
|
-
|
|
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
|
-
|
|
1611
|
-
|
|
1612
|
-
|
|
1613
|
-
|
|
1614
|
-
|
|
1615
|
-
|
|
1616
|
-
|
|
1617
|
-
|
|
1618
|
-
|
|
1619
|
-
|
|
1620
|
-
|
|
1621
|
-
|
|
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
|
-
|
|
1626
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1667
|
-
|
|
1668
|
-
|
|
1669
|
-
|
|
1670
|
-
|
|
1671
|
-
|
|
1672
|
-
|
|
1673
|
-
|
|
1674
|
-
|
|
1675
|
-
|
|
1676
|
-
|
|
1677
|
-
|
|
1678
|
-
|
|
1679
|
-
|
|
1680
|
-
)
|
|
1681
|
-
|
|
1682
|
-
|
|
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
|
|
1709
|
-
|
|
1710
|
-
|
|
1711
|
-
|
|
1712
|
-
|
|
1713
|
-
|
|
1714
|
-
|
|
1715
|
-
|
|
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
|
-
|
|
1727
|
-
|
|
1728
|
-
if (
|
|
1729
|
-
|
|
1730
|
-
|
|
1731
|
-
|
|
1732
|
-
|
|
1733
|
-
|
|
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
|
-
|
|
1780
|
-
|
|
1781
|
-
|
|
1782
|
-
|
|
1783
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1812
|
-
|
|
1813
|
-
|
|
1814
|
-
|
|
1815
|
-
|
|
1816
|
-
|
|
1817
|
-
|
|
1818
|
-
|
|
1819
|
-
|
|
1820
|
-
|
|
1821
|
-
|
|
1822
|
-
|
|
1823
|
-
|
|
1824
|
-
|
|
1825
|
-
|
|
1826
|
-
|
|
1827
|
-
|
|
1828
|
-
|
|
1829
|
-
|
|
1830
|
-
|
|
1831
|
-
//
|
|
1832
|
-
//
|
|
1833
|
-
|
|
1834
|
-
|
|
1835
|
-
|
|
1836
|
-
|
|
1837
|
-
|
|
1838
|
-
|
|
1839
|
-
|
|
1840
|
-
|
|
1841
|
-
|
|
1842
|
-
|
|
1843
|
-
|
|
1844
|
-
|
|
1845
|
-
|
|
1846
|
-
|
|
1847
|
-
|
|
1848
|
-
|
|
1849
|
-
|
|
1850
|
-
|
|
1851
|
-
|
|
1852
|
-
|
|
1853
|
-
|
|
1854
|
-
|
|
1855
|
-
|
|
1856
|
-
|
|
1857
|
-
|
|
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
|
-
|
|
1878
|
-
|
|
1879
|
-
|
|
1880
|
-
|
|
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
|
-
|
|
1884
|
-
|
|
1885
|
-
|
|
1886
|
-
|
|
1887
|
-
|
|
1888
|
-
|
|
1889
|
-
|
|
1890
|
-
|
|
1891
|
-
|
|
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
|
-
|
|
1894
|
-
|
|
1895
|
-
|
|
1896
|
-
|
|
1897
|
-
|
|
1898
|
-
|
|
1899
|
-
|
|
1900
|
-
|
|
1901
|
-
|
|
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
|
-
|
|
1905
|
-
|
|
1906
|
-
|
|
1907
|
-
|
|
1908
|
-
|
|
1909
|
-
|
|
1910
|
-
|
|
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
|
-
|
|
1929
|
-
|
|
1930
|
-
|
|
1931
|
-
|
|
1932
|
-
|
|
1933
|
-
|
|
1934
|
-
|
|
1935
|
-
|
|
1936
|
-
|
|
1937
|
-
|
|
1938
|
-
|
|
1939
|
-
|
|
1940
|
-
|
|
1941
|
-
|
|
1942
|
-
|
|
1943
|
-
|
|
1944
|
-
|
|
1945
|
-
|
|
1946
|
-
|
|
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
|
-
|
|
1951
|
-
|
|
1952
|
-
|
|
1953
|
-
|
|
1954
|
-
|
|
1955
|
-
|
|
1956
|
-
|
|
1957
|
-
|
|
1958
|
-
|
|
1959
|
-
|
|
1960
|
-
|
|
1961
|
-
|
|
1962
|
-
|
|
1963
|
-
|
|
1964
|
-
|
|
1965
|
-
|
|
1966
|
-
|
|
1967
|
-
|
|
1968
|
-
|
|
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
|
-
|
|
1974
|
-
|
|
1975
|
-
|
|
1976
|
-
|
|
1977
|
-
|
|
1978
|
-
|
|
1979
|
-
|
|
1980
|
-
|
|
1981
|
-
|
|
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
|
-
|
|
1991
|
-
|
|
1992
|
-
|
|
1993
|
-
|
|
1994
|
-
|
|
1995
|
-
|
|
1996
|
-
|
|
1997
|
-
|
|
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
|
-
|
|
2001
|
-
|
|
2002
|
-
|
|
2003
|
-
|
|
2004
|
-
|
|
2005
|
-
|
|
2006
|
-
|
|
2007
|
-
|
|
2008
|
-
|
|
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
|
-
|
|
2012
|
-
|
|
2013
|
-
|
|
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
|
-
|
|
2018
|
-
|
|
2019
|
-
|
|
2020
|
-
type:
|
|
2021
|
-
|
|
2022
|
-
|
|
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
|
-
|
|
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
|
-
|
|
2052
|
-
|
|
2053
|
-
|
|
2054
|
-
|
|
2055
|
-
|
|
2056
|
-
|
|
2057
|
-
|
|
2058
|
-
|
|
2059
|
-
|
|
2060
|
-
|
|
2061
|
-
|
|
2062
|
-
|
|
2063
|
-
|
|
2064
|
-
|
|
2065
|
-
|
|
2066
|
-
|
|
2067
|
-
|
|
2068
|
-
|
|
2069
|
-
|
|
2070
|
-
|
|
2071
|
-
|
|
2072
|
-
|
|
2073
|
-
|
|
2074
|
-
|
|
2075
|
-
|
|
2076
|
-
|
|
2077
|
-
|
|
2078
|
-
|
|
2079
|
-
|
|
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
|
-
|
|
2084
|
-
|
|
2085
|
-
|
|
2086
|
-
|
|
2087
|
-
|
|
2088
|
-
//
|
|
2089
|
-
|
|
2090
|
-
|
|
2091
|
-
|
|
2092
|
-
|
|
2093
|
-
|
|
2094
|
-
|
|
2095
|
-
|
|
2096
|
-
|
|
2097
|
-
|
|
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
|
-
|
|
2121
|
-
|
|
2122
|
-
|
|
2123
|
-
|
|
2124
|
-
|
|
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
|
-
|
|
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
|
-
|
|
2129
|
-
|
|
2130
|
-
|
|
2131
|
-
|
|
2132
|
-
|
|
2133
|
-
|
|
2134
|
-
|
|
2135
|
-
|
|
2136
|
-
|
|
2137
|
-
|
|
2138
|
-
|
|
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
|
-
|
|
2172
|
-
|
|
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
|
-
|
|
2175
|
-
|
|
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
|
-
|
|
2179
|
-
|
|
2180
|
-
|
|
2181
|
-
|
|
2182
|
-
|
|
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
|
-
|
|
2187
|
-
|
|
2188
|
-
|
|
2189
|
-
|
|
2190
|
-
|
|
2191
|
-
|
|
2192
|
-
|
|
2193
|
-
|
|
2194
|
-
|
|
2195
|
-
|
|
2196
|
-
|
|
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
|
|
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:
|
|
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 =
|
|
2231
|
-
if (!record || record
|
|
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:
|
|
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 =
|
|
2328
|
-
if (!record || record
|
|
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(
|
|
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(
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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
|
|
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
|
-
"
|
|
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
|
|
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
|
|
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:
|
|
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:
|
|
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:
|
|
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
|
-
"
|
|
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
|
|
3319
|
-
: "Agent calls block unless
|
|
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
|
-
|
|
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
|
-
|
|
4817
|
+
ctx: ExtensionCommandContext | undefined,
|
|
4818
|
+
changeMsg: string,
|
|
3507
4819
|
): void {
|
|
3508
4820
|
const { message, level } = saveAndEmitChanged(
|
|
3509
4821
|
snapshotSettings(),
|
|
3510
|
-
|
|
4822
|
+
changeMsg,
|
|
3511
4823
|
(event, payload) => pi.events.emit(event, payload),
|
|
3512
4824
|
)
|
|
3513
|
-
|
|
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
|
}
|