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