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