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