@esso0428/pi-subagents 0.17.6 → 0.17.8
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +14 -0
- package/CONTRIBUTING.md +4 -0
- package/dist/abortable.d.ts +13 -0
- package/dist/abortable.d.ts.map +1 -0
- package/dist/abortable.js +43 -0
- package/dist/abortable.js.map +1 -0
- package/dist/agent-color.d.ts +36 -0
- package/dist/agent-color.d.ts.map +1 -0
- package/dist/agent-color.js +124 -0
- package/dist/agent-color.js.map +1 -0
- package/dist/agent-file-toggle.d.ts +126 -0
- package/dist/agent-file-toggle.d.ts.map +1 -0
- package/dist/agent-file-toggle.js +259 -0
- package/dist/agent-file-toggle.js.map +1 -0
- package/dist/agent-history.d.ts +4 -0
- package/dist/agent-history.d.ts.map +1 -1
- package/dist/agent-history.js +47 -1
- package/dist/agent-history.js.map +1 -1
- package/dist/agent-manager.d.ts +370 -56
- package/dist/agent-manager.d.ts.map +1 -1
- package/dist/agent-manager.js +1123 -409
- package/dist/agent-manager.js.map +1 -1
- package/dist/agent-runner.d.ts +100 -10
- package/dist/agent-runner.d.ts.map +1 -1
- package/dist/agent-runner.js +166 -21
- package/dist/agent-runner.js.map +1 -1
- package/dist/agent-types.d.ts +57 -5
- package/dist/agent-types.d.ts.map +1 -1
- package/dist/agent-types.js +164 -32
- package/dist/agent-types.js.map +1 -1
- package/dist/child-context.d.ts +3 -0
- package/dist/child-context.d.ts.map +1 -0
- package/dist/child-context.js +13 -0
- package/dist/child-context.js.map +1 -0
- package/dist/cross-extension-rpc.d.ts +23 -3
- package/dist/cross-extension-rpc.d.ts.map +1 -1
- package/dist/cross-extension-rpc.js +79 -17
- package/dist/cross-extension-rpc.js.map +1 -1
- package/dist/custom-agents.d.ts +38 -1
- package/dist/custom-agents.d.ts.map +1 -1
- package/dist/custom-agents.js +164 -12
- package/dist/custom-agents.js.map +1 -1
- package/dist/index.d.ts +34 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1912 -492
- package/dist/index.js.map +1 -1
- package/dist/invocation-config.d.ts +87 -2
- package/dist/invocation-config.d.ts.map +1 -1
- package/dist/invocation-config.js +71 -3
- package/dist/invocation-config.js.map +1 -1
- package/dist/mention-clone.d.ts +88 -0
- package/dist/mention-clone.d.ts.map +1 -0
- package/dist/mention-clone.js +154 -0
- package/dist/mention-clone.js.map +1 -0
- package/dist/mention.d.ts +82 -0
- package/dist/mention.d.ts.map +1 -0
- package/dist/mention.js +132 -0
- package/dist/mention.js.map +1 -0
- package/dist/model-resolver.d.ts +17 -0
- package/dist/model-resolver.d.ts.map +1 -1
- package/dist/model-resolver.js +15 -0
- package/dist/model-resolver.js.map +1 -1
- package/dist/model-scope.d.ts +50 -0
- package/dist/model-scope.d.ts.map +1 -0
- package/dist/model-scope.js +49 -0
- package/dist/model-scope.js.map +1 -0
- package/dist/nested-tools.d.ts +57 -0
- package/dist/nested-tools.d.ts.map +1 -0
- package/dist/nested-tools.js +301 -0
- package/dist/nested-tools.js.map +1 -0
- package/dist/output-file.d.ts +22 -3
- package/dist/output-file.d.ts.map +1 -1
- package/dist/output-file.js +58 -7
- package/dist/output-file.js.map +1 -1
- package/dist/prompts.d.ts +23 -0
- package/dist/prompts.d.ts.map +1 -1
- package/dist/prompts.js +20 -2
- package/dist/prompts.js.map +1 -1
- package/dist/schedule.d.ts.map +1 -1
- package/dist/schedule.js +36 -15
- package/dist/schedule.js.map +1 -1
- package/dist/settings.d.ts +228 -2
- package/dist/settings.d.ts.map +1 -1
- package/dist/settings.js +94 -0
- package/dist/settings.js.map +1 -1
- package/dist/status-note.d.ts +49 -1
- package/dist/status-note.d.ts.map +1 -1
- package/dist/status-note.js +62 -1
- package/dist/status-note.js.map +1 -1
- package/dist/structured-output.d.ts +62 -0
- package/dist/structured-output.d.ts.map +1 -0
- package/dist/structured-output.js +113 -0
- package/dist/structured-output.js.map +1 -0
- package/dist/types.d.ts +176 -10
- package/dist/types.d.ts.map +1 -1
- package/dist/ui/agent-mention.d.ts +83 -0
- package/dist/ui/agent-mention.d.ts.map +1 -0
- package/dist/ui/agent-mention.js +188 -0
- package/dist/ui/agent-mention.js.map +1 -0
- package/dist/ui/agent-widget.d.ts +97 -75
- package/dist/ui/agent-widget.d.ts.map +1 -1
- package/dist/ui/agent-widget.js +398 -420
- package/dist/ui/agent-widget.js.map +1 -1
- package/dist/ui/conversation-blocks.d.ts.map +1 -1
- package/dist/ui/conversation-blocks.js +6 -0
- package/dist/ui/conversation-blocks.js.map +1 -1
- package/dist/ui/conversation-timeline.d.ts +10 -2
- package/dist/ui/conversation-timeline.d.ts.map +1 -1
- package/dist/ui/conversation-timeline.js +130 -23
- package/dist/ui/conversation-timeline.js.map +1 -1
- package/dist/ui/conversation-viewer.d.ts +15 -5
- package/dist/ui/conversation-viewer.d.ts.map +1 -1
- package/dist/ui/conversation-viewer.js +202 -50
- package/dist/ui/conversation-viewer.js.map +1 -1
- package/dist/ui/fleet-list.d.ts +198 -0
- package/dist/ui/fleet-list.d.ts.map +1 -0
- package/dist/ui/fleet-list.js +487 -0
- package/dist/ui/fleet-list.js.map +1 -0
- package/dist/ui/schedule-menu.d.ts.map +1 -1
- package/dist/ui/schedule-menu.js +6 -7
- package/dist/ui/schedule-menu.js.map +1 -1
- package/dist/ui/select-item.d.ts +28 -0
- package/dist/ui/select-item.d.ts.map +1 -0
- package/dist/ui/select-item.js +35 -0
- package/dist/ui/select-item.js.map +1 -0
- package/dist/ui/workflow-card.d.ts +176 -0
- package/dist/ui/workflow-card.d.ts.map +1 -0
- package/dist/ui/workflow-card.js +333 -0
- package/dist/ui/workflow-card.js.map +1 -0
- package/dist/ui/workflow-dialog.d.ts +306 -0
- package/dist/ui/workflow-dialog.d.ts.map +1 -0
- package/dist/ui/workflow-dialog.js +844 -0
- package/dist/ui/workflow-dialog.js.map +1 -0
- package/dist/ui/workflow-menu.d.ts +61 -0
- package/dist/ui/workflow-menu.d.ts.map +1 -0
- package/dist/ui/workflow-menu.js +148 -0
- package/dist/ui/workflow-menu.js.map +1 -0
- package/dist/usage.d.ts +86 -1
- package/dist/usage.d.ts.map +1 -1
- package/dist/usage.js +72 -1
- package/dist/usage.js.map +1 -1
- package/dist/workflow/collisions.d.ts +96 -0
- package/dist/workflow/collisions.d.ts.map +1 -0
- package/dist/workflow/collisions.js +89 -0
- package/dist/workflow/collisions.js.map +1 -0
- package/dist/workflow/entry.d.ts +33 -0
- package/dist/workflow/entry.d.ts.map +1 -0
- package/dist/workflow/entry.js +30 -0
- package/dist/workflow/entry.js.map +1 -0
- package/dist/workflow/host.d.ts +63 -0
- package/dist/workflow/host.d.ts.map +1 -0
- package/dist/workflow/host.js +363 -0
- package/dist/workflow/host.js.map +1 -0
- package/dist/workflow/journal.d.ts +98 -0
- package/dist/workflow/journal.d.ts.map +1 -0
- package/dist/workflow/journal.js +121 -0
- package/dist/workflow/journal.js.map +1 -0
- package/dist/workflow/json-schema.d.ts +52 -0
- package/dist/workflow/json-schema.d.ts.map +1 -0
- package/dist/workflow/json-schema.js +112 -0
- package/dist/workflow/json-schema.js.map +1 -0
- package/dist/workflow/meta.d.ts +68 -0
- package/dist/workflow/meta.d.ts.map +1 -0
- package/dist/workflow/meta.js +318 -0
- package/dist/workflow/meta.js.map +1 -0
- package/dist/workflow/progress.d.ts +225 -0
- package/dist/workflow/progress.d.ts.map +1 -0
- package/dist/workflow/progress.js +362 -0
- package/dist/workflow/progress.js.map +1 -0
- package/dist/workflow/runtime.d.ts +335 -0
- package/dist/workflow/runtime.d.ts.map +1 -0
- package/dist/workflow/runtime.js +831 -0
- package/dist/workflow/runtime.js.map +1 -0
- package/dist/workflow/saved.d.ts +91 -0
- package/dist/workflow/saved.d.ts.map +1 -0
- package/dist/workflow/saved.js +204 -0
- package/dist/workflow/saved.js.map +1 -0
- package/dist/workflow/task.d.ts +137 -0
- package/dist/workflow/task.d.ts.map +1 -0
- package/dist/workflow/task.js +208 -0
- package/dist/workflow/task.js.map +1 -0
- package/dist/workflow/tool-description.d.ts +39 -0
- package/dist/workflow/tool-description.d.ts.map +1 -0
- package/dist/workflow/tool-description.js +200 -0
- package/dist/workflow/tool-description.js.map +1 -0
- package/dist/workflow/worker-source.d.ts +48 -0
- package/dist/workflow/worker-source.d.ts.map +1 -0
- package/dist/workflow/worker-source.js +779 -0
- package/dist/workflow/worker-source.js.map +1 -0
- package/dist/worktree.d.ts +10 -3
- package/dist/worktree.d.ts.map +1 -1
- package/dist/worktree.js +58 -54
- package/dist/worktree.js.map +1 -1
- package/dist/xml.d.ts +11 -0
- package/dist/xml.d.ts.map +1 -0
- package/dist/xml.js +13 -0
- package/dist/xml.js.map +1 -0
- package/docs/rpc.md +183 -0
- package/docs/superpowers/plans/2026-09-30-upstream-event-workflow-partial-history.md +195 -0
- package/docs/superpowers/specs/2026-09-30-upstream-event-workflow-partial-history-design.md +49 -0
- package/docs/workflows.md +437 -0
- package/examples/agent-tool-description.md +7 -7
- package/examples/workflows/compose.js +51 -0
- package/examples/workflows/fan-out-audit.js +47 -0
- package/examples/workflows/gated-fix.js +60 -0
- package/examples/workflows/lib/count-child.js +27 -0
- package/examples/workflows/review-panel.js +63 -0
- package/examples/workflows/structured-findings.js +78 -0
- package/package.json +1 -1
- package/src/abortable.ts +43 -0
- package/src/agent-color.ts +161 -0
- package/src/agent-file-toggle.ts +269 -0
- package/src/agent-history.ts +54 -2
- package/src/agent-manager.ts +1263 -402
- package/src/agent-runner.ts +251 -27
- package/src/agent-types.ts +188 -32
- package/src/child-context.ts +15 -0
- package/src/cross-extension-rpc.ts +96 -20
- package/src/custom-agents.ts +170 -13
- package/src/index.ts +2029 -536
- package/src/invocation-config.ts +118 -3
- package/src/mention-clone.ts +196 -0
- package/src/mention.ts +141 -0
- package/src/model-resolver.ts +18 -0
- package/src/model-scope.ts +70 -0
- package/src/nested-tools.ts +424 -0
- package/src/output-file.ts +61 -6
- package/src/prompts.ts +45 -2
- package/src/schedule.ts +35 -14
- package/src/settings.ts +312 -2
- package/src/status-note.ts +66 -1
- package/src/structured-output.ts +130 -0
- package/src/types.ts +177 -10
- package/src/ui/agent-mention.ts +216 -0
- package/src/ui/agent-widget.ts +393 -441
- package/src/ui/conversation-blocks.ts +6 -0
- package/src/ui/conversation-timeline.ts +139 -25
- package/src/ui/conversation-viewer.ts +212 -48
- package/src/ui/fleet-list.ts +558 -0
- package/src/ui/schedule-menu.ts +9 -8
- package/src/ui/select-item.ts +45 -0
- package/src/ui/workflow-card.ts +470 -0
- package/src/ui/workflow-dialog.ts +1115 -0
- package/src/ui/workflow-menu.ts +193 -0
- package/src/usage.ts +109 -2
- package/src/workflow/collisions.ts +123 -0
- package/src/workflow/entry.ts +47 -0
- package/src/workflow/host.ts +403 -0
- package/src/workflow/journal.ts +164 -0
- package/src/workflow/json-schema.ts +128 -0
- package/src/workflow/meta.ts +325 -0
- package/src/workflow/progress.ts +550 -0
- package/src/workflow/runtime.ts +1219 -0
- package/src/workflow/saved.ts +217 -0
- package/src/workflow/task.ts +302 -0
- package/src/workflow/tool-description.ts +200 -0
- package/src/workflow/worker-source.ts +781 -0
- package/src/worktree.ts +69 -55
- package/src/xml.ts +13 -0
- package/vitest.config.ts +0 -18
package/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,71 @@ 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
1069
|
const widget = new AgentWidget(manager, agentActivity, getWidgetMode, {
|
|
574
1070
|
canOpenHistory: (record) => canOpenAgentHistory(record, currentCtx?.cwd),
|
|
575
|
-
onOpen: (record
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
void viewAgentConversation(ctx, record, mode);
|
|
1071
|
+
onOpen: (record) => {
|
|
1072
|
+
if (currentCtx)
|
|
1073
|
+
void viewAgentConversation(currentCtx, record);
|
|
579
1074
|
},
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
//
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
function
|
|
590
|
-
|
|
1075
|
+
showCost: isShowCostEnabled,
|
|
1076
|
+
}, isShowModelEnabled);
|
|
1077
|
+
function setWidgetMode(m) { widgetMode = m; widget.update(); }
|
|
1078
|
+
// Claude Code-style FleetView: navigable list of main + subagents below the editor.
|
|
1079
|
+
// The last two arguments keep a conversation overlay opened here identical to
|
|
1080
|
+
// one opened from `/agents`: same setting on the way in, same persist out.
|
|
1081
|
+
const fleet = new FleetList(manager, agentActivity, isShowCostEnabled, getViewerMarkdown, (mode) => chooseViewerMarkdown(mode, currentCtx), process.cwd());
|
|
1082
|
+
let fleetViewEnabled = true;
|
|
1083
|
+
function isFleetViewEnabled() { return fleetViewEnabled; }
|
|
1084
|
+
function setFleetViewEnabled(b) { fleetViewEnabled = b; fleet.setEnabled(b); }
|
|
1085
|
+
// Claude Code-style `@handle message` prompt mentions. Read live by both the
|
|
1086
|
+
// `input` hook and the stacked autocomplete provider, so the toggle applies
|
|
1087
|
+
// immediately — the provider itself can never be unregistered (pi's wrapper
|
|
1088
|
+
// list is append-only), it just delegates everything when this is off.
|
|
1089
|
+
let agentMentionMode = "model";
|
|
1090
|
+
function getAgentMentionMode() { return agentMentionMode; }
|
|
1091
|
+
function setAgentMentionMode(mode) { agentMentionMode = mode; }
|
|
1092
|
+
// `model` and `direct` differ only in who starts a not-yet-running agent, so
|
|
1093
|
+
// everything that just asks "are mentions live at all" — the suggestion list,
|
|
1094
|
+
// the steer and resume branches — reads this instead of the mode.
|
|
1095
|
+
function isAgentMentionsEnabled() { return agentMentionMode !== "off"; }
|
|
1096
|
+
// Project/global default for writing the subagent .output transcript lives in
|
|
1097
|
+
// output-file.ts (both spawn paths read it). A custom agent's
|
|
1098
|
+
// `output_transcript` frontmatter overrides it per spawn; when the frontmatter
|
|
1099
|
+
// is silent, this default applies. Read live at spawn time.
|
|
591
1100
|
// ---- Join mode configuration ----
|
|
592
1101
|
let defaultJoinMode = 'smart';
|
|
593
1102
|
function getDefaultJoinMode() { return defaultJoinMode; }
|
|
594
1103
|
function setDefaultJoinMode(mode) { defaultJoinMode = mode; }
|
|
1104
|
+
// What an unqualified top-level spawn means. Defaults to background,
|
|
1105
|
+
// following Claude Code; `backgroundByDefault: false` restores the previous
|
|
1106
|
+
// foreground default. Nested spawns ignore this — see nested-tools.ts.
|
|
1107
|
+
let backgroundByDefault = true;
|
|
1108
|
+
function getBackgroundByDefault() { return backgroundByDefault; }
|
|
1109
|
+
function setBackgroundByDefault(b) { backgroundByDefault = b; }
|
|
595
1110
|
// Master switch for the schedule subagent feature. Defaults to enabled.
|
|
596
1111
|
// Read once at extension init (before tool registration) so the Agent tool's
|
|
597
1112
|
// param schema reflects the persisted setting. Runtime toggles via /agents
|
|
@@ -601,16 +1116,25 @@ export default function (pi) {
|
|
|
601
1116
|
let schedulingEnabled = true;
|
|
602
1117
|
function isSchedulingEnabled() { return schedulingEnabled; }
|
|
603
1118
|
function setSchedulingEnabled(b) { schedulingEnabled = b; }
|
|
604
|
-
//
|
|
605
|
-
//
|
|
606
|
-
//
|
|
607
|
-
//
|
|
608
|
-
//
|
|
609
|
-
//
|
|
610
|
-
//
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
1119
|
+
// Master switch for scripted workflows. Defaults to ON. Off means the
|
|
1120
|
+
// `SubagentWorkflow` tool is never registered: the model is not told the
|
|
1121
|
+
// feature exists (zero context cost) and has nothing to call. The
|
|
1122
|
+
// `/agents → Workflows` view and `--subagents-workflow-file` are refused too, so
|
|
1123
|
+
// there is no second door into the same machinery.
|
|
1124
|
+
//
|
|
1125
|
+
// `workflowsPinned` records that the answer came from the user — a boolean in
|
|
1126
|
+
// subagents.json, or the settings toggle — rather than from this default. It
|
|
1127
|
+
// is what `resolveWorkflowCollisions` checks before yielding to another
|
|
1128
|
+
// extension's workflow tool: a default may be overridden by what else is
|
|
1129
|
+
// loaded, an explicit choice may not.
|
|
1130
|
+
let workflowsEnabled = true;
|
|
1131
|
+
let workflowsPinned = false;
|
|
1132
|
+
function isWorkflowsEnabled() { return workflowsEnabled; }
|
|
1133
|
+
function isWorkflowsPinned() { return workflowsPinned; }
|
|
1134
|
+
function setWorkflowsEnabled(b) {
|
|
1135
|
+
workflowsEnabled = b;
|
|
1136
|
+
workflowsPinned = true;
|
|
1137
|
+
}
|
|
614
1138
|
// ---- Disable default agents configuration ----
|
|
615
1139
|
// When enabled, the three hardcoded default agents (general-purpose, Explore,
|
|
616
1140
|
// Plan) are not registered. User-defined agents from project/global custom
|
|
@@ -671,20 +1195,99 @@ export default function (pi) {
|
|
|
671
1195
|
}
|
|
672
1196
|
}
|
|
673
1197
|
}
|
|
1198
|
+
/**
|
|
1199
|
+
* Launch a detached resume of an existing agent and wire everything a
|
|
1200
|
+
* re-running agent needs: transcript anchoring, activity tracking, join-mode
|
|
1201
|
+
* batching, the widget/fleet refresh, and the `subagents:created` event.
|
|
1202
|
+
*
|
|
1203
|
+
* Shared by the Agent tool's `resume` + `run_in_background` branch and the
|
|
1204
|
+
* `@handle message` prompt mention — they differ only in how they report the
|
|
1205
|
+
* outcome. Returns the record, or undefined when the manager refused because
|
|
1206
|
+
* the agent is still running (see AgentManager.resume).
|
|
1207
|
+
*
|
|
1208
|
+
* Callers must have already established that the record has a session.
|
|
1209
|
+
*/
|
|
1210
|
+
async function startBackgroundResume(ctx, existing, prompt, opts) {
|
|
1211
|
+
const id = existing.id;
|
|
1212
|
+
const joinMode = resolveJoinMode(defaultJoinMode, true);
|
|
1213
|
+
// Assigned unconditionally: the completion notification carries this as
|
|
1214
|
+
// `<tool-use-id>`, so a mention-resume (which passes none) has to CLEAR the
|
|
1215
|
+
// id left by the spawn that created the record. Keeping it would point the
|
|
1216
|
+
// orchestrator's new result at a tool call that was answered runs ago.
|
|
1217
|
+
existing.toolCallId = opts.toolCallId;
|
|
1218
|
+
if (joinMode)
|
|
1219
|
+
existing.joinMode = joinMode;
|
|
1220
|
+
// Reuse the agent's transcript rather than starting a fresh one: the
|
|
1221
|
+
// path is deterministic per agent+session, so writing an initial entry
|
|
1222
|
+
// would truncate the previous run's turns (see ensureOutputFile).
|
|
1223
|
+
if (opts.outputTranscript) {
|
|
1224
|
+
existing.outputFile = createOutputFilePath(ctx.cwd, id, ctx.sessionManager.getSessionId());
|
|
1225
|
+
ensureOutputFile(existing.outputFile);
|
|
1226
|
+
}
|
|
1227
|
+
// Anchor streaming past the turns already on disk, captured BEFORE the
|
|
1228
|
+
// run starts. The resumed prompt lands as an ordinary user message at
|
|
1229
|
+
// this index, so it is written exactly once.
|
|
1230
|
+
const transcriptAnchor = existing.session?.messages.length ?? 0;
|
|
1231
|
+
const { state: bgState, callbacks: bgCallbacks } = createActivityTracker(opts.maxTurns);
|
|
1232
|
+
// resumeAgent has no onSessionCreated — the session predates this run —
|
|
1233
|
+
// so seed it directly, or the widget shows no context % for the agent.
|
|
1234
|
+
bgState.session = existing.session;
|
|
1235
|
+
// No `signal`: a background spawn deliberately omits it, and a detached
|
|
1236
|
+
// resume must behave the same. Passing it would abort this agent when
|
|
1237
|
+
// the parent turn is interrupted (user Esc), while agents started with
|
|
1238
|
+
// run_in_background in that same turn keep going.
|
|
1239
|
+
const record = await manager.resume(id, prompt, undefined, {
|
|
1240
|
+
isBackground: true,
|
|
1241
|
+
onToolActivity: bgCallbacks.onToolActivity,
|
|
1242
|
+
onAssistantUsage: bgCallbacks.onAssistantUsage,
|
|
1243
|
+
// Fires when the run actually starts — immediately, or on queue
|
|
1244
|
+
// drain. Wiring it here (rather than after resume() returns) means a
|
|
1245
|
+
// resume stopped while still queued never started streaming, so
|
|
1246
|
+
// there is no subscription left behind for a later run to trip over.
|
|
1247
|
+
onStarted: () => {
|
|
1248
|
+
const rec = manager.getRecord(id);
|
|
1249
|
+
if (rec?.session && rec.outputFile) {
|
|
1250
|
+
rec.outputCleanup = streamToOutputFile(rec.session, rec.outputFile, id, ctx.cwd, transcriptAnchor);
|
|
1251
|
+
}
|
|
1252
|
+
},
|
|
1253
|
+
});
|
|
1254
|
+
if (!record)
|
|
1255
|
+
return undefined;
|
|
1256
|
+
if (joinMode != null && joinMode !== 'async') {
|
|
1257
|
+
currentBatchAgents.push({ id, joinMode });
|
|
1258
|
+
if (batchFinalizeTimer)
|
|
1259
|
+
clearTimeout(batchFinalizeTimer);
|
|
1260
|
+
batchFinalizeTimer = setTimeout(finalizeBatch, 100);
|
|
1261
|
+
}
|
|
1262
|
+
agentActivity.set(id, bgState);
|
|
1263
|
+
// This agent already finished once, so the widget holds a finished-age
|
|
1264
|
+
// for it that is past the linger limit — without clearing it, the
|
|
1265
|
+
// resumed run's ✓/✗ line never renders and the agent just vanishes.
|
|
1266
|
+
widget.markRunning(id);
|
|
1267
|
+
widget.ensureTimer();
|
|
1268
|
+
widget.update();
|
|
1269
|
+
fleet.ensureTimer();
|
|
1270
|
+
fleet.update();
|
|
1271
|
+
// Resume ignores subagent_type (the record keeps the type it was
|
|
1272
|
+
// spawned with), so report the record's own identity — a "created"
|
|
1273
|
+
// event carrying the caller's type would re-register the agent under
|
|
1274
|
+
// the wrong one in cross-extension mirrors keyed by id.
|
|
1275
|
+
pi.events.emit("subagents:created", {
|
|
1276
|
+
id,
|
|
1277
|
+
type: existing.type,
|
|
1278
|
+
description: existing.description,
|
|
1279
|
+
isBackground: true,
|
|
1280
|
+
});
|
|
1281
|
+
return record;
|
|
1282
|
+
}
|
|
674
1283
|
// Grab UI context from first tool execution + clear lingering widget on new turn
|
|
675
1284
|
pi.on("tool_execution_start", async (_event, ctx) => {
|
|
676
|
-
|
|
1285
|
+
if (ctx.hasUI && (ctx.mode === undefined || ctx.mode === "tui"))
|
|
1286
|
+
widget.setUICtx(ctx.ui);
|
|
1287
|
+
if (ctx.hasUI && ctx.mode === undefined)
|
|
1288
|
+
fleet.setUICtx(ctx.ui, true);
|
|
677
1289
|
widget.onTurnStart();
|
|
678
1290
|
});
|
|
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
1291
|
/** Build the full type list text dynamically from available agents only. */
|
|
689
1292
|
const buildTypeListText = () => {
|
|
690
1293
|
const available = getAvailableTypes();
|
|
@@ -717,15 +1320,29 @@ export default function (pi) {
|
|
|
717
1320
|
// to stderr and falls back to defaults.
|
|
718
1321
|
applyAndEmitLoaded({
|
|
719
1322
|
setMaxConcurrent: (n) => manager.setMaxConcurrent(n),
|
|
1323
|
+
setMaxConcurrentForeground: (n) => manager.setMaxConcurrentForeground(n),
|
|
720
1324
|
setDefaultMaxTurns,
|
|
721
1325
|
setGraceTurns,
|
|
722
1326
|
setDefaultJoinMode,
|
|
1327
|
+
setBackgroundByDefault,
|
|
723
1328
|
setSchedulingEnabled,
|
|
724
1329
|
setScopeModels: setScopeModelsEnabled,
|
|
1330
|
+
setStrictAgentFiles: (b) => { strictAgentFiles = b; },
|
|
725
1331
|
setDisableDefaultAgents: setDisableDefaultAgents,
|
|
726
1332
|
setToolDescriptionMode: setToolDescriptionMode,
|
|
1333
|
+
setFleetView: setFleetViewEnabled,
|
|
1334
|
+
setAgentMentions: setAgentMentionMode,
|
|
1335
|
+
setRememberAgents,
|
|
727
1336
|
setWidgetMode: setWidgetMode,
|
|
728
|
-
setOutputTranscript:
|
|
1337
|
+
setOutputTranscript: setOutputTranscriptDefault,
|
|
1338
|
+
setWorktreeIsolation: setWorktreeIsolationEnabled,
|
|
1339
|
+
setWorkflowsEnabled: setWorkflowsEnabled,
|
|
1340
|
+
setMaxSubagentDepth: setMaxSubagentDepth,
|
|
1341
|
+
setFallbackSubagent: setFallbackSubagent,
|
|
1342
|
+
setReportUsage,
|
|
1343
|
+
setShowCost,
|
|
1344
|
+
setShowModel,
|
|
1345
|
+
setViewerMarkdown,
|
|
729
1346
|
}, (event, payload) => pi.events.emit(event, payload));
|
|
730
1347
|
// ---- Agent tool ----
|
|
731
1348
|
// Schedule param + its guideline are gated on `schedulingEnabled` (read once
|
|
@@ -744,6 +1361,19 @@ export default function (pi) {
|
|
|
744
1361
|
const scheduleGuideline = isSchedulingEnabled()
|
|
745
1362
|
? `\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
1363
|
: "";
|
|
1364
|
+
// Same trade as scheduleParam/scheduleGuideline above: `isolationParam` drops
|
|
1365
|
+
// the field from the schema when the project set `worktreeIsolation: false`,
|
|
1366
|
+
// so the prose has to go with it. Left in, it would teach the model to pass a
|
|
1367
|
+
// parameter that isn't declared — accepted (TypeBox sets no
|
|
1368
|
+
// `additionalProperties: false`) and then silently dropped by the resolver.
|
|
1369
|
+
// With no per-result note by design, the model would have every reason to go
|
|
1370
|
+
// on reporting a `pi-agent-*` branch that was never created.
|
|
1371
|
+
const isolationGuideline = isWorktreeIsolationEnabled()
|
|
1372
|
+
? `\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.`
|
|
1373
|
+
: "";
|
|
1374
|
+
const isolationCompactGuideline = isWorktreeIsolationEnabled()
|
|
1375
|
+
? `\n- isolation: "worktree" gives the agent its own git worktree (removed on completion); changes land on a branch named in the result.`
|
|
1376
|
+
: "";
|
|
747
1377
|
// Compact Agent tool description (#91, `toolDescriptionMode: "compact"`) —
|
|
748
1378
|
// the same load-bearing facts as the full version at ~75% fewer tokens, for
|
|
749
1379
|
// small/local models. Per-option details live in the param descriptions.
|
|
@@ -754,10 +1384,10 @@ Custom agents: .pi/agents/<name>.md (project) or ${getAgentDir()}/agents/<name>.
|
|
|
754
1384
|
|
|
755
1385
|
Notes:
|
|
756
1386
|
- 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
|
|
1387
|
+
- Parallel work: one message, multiple Agent calls — they run concurrently.
|
|
1388
|
+
- 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
1389
|
- 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.`;
|
|
1390
|
+
- resume continues a previous agent by ID; steer_subagent messages a running one.${isolationCompactGuideline}`;
|
|
761
1391
|
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
1392
|
|
|
763
1393
|
Available agent types and the tools they have access to:
|
|
@@ -774,23 +1404,23 @@ If the target is already known, use a direct tool — \`read\` for a known path,
|
|
|
774
1404
|
## Usage notes
|
|
775
1405
|
|
|
776
1406
|
- 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
|
|
1407
|
+
- 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
1408
|
- 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:
|
|
1409
|
+
- 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.
|
|
1410
|
+
- 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.
|
|
1411
|
+
- **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.
|
|
1412
|
+
- **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
1413
|
- 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
1414
|
- Use steer_subagent to send mid-run messages to a running background agent.
|
|
784
1415
|
- 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
1416
|
- 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
1417
|
- Use model to specify a different model (as "provider/modelId", or fuzzy e.g. "haiku", "sonnet").
|
|
787
1418
|
- 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}
|
|
1419
|
+
- Use inherit_context if the agent needs the parent conversation history.${isolationGuideline}${scheduleGuideline}
|
|
790
1420
|
|
|
791
1421
|
## Writing the prompt
|
|
792
1422
|
|
|
793
|
-
|
|
1423
|
+
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
1424
|
- Explain what you're trying to accomplish and why.
|
|
795
1425
|
- Describe what you've already learned or ruled out.
|
|
796
1426
|
- Give enough context about the surrounding problem that the agent can make judgment calls rather than just following a narrow instruction.
|
|
@@ -809,6 +1439,7 @@ Terse command-style prompts produce shallow, generic work.
|
|
|
809
1439
|
typeList: buildTypeListText,
|
|
810
1440
|
compactTypeList: buildCompactTypeListText,
|
|
811
1441
|
agentDir: getAgentDir,
|
|
1442
|
+
isolationGuideline: () => isolationGuideline,
|
|
812
1443
|
scheduleGuideline: () => scheduleGuideline,
|
|
813
1444
|
};
|
|
814
1445
|
// Replacement callback (not a string) — agent descriptions may contain `$&` etc.
|
|
@@ -850,7 +1481,10 @@ Terse command-style prompts produce shallow, generic work.
|
|
|
850
1481
|
}
|
|
851
1482
|
return fullAgentToolDescription;
|
|
852
1483
|
})();
|
|
853
|
-
|
|
1484
|
+
// Held rather than registered inline: the mention clone reuses this exact
|
|
1485
|
+
// definition, so the agent it starts is an ordinary top-level spawn instead
|
|
1486
|
+
// of a second implementation that has to be kept in step with this one.
|
|
1487
|
+
const agentTool = defineTool({
|
|
854
1488
|
name: SUBAGENT_TOOL_NAMES.AGENT,
|
|
855
1489
|
label: "Agent",
|
|
856
1490
|
description: agentToolDescription,
|
|
@@ -868,6 +1502,9 @@ Terse command-style prompts produce shallow, generic work.
|
|
|
868
1502
|
description: Type.String({
|
|
869
1503
|
description: "A short (3-5 word) description of the task (shown in UI).",
|
|
870
1504
|
}),
|
|
1505
|
+
name: Type.Optional(Type.String({
|
|
1506
|
+
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.',
|
|
1507
|
+
})),
|
|
871
1508
|
subagent_type: Type.String({
|
|
872
1509
|
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
1510
|
}),
|
|
@@ -882,10 +1519,10 @@ Terse command-style prompts produce shallow, generic work.
|
|
|
882
1519
|
minimum: 1,
|
|
883
1520
|
})),
|
|
884
1521
|
run_in_background: Type.Optional(Type.Boolean({
|
|
885
|
-
description: "
|
|
1522
|
+
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
1523
|
})),
|
|
887
1524
|
resume: Type.Optional(Type.String({
|
|
888
|
-
description: "Optional agent ID to resume from. Continues from previous context.",
|
|
1525
|
+
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
1526
|
})),
|
|
890
1527
|
isolated: Type.Optional(Type.Boolean({
|
|
891
1528
|
description: "If true, agent gets no extension/MCP tools — only built-in tools.",
|
|
@@ -893,21 +1530,37 @@ Terse command-style prompts produce shallow, generic work.
|
|
|
893
1530
|
inherit_context: Type.Optional(Type.Boolean({
|
|
894
1531
|
description: "If true, fork parent conversation into the agent. Default: false (fresh context).",
|
|
895
1532
|
})),
|
|
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
|
-
})),
|
|
1533
|
+
...isolationParam(isWorktreeIsolationEnabled()),
|
|
899
1534
|
...scheduleParam,
|
|
900
1535
|
}),
|
|
901
1536
|
// ---- Custom rendering: Claude Code style ----
|
|
902
|
-
renderCall(args, theme) {
|
|
903
|
-
|
|
1537
|
+
renderCall(args, theme, context) {
|
|
1538
|
+
// A badge closes its own background, which would clear the tool block's row tint
|
|
1539
|
+
// for the rest of the line, so the badge restores it. The tint is opened here too:
|
|
1540
|
+
// the TUI's Box paints it, but HTML export takes it from CSS, and restoring a
|
|
1541
|
+
// background the line never opened is what banded the export before. The line is
|
|
1542
|
+
// deliberately left open — Box.applyBackgroundToLine pads to width and *then*
|
|
1543
|
+
// wraps, so closing here would leave that padding untinted, and HTML export closes
|
|
1544
|
+
// any open span per line anyway. No badge means no tint, so an uncolored agent
|
|
1545
|
+
// renders exactly the line it always did.
|
|
1546
|
+
const rowBackground = hasAgentBadge(args.subagent_type)
|
|
1547
|
+
? theme.getBgAnsi(context.isPartial ? "toolPendingBg" : context.isError ? "toolErrorBg" : "toolSuccessBg")
|
|
1548
|
+
: "";
|
|
904
1549
|
const desc = args.description ?? "";
|
|
905
|
-
|
|
1550
|
+
const name = renderAgentName(args.subagent_type, theme, {
|
|
1551
|
+
fallbackColor: "toolTitle",
|
|
1552
|
+
restoreBackground: rowBackground,
|
|
1553
|
+
bold: true,
|
|
1554
|
+
});
|
|
1555
|
+
return new Text(rowBackground + "▸ " + name + (desc ? " " + theme.fg("muted", desc) : ""), 0, 0);
|
|
906
1556
|
},
|
|
907
|
-
renderResult(result, { expanded, isPartial }, theme) {
|
|
1557
|
+
renderResult(result, { expanded, isPartial }, theme, renderContext) {
|
|
908
1558
|
const details = result.details;
|
|
909
|
-
|
|
910
|
-
|
|
1559
|
+
const text = result.content[0]?.type === "text" ? result.content[0].text : "";
|
|
1560
|
+
// Pi reports pre-execution failures (extension block, abort, argument
|
|
1561
|
+
// validation) as `{ content: [reason], details: {} }` with isError set —
|
|
1562
|
+
// no status to render, so show the reason instead of inventing one (#199).
|
|
1563
|
+
if (renderContext.isError || !details?.status) {
|
|
911
1564
|
return new Text(text, 0, 0);
|
|
912
1565
|
}
|
|
913
1566
|
// Helper: build "haiku · thinking: high · ↻5≤30 · 3 tool uses · 33.8k tokens" stats string
|
|
@@ -924,6 +1577,11 @@ Terse command-style prompts produce shallow, generic work.
|
|
|
924
1577
|
parts.push(`${d.toolUses} tool use${d.toolUses === 1 ? "" : "s"}`);
|
|
925
1578
|
if (d.tokens)
|
|
926
1579
|
parts.push(d.tokens);
|
|
1580
|
+
if (showCost) {
|
|
1581
|
+
const costText = formatCost(d.cost ?? 0);
|
|
1582
|
+
if (costText)
|
|
1583
|
+
parts.push(costText);
|
|
1584
|
+
}
|
|
927
1585
|
return parts.map(p => fgPreservingNestedStyles(theme, "dim", p)).join(" " + theme.fg("dim", "·") + " ");
|
|
928
1586
|
};
|
|
929
1587
|
// ---- While running (streaming) ----
|
|
@@ -969,6 +1627,11 @@ Terse command-style prompts produce shallow, generic work.
|
|
|
969
1627
|
line += "\n" + theme.fg("dim", " ⎿ Stopped");
|
|
970
1628
|
return new Text(line, 0, 0);
|
|
971
1629
|
}
|
|
1630
|
+
// Anything left ("queued", or a status added later) has no rendering of
|
|
1631
|
+
// its own — the turn-limit wording below must not be the catch-all.
|
|
1632
|
+
if (details.status !== "error" && details.status !== "aborted") {
|
|
1633
|
+
return new Text(text, 0, 0);
|
|
1634
|
+
}
|
|
972
1635
|
// ---- Error / Aborted (hard max_turns) ----
|
|
973
1636
|
const s = stats(details);
|
|
974
1637
|
let line = theme.fg("error", "✗") + (s ? " " + s : "");
|
|
@@ -987,13 +1650,38 @@ Terse command-style prompts produce shallow, generic work.
|
|
|
987
1650
|
// Reload custom agents so new project/global .md files are picked up without restart
|
|
988
1651
|
reloadCustomAgents();
|
|
989
1652
|
const rawType = params.subagent_type;
|
|
990
|
-
|
|
991
|
-
|
|
992
|
-
|
|
1653
|
+
// Single decision point for dispatch (#183): unknown, disabled and
|
|
1654
|
+
// case-ambiguous types are refused here, BEFORE anything spawns, so a
|
|
1655
|
+
// background or scheduled call can't start running the wrong agent while
|
|
1656
|
+
// the caller is still unaware. `fallbackSubagent` decides whether an
|
|
1657
|
+
// unresolvable type falls back or fails closed.
|
|
1658
|
+
const dispatch = resolveSpawnType(rawType);
|
|
1659
|
+
// `resume` replays a stored session and ignores `subagent_type` entirely,
|
|
1660
|
+
// but the parameter is required by the schema — so gating it here would
|
|
1661
|
+
// make a live agent unresumable the moment its type is deleted, disabled,
|
|
1662
|
+
// or gains a case-clashing sibling. Only a real spawn is gated.
|
|
1663
|
+
if (!dispatch.ok && !params.resume)
|
|
1664
|
+
return textResult(dispatch.message);
|
|
1665
|
+
const subagentType = dispatch.ok ? dispatch.type : rawType;
|
|
1666
|
+
// What the caller actually asked for, named once: `fellBackFrom` is "" for
|
|
1667
|
+
// a blank request, so reading it inline invites the `??`-vs-`||` slip that
|
|
1668
|
+
// once persisted an empty type into a scheduled job.
|
|
1669
|
+
const requestedType = (dispatch.ok && dispatch.fellBackFrom) || subagentType;
|
|
1670
|
+
// Computed at resolution rather than after the run, so the background and
|
|
1671
|
+
// schedule branches carry it too — previously it existed only on the
|
|
1672
|
+
// foreground path. Resume deliberately doesn't: it replays the stored
|
|
1673
|
+
// session and ignores `subagent_type` entirely, so a note about type
|
|
1674
|
+
// substitution would be describing something that didn't happen.
|
|
1675
|
+
const fallbackNote = dispatch.ok && dispatch.fellBackFrom !== undefined
|
|
1676
|
+
? `Note: Unknown agent type "${dispatch.fellBackFrom}" — using ${resolveType(subagentType) ? subagentType : "the fallback agent config"}.\n\n`
|
|
1677
|
+
: "";
|
|
993
1678
|
const displayName = getDisplayName(subagentType);
|
|
994
1679
|
// Get agent config (if any)
|
|
995
1680
|
const customConfig = getAgentConfig(subagentType);
|
|
996
|
-
const resolvedConfig = resolveAgentInvocationConfig(customConfig, params
|
|
1681
|
+
const resolvedConfig = resolveAgentInvocationConfig(customConfig, params, {
|
|
1682
|
+
worktreeAllowed: isWorktreeIsolationEnabled(),
|
|
1683
|
+
defaultRunInBackground: getBackgroundByDefault(),
|
|
1684
|
+
});
|
|
997
1685
|
// Resolve model from agent config first; tool-call params only fill gaps.
|
|
998
1686
|
let model = ctx.model;
|
|
999
1687
|
if (resolvedConfig.modelInput) {
|
|
@@ -1008,28 +1696,20 @@ Terse command-style prompts produce shallow, generic work.
|
|
|
1008
1696
|
}
|
|
1009
1697
|
}
|
|
1010
1698
|
// 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
|
-
}
|
|
1699
|
+
// user's enabledModels list. Policy (hard error vs warn-and-proceed) lives
|
|
1700
|
+
// in model-scope.ts so the nested delegation tools apply the same rule.
|
|
1701
|
+
const scopeVerdict = checkModelScope({
|
|
1702
|
+
model,
|
|
1703
|
+
cwd: ctx.cwd,
|
|
1704
|
+
modelRegistry: ctx.modelRegistry,
|
|
1705
|
+
callerSupplied: resolvedConfig.modelFromParams,
|
|
1706
|
+
agentLabel: customConfig?.displayName ?? subagentType,
|
|
1707
|
+
modelInput: resolvedConfig.modelInput,
|
|
1708
|
+
});
|
|
1709
|
+
if (scopeVerdict.kind === "error")
|
|
1710
|
+
return textResult(scopeVerdict.message);
|
|
1711
|
+
if (scopeVerdict.kind === "warn")
|
|
1712
|
+
ctx.ui.notify(scopeVerdict.message, "warning");
|
|
1033
1713
|
const thinking = resolvedConfig.thinking;
|
|
1034
1714
|
const inheritContext = resolvedConfig.inheritContext;
|
|
1035
1715
|
const runInBackground = resolvedConfig.runInBackground;
|
|
@@ -1046,29 +1726,35 @@ Terse command-style prompts produce shallow, generic work.
|
|
|
1046
1726
|
return;
|
|
1047
1727
|
rec.outputFile = createOutputFilePath(ctx.cwd, agentId, ctx.sessionManager.getSessionId());
|
|
1048
1728
|
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
1729
|
};
|
|
1061
|
-
|
|
1062
|
-
|
|
1063
|
-
|
|
1064
|
-
|
|
1065
|
-
|
|
1730
|
+
// Unconditional, not "only when it differs from the parent": a thinking
|
|
1731
|
+
// level reads as a property of a model, and an agent that inherited the
|
|
1732
|
+
// parent's model used to show the level with nothing to attach it to.
|
|
1733
|
+
// This is the pre-session snapshot — agent-manager overwrites it with the
|
|
1734
|
+
// effective values the moment a session reports them.
|
|
1735
|
+
const { modelName, modelId } = model ? describeModel(model) : { modelName: undefined, modelId: undefined };
|
|
1736
|
+
// What the caller SPELLED, kept only if it names a different model than the
|
|
1737
|
+
// one that won. Model input is fuzzy — `"haiku"` and
|
|
1738
|
+
// `"anthropic/claude-haiku-4-5"` are the same model — so comparing the two
|
|
1739
|
+
// strings would disclose an override that never happened. A spelling that
|
|
1740
|
+
// resolves to nothing is still worth disclosing: it cannot have taken effect.
|
|
1741
|
+
const askedModel = ((asked) => {
|
|
1742
|
+
if (!asked)
|
|
1743
|
+
return undefined;
|
|
1744
|
+
const resolvedAsked = resolveModel(asked, ctx.modelRegistry);
|
|
1745
|
+
if (typeof resolvedAsked === "string")
|
|
1746
|
+
return asked;
|
|
1747
|
+
return resolvedAsked.provider === model?.provider && resolvedAsked.id === model?.id ? undefined : asked;
|
|
1748
|
+
})(resolvedConfig.overridden?.model);
|
|
1066
1749
|
const effectiveMaxTurns = normalizeMaxTurns(resolvedConfig.maxTurns ?? getDefaultMaxTurns());
|
|
1067
1750
|
const agentInvocation = {
|
|
1068
1751
|
modelName,
|
|
1069
|
-
|
|
1752
|
+
modelId,
|
|
1070
1753
|
thinking,
|
|
1071
|
-
|
|
1754
|
+
// Only set where the agent file outranked the caller, so the surfaces can
|
|
1755
|
+
// disclose a parameter that was accepted but could not take effect (#182).
|
|
1756
|
+
requestedThinking: resolvedConfig.overridden?.thinking,
|
|
1757
|
+
requestedModel: askedModel,
|
|
1072
1758
|
// Explicit value only — the default fallback would just add noise.
|
|
1073
1759
|
// Normalize so `0` (unlimited) doesn't surface as a misleading "max turns: 0".
|
|
1074
1760
|
maxTurns: normalizeMaxTurns(resolvedConfig.maxTurns),
|
|
@@ -1088,6 +1774,34 @@ Terse command-style prompts produce shallow, generic work.
|
|
|
1088
1774
|
modelName,
|
|
1089
1775
|
tags: agentTags.length > 0 ? agentTags : undefined,
|
|
1090
1776
|
};
|
|
1777
|
+
/**
|
|
1778
|
+
* `detailBase` for a record that exists, which outranks it: the base is a
|
|
1779
|
+
* snapshot of what this call REQUESTED, and pi may have resolved a
|
|
1780
|
+
* different model or clamped the thinking level (agent-manager writes the
|
|
1781
|
+
* effective values back when the session reports them). Resume goes
|
|
1782
|
+
* further and ignores the model/thinking parameters outright — it runs on
|
|
1783
|
+
* the session it is reopening — so rendering the base there advertises
|
|
1784
|
+
* settings the run never used.
|
|
1785
|
+
*
|
|
1786
|
+
* The mode label is rebuilt rather than carried over: it hangs off the
|
|
1787
|
+
* agent TYPE, not the invocation, so tags taken straight from
|
|
1788
|
+
* buildInvocationTags would silently drop `twin`.
|
|
1789
|
+
*/
|
|
1790
|
+
const detailBaseFor = (rec) => {
|
|
1791
|
+
if (!rec?.invocation)
|
|
1792
|
+
return detailBase;
|
|
1793
|
+
const type = rec.type;
|
|
1794
|
+
const { modelName: recModelName, tags } = buildInvocationTags(rec.invocation);
|
|
1795
|
+
const recModeLabel = getPromptModeLabel(type);
|
|
1796
|
+
const recTags = recModeLabel ? [recModeLabel, ...tags] : tags;
|
|
1797
|
+
return {
|
|
1798
|
+
displayName: getDisplayName(type),
|
|
1799
|
+
description: rec.description,
|
|
1800
|
+
subagentType: type,
|
|
1801
|
+
modelName: recModelName,
|
|
1802
|
+
tags: recTags.length > 0 ? recTags : undefined,
|
|
1803
|
+
};
|
|
1804
|
+
};
|
|
1091
1805
|
// ---- Schedule: register a job, don't spawn now ----
|
|
1092
1806
|
if (params.schedule) {
|
|
1093
1807
|
if (!isSchedulingEnabled()) {
|
|
@@ -1110,7 +1824,9 @@ Terse command-style prompts produce shallow, generic work.
|
|
|
1110
1824
|
name: params.description,
|
|
1111
1825
|
description: params.description,
|
|
1112
1826
|
schedule: params.schedule,
|
|
1113
|
-
|
|
1827
|
+
// The caller's own name, not the substitute — the scheduler re-resolves
|
|
1828
|
+
// at fire time, and the original is what a user edits.
|
|
1829
|
+
subagent_type: requestedType,
|
|
1114
1830
|
prompt: params.prompt,
|
|
1115
1831
|
model: params.model,
|
|
1116
1832
|
thinking: thinking,
|
|
@@ -1119,7 +1835,7 @@ Terse command-style prompts produce shallow, generic work.
|
|
|
1119
1835
|
isolation: isolation,
|
|
1120
1836
|
});
|
|
1121
1837
|
const next = scheduler.getNextRun(job.id);
|
|
1122
|
-
return textResult(
|
|
1838
|
+
return textResult(`${fallbackNote}Scheduled "${job.name}" (id: ${job.id}, type: ${job.scheduleType}). ` +
|
|
1123
1839
|
`Next run: ${next ?? "(unknown)"}. ` +
|
|
1124
1840
|
`Manage via /agents → Scheduled jobs.`);
|
|
1125
1841
|
}
|
|
@@ -1130,12 +1846,44 @@ Terse command-style prompts produce shallow, generic work.
|
|
|
1130
1846
|
// Resume existing agent
|
|
1131
1847
|
if (params.resume) {
|
|
1132
1848
|
const existing = manager.getRecord(params.resume);
|
|
1133
|
-
if (!existing) {
|
|
1849
|
+
if (!existing || !isTopLevelAgent(existing)) {
|
|
1134
1850
|
return textResult(`Agent not found: "${params.resume}". It may have been cleaned up.`);
|
|
1135
1851
|
}
|
|
1136
1852
|
if (!existing.session) {
|
|
1137
1853
|
return textResult(`Agent "${params.resume}" has no active session to resume.`);
|
|
1138
1854
|
}
|
|
1855
|
+
// Background resume: detached run that notifies on completion, mirroring
|
|
1856
|
+
// a background spawn. Previously run_in_background was silently ignored
|
|
1857
|
+
// on resume (this branch returned before the background branch below),
|
|
1858
|
+
// so a resumed agent always blocked the main loop until it finished.
|
|
1859
|
+
if (runInBackground) {
|
|
1860
|
+
const id = existing.id;
|
|
1861
|
+
// A detached resume hands control back while the record stays
|
|
1862
|
+
// "running", so nothing stops the model from resuming the same agent
|
|
1863
|
+
// again mid-run. manager.resume() refuses that (it would orphan the
|
|
1864
|
+
// live run's abort controller); say why here, where the model can act
|
|
1865
|
+
// on it, instead of letting it read as a generic failure.
|
|
1866
|
+
if (existing.status === "running" || existing.status === "queued") {
|
|
1867
|
+
return textResult(`Agent "${params.resume}" is still ${existing.status} — it can only be resumed once its current run finishes.\n` +
|
|
1868
|
+
`Use steer_subagent to send it a message mid-run, or get_subagent_result to wait for it.`);
|
|
1869
|
+
}
|
|
1870
|
+
const record = await startBackgroundResume(ctx, existing, params.prompt, {
|
|
1871
|
+
outputTranscript,
|
|
1872
|
+
maxTurns: effectiveMaxTurns,
|
|
1873
|
+
toolCallId,
|
|
1874
|
+
});
|
|
1875
|
+
if (!record) {
|
|
1876
|
+
return textResult(`Failed to resume agent "${params.resume}".`);
|
|
1877
|
+
}
|
|
1878
|
+
const isQueued = record.status === "queued";
|
|
1879
|
+
return textResult(`Agent ${isQueued ? "queued" : "resumed"} in background.\n` +
|
|
1880
|
+
`Agent ID: ${id}\n` +
|
|
1881
|
+
`Type: ${existing.type}\n` +
|
|
1882
|
+
(record.outputFile ? `Output file: ${record.outputFile}\n` : "") +
|
|
1883
|
+
(isQueued ? `Position: queued (max ${manager.getMaxConcurrent()} concurrent)\n` : "") +
|
|
1884
|
+
`\nYou will be notified when this agent completes.\n` +
|
|
1885
|
+
`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 });
|
|
1886
|
+
}
|
|
1139
1887
|
const record = await manager.resume(params.resume, params.prompt, signal);
|
|
1140
1888
|
if (!record) {
|
|
1141
1889
|
return textResult(`Failed to resume agent "${params.resume}".`);
|
|
@@ -1143,53 +1891,57 @@ Terse command-style prompts produce shallow, generic work.
|
|
|
1143
1891
|
// A failed resume surfaces the error, plus any partial output THIS
|
|
1144
1892
|
// resume produced (never the previous turn's answer, #144).
|
|
1145
1893
|
if (record.status === "error") {
|
|
1146
|
-
return textResult(`Agent failed: ${record.error}${partialOutputSuffix(record)}`, buildDetails(
|
|
1894
|
+
return textResult(`Agent failed: ${record.error}${partialOutputSuffix(record)}`, buildDetails(detailBaseFor(record), record));
|
|
1147
1895
|
}
|
|
1148
|
-
return textResult(record.result?.trim() || "No output.", buildDetails(
|
|
1896
|
+
return textResult(record.result?.trim() || "No output.", buildDetails(detailBaseFor(record), record));
|
|
1149
1897
|
}
|
|
1150
1898
|
// Background execution
|
|
1151
1899
|
if (runInBackground) {
|
|
1152
1900
|
const { state: bgState, callbacks: bgCallbacks } = createActivityTracker(effectiveMaxTurns);
|
|
1153
1901
|
// Wrap onSessionCreated to wire output file streaming.
|
|
1154
|
-
// The callback reads
|
|
1155
|
-
//
|
|
1902
|
+
// The callback lazily reads record.outputFile (set right after spawn)
|
|
1903
|
+
// rather than closing over a value that doesn't exist yet.
|
|
1156
1904
|
let id;
|
|
1157
|
-
const joinMode = resolveJoinMode(defaultJoinMode, true);
|
|
1158
1905
|
const origBgOnSession = bgCallbacks.onSessionCreated;
|
|
1159
1906
|
bgCallbacks.onSessionCreated = (session) => {
|
|
1160
1907
|
origBgOnSession(session);
|
|
1161
1908
|
const rec = manager.getRecord(id);
|
|
1162
1909
|
if (rec?.outputFile) {
|
|
1163
|
-
rec.outputCleanup = streamToOutputFile(session, rec.outputFile, id, ctx.cwd,
|
|
1910
|
+
rec.outputCleanup = streamToOutputFile(session, rec.outputFile, id, ctx.cwd, undefined);
|
|
1164
1911
|
}
|
|
1165
1912
|
};
|
|
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.
|
|
1913
|
+
// A throw here means the agent never started. Let it out: pi marks a
|
|
1914
|
+
// tool call failed only when execute throws, and a returned message
|
|
1915
|
+
// reads to the model as a subagent that ran and reported this (#179).
|
|
1916
|
+
id = manager.spawn(pi, ctx, subagentType, params.prompt, {
|
|
1917
|
+
description: params.description,
|
|
1918
|
+
name: params.name,
|
|
1919
|
+
model,
|
|
1920
|
+
maxTurns: effectiveMaxTurns,
|
|
1921
|
+
isolated,
|
|
1922
|
+
inheritContext,
|
|
1923
|
+
thinkingLevel: thinking,
|
|
1924
|
+
isBackground: true,
|
|
1925
|
+
isolation,
|
|
1926
|
+
invocation: agentInvocation,
|
|
1927
|
+
outputTranscript,
|
|
1928
|
+
rootSessionId: ctx.sessionManager.getSessionId(),
|
|
1929
|
+
...bgCallbacks,
|
|
1930
|
+
});
|
|
1931
|
+
// Set output file + join mode synchronously after spawn, before the
|
|
1932
|
+
// event loop yields — onSessionCreated is async so this is safe.
|
|
1933
|
+
const joinMode = resolveJoinMode(defaultJoinMode, true);
|
|
1188
1934
|
const record = manager.getRecord(id);
|
|
1189
1935
|
if (record && joinMode) {
|
|
1190
1936
|
record.joinMode = joinMode;
|
|
1191
1937
|
record.toolCallId = toolCallId;
|
|
1938
|
+
attachTranscript(record, id);
|
|
1192
1939
|
}
|
|
1940
|
+
// With isolation: "worktree" the agent isn't running yet — the repo
|
|
1941
|
+
// copy is an awaited git call. Wait for it here, after the synchronous
|
|
1942
|
+
// wiring above, so a strict-isolation failure still fails THIS tool
|
|
1943
|
+
// call instead of being reported as a subagent that ran (#179).
|
|
1944
|
+
await manager.awaitStartup(id);
|
|
1193
1945
|
if (joinMode == null || joinMode === 'async') {
|
|
1194
1946
|
// Foreground/no join mode or explicit async — not part of any batch
|
|
1195
1947
|
}
|
|
@@ -1205,6 +1957,8 @@ Terse command-style prompts produce shallow, generic work.
|
|
|
1205
1957
|
agentActivity.set(id, bgState);
|
|
1206
1958
|
widget.ensureTimer();
|
|
1207
1959
|
widget.update();
|
|
1960
|
+
fleet.ensureTimer();
|
|
1961
|
+
fleet.update();
|
|
1208
1962
|
// Emit created event
|
|
1209
1963
|
pi.events.emit("subagents:created", {
|
|
1210
1964
|
id,
|
|
@@ -1213,7 +1967,7 @@ Terse command-style prompts produce shallow, generic work.
|
|
|
1213
1967
|
isBackground: true,
|
|
1214
1968
|
});
|
|
1215
1969
|
const isQueued = record?.status === "queued";
|
|
1216
|
-
return textResult(
|
|
1970
|
+
return textResult(`${fallbackNote}Agent ${isQueued ? "queued" : "started"} in background.\n` +
|
|
1217
1971
|
`Agent ID: ${id}\n` +
|
|
1218
1972
|
`Type: ${displayName}\n` +
|
|
1219
1973
|
`Description: ${params.description}\n` +
|
|
@@ -1221,22 +1975,38 @@ Terse command-style prompts produce shallow, generic work.
|
|
|
1221
1975
|
(isQueued ? `Position: queued (max ${manager.getMaxConcurrent()} concurrent)\n` : "") +
|
|
1222
1976
|
`\nYou will be notified when this agent completes.\n` +
|
|
1223
1977
|
`Use get_subagent_result to retrieve full results, or steer_subagent to send it messages.\n` +
|
|
1224
|
-
`Do not duplicate this agent's work.`, { ...
|
|
1978
|
+
`Do not duplicate this agent's work.`, { ...detailBaseFor(record), toolUses: 0, tokens: "", durationMs: 0, status: "background", agentId: id });
|
|
1225
1979
|
}
|
|
1226
1980
|
// Foreground (synchronous) execution — stream progress via onUpdate
|
|
1227
1981
|
let spinnerFrame = 0;
|
|
1228
1982
|
const startedAt = Date.now();
|
|
1229
1983
|
let fgId;
|
|
1984
|
+
// Set only while the spawn is parked on a foreground concurrency slot
|
|
1985
|
+
// (maxConcurrentForeground); undefined the rest of the time, including
|
|
1986
|
+
// always when the limit is unset.
|
|
1987
|
+
let queuedAhead;
|
|
1230
1988
|
const streamUpdate = () => {
|
|
1989
|
+
// Spend from the record, everything else from the live tracker. `fgId`
|
|
1990
|
+
// is set in onSessionCreated below, which fires before the first
|
|
1991
|
+
// assistant message — so nothing is spent while this reads zero.
|
|
1992
|
+
const fgRecord = fgId ? manager.getRecord(fgId) : undefined;
|
|
1231
1993
|
const details = {
|
|
1232
|
-
...
|
|
1994
|
+
...detailBaseFor(fgRecord),
|
|
1233
1995
|
toolUses: fgState.toolUses,
|
|
1234
|
-
tokens: formatLifetimeTokens(
|
|
1996
|
+
tokens: fgRecord ? formatLifetimeTokens(fgRecord) : "",
|
|
1997
|
+
cost: fgRecord ? getLifetimeCost(fgRecord.lifetimeUsage) : 0,
|
|
1235
1998
|
turnCount: fgState.turnCount,
|
|
1236
1999
|
maxTurns: fgState.maxTurns,
|
|
1237
2000
|
durationMs: Date.now() - startedAt,
|
|
2001
|
+
// Deliberately still "running" while queued: the renderer routes any
|
|
2002
|
+
// status it doesn't know to raw text (see the catch-all below), which
|
|
2003
|
+
// would drop the spinner and read as hung. Only the activity line
|
|
2004
|
+
// changes — "thinking…" would be a lie for an agent that has not
|
|
2005
|
+
// started and may not for minutes.
|
|
1238
2006
|
status: "running",
|
|
1239
|
-
activity:
|
|
2007
|
+
activity: queuedAhead === undefined
|
|
2008
|
+
? describeActivity(fgState.activeTools, fgState.responseText)
|
|
2009
|
+
: `queued — waiting for a foreground slot${queuedAhead > 0 ? ` (${queuedAhead} ahead)` : ""}`,
|
|
1240
2010
|
spinnerFrame: spinnerFrame % SPINNER.length,
|
|
1241
2011
|
};
|
|
1242
2012
|
onUpdate?.({
|
|
@@ -1251,12 +2021,20 @@ Terse command-style prompts produce shallow, generic work.
|
|
|
1251
2021
|
const origOnSession = fgCallbacks.onSessionCreated;
|
|
1252
2022
|
fgCallbacks.onSessionCreated = (session) => {
|
|
1253
2023
|
origOnSession(session);
|
|
2024
|
+
// It really started — stop reporting it as queued, and repaint now
|
|
2025
|
+
// rather than leaving the stale line up for the next spinner tick.
|
|
2026
|
+
// Guarded, so a spawn that never queued emits no extra update.
|
|
2027
|
+
if (queuedAhead !== undefined) {
|
|
2028
|
+
queuedAhead = undefined;
|
|
2029
|
+
streamUpdate();
|
|
2030
|
+
}
|
|
1254
2031
|
for (const a of manager.listAgents()) {
|
|
1255
2032
|
if (a.session === session) {
|
|
1256
2033
|
fgId = a.id;
|
|
1257
2034
|
agentActivity.set(a.id, fgState);
|
|
1258
2035
|
widget.ensureTimer();
|
|
1259
|
-
|
|
2036
|
+
fleet.ensureTimer();
|
|
2037
|
+
fleet.update();
|
|
1260
2038
|
break;
|
|
1261
2039
|
}
|
|
1262
2040
|
}
|
|
@@ -1264,7 +2042,7 @@ Terse command-style prompts produce shallow, generic work.
|
|
|
1264
2042
|
if (fgId) {
|
|
1265
2043
|
const rec = manager.getRecord(fgId);
|
|
1266
2044
|
if (rec?.outputFile) {
|
|
1267
|
-
rec.outputCleanup = streamToOutputFile(session, rec.outputFile, fgId, ctx.cwd,
|
|
2045
|
+
rec.outputCleanup = streamToOutputFile(session, rec.outputFile, fgId, ctx.cwd, undefined);
|
|
1268
2046
|
}
|
|
1269
2047
|
}
|
|
1270
2048
|
};
|
|
@@ -1278,6 +2056,7 @@ Terse command-style prompts produce shallow, generic work.
|
|
|
1278
2056
|
try {
|
|
1279
2057
|
const fgResult = await manager.spawnAndWait(pi, ctx, subagentType, params.prompt, {
|
|
1280
2058
|
description: params.description,
|
|
2059
|
+
name: params.name,
|
|
1281
2060
|
model,
|
|
1282
2061
|
maxTurns: effectiveMaxTurns,
|
|
1283
2062
|
isolated,
|
|
@@ -1285,7 +2064,13 @@ Terse command-style prompts produce shallow, generic work.
|
|
|
1285
2064
|
thinkingLevel: thinking,
|
|
1286
2065
|
isolation,
|
|
1287
2066
|
invocation: agentInvocation,
|
|
2067
|
+
outputTranscript,
|
|
1288
2068
|
signal,
|
|
2069
|
+
rootSessionId: ctx.sessionManager.getSessionId(),
|
|
2070
|
+
// Deliberately does NOT set fgId: that drives agentActivity, the
|
|
2071
|
+
// widget and the `finally` cleanup below, none of which should see an
|
|
2072
|
+
// agent that has no session and may never get one.
|
|
2073
|
+
onQueued: (_id, ahead) => { queuedAhead = ahead; streamUpdate(); },
|
|
1289
2074
|
...fgCallbacks,
|
|
1290
2075
|
}, (fgAgentId) => {
|
|
1291
2076
|
// onSpawned: called synchronously after spawn, before onSessionCreated fires.
|
|
@@ -1295,24 +2080,21 @@ Terse command-style prompts produce shallow, generic work.
|
|
|
1295
2080
|
});
|
|
1296
2081
|
record = fgResult.record;
|
|
1297
2082
|
}
|
|
1298
|
-
|
|
2083
|
+
finally {
|
|
2084
|
+
// Runs on both paths, so a startup throw — which now propagates, see
|
|
2085
|
+
// the background spawn above (#179) — no longer leaves the spinner
|
|
2086
|
+
// ticking or a finished agent on the widget.
|
|
1299
2087
|
clearInterval(spinnerInterval);
|
|
1300
|
-
|
|
2088
|
+
if (fgId) {
|
|
2089
|
+
agentActivity.delete(fgId);
|
|
2090
|
+
widget.markFinished(fgId);
|
|
2091
|
+
fleet.onAgentFinished(fgId);
|
|
2092
|
+
}
|
|
1301
2093
|
}
|
|
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
|
-
: "";
|
|
2094
|
+
// Get final token count — from the record, like the cost below it, so the
|
|
2095
|
+
// two describe the same work when the agent delegated to nested children.
|
|
2096
|
+
const tokenText = formatLifetimeTokens(record);
|
|
2097
|
+
const details = buildDetails(detailBaseFor(record), record, fgState, { tokens: tokenText });
|
|
1316
2098
|
if (record.status === "error") {
|
|
1317
2099
|
// Error headline + any partial output the run produced before failing.
|
|
1318
2100
|
return textResult(`${fallbackNote}Agent failed: ${record.error}${partialOutputSuffix(record)}`, details);
|
|
@@ -1321,19 +2103,441 @@ Terse command-style prompts produce shallow, generic work.
|
|
|
1321
2103
|
const statsParts = [`${record.toolUses} tool uses`];
|
|
1322
2104
|
if (tokenText)
|
|
1323
2105
|
statsParts.push(tokenText);
|
|
1324
|
-
|
|
2106
|
+
if (showCost) {
|
|
2107
|
+
const costText = formatCost(getLifetimeCost(record.lifetimeUsage));
|
|
2108
|
+
if (costText)
|
|
2109
|
+
statsParts.push(costText);
|
|
2110
|
+
}
|
|
2111
|
+
return textResult(`${fallbackNote}Agent completed in ${formatMs(durationMs)} (${statsParts.join(", ")})${getForegroundOutcomeNote(record.status)}.\n\n` +
|
|
1325
2112
|
(record.result?.trim() || "No output."), details);
|
|
1326
2113
|
},
|
|
1327
|
-
})
|
|
2114
|
+
});
|
|
2115
|
+
/**
|
|
2116
|
+
* Wrap a tool so its results carry back whatever subagent spend the parent
|
|
2117
|
+
* session has not been told about yet (see `PendingUsagePool`).
|
|
2118
|
+
*
|
|
2119
|
+
* Pi copies `AgentToolResult.usage` onto the persisted tool-result message and
|
|
2120
|
+
* folds it into `getSessionStats()`, which is what the footer, the statusline
|
|
2121
|
+
* and `/cost` read — so this is the whole of "report usage to the parent".
|
|
2122
|
+
*
|
|
2123
|
+
* Nothing is attached to a call with no tool-call id. That is the `@handle`
|
|
2124
|
+
* mention path (`mention-clone.ts`), which invokes this tool from a fork of the
|
|
2125
|
+
* conversation that is discarded moments later: the result never becomes a
|
|
2126
|
+
* message in the real session, so usage hung on it would be spend the user paid
|
|
2127
|
+
* for and nobody counted. Skipping leaves it pending for the next real result.
|
|
2128
|
+
*/
|
|
2129
|
+
function withUsageReporting(tool) {
|
|
2130
|
+
return {
|
|
2131
|
+
...tool,
|
|
2132
|
+
execute: async (toolCallId, ...rest) => {
|
|
2133
|
+
const result = await tool.execute(toolCallId, ...rest);
|
|
2134
|
+
if (!reportUsage || !toolCallId)
|
|
2135
|
+
return result;
|
|
2136
|
+
const usage = pendingUsage.drain();
|
|
2137
|
+
return usage ? { ...result, usage } : result;
|
|
2138
|
+
},
|
|
2139
|
+
};
|
|
2140
|
+
}
|
|
2141
|
+
function registerToolReportingUsage(tool) {
|
|
2142
|
+
pi.registerTool(withUsageReporting(tool));
|
|
2143
|
+
}
|
|
2144
|
+
// The mention path is handed THIS object, not the bare `agentTool` — see the
|
|
2145
|
+
// mention-clone header on why the clone must call the registered tool.
|
|
2146
|
+
const registeredAgentTool = withUsageReporting(agentTool);
|
|
2147
|
+
pi.registerTool(registeredAgentTool);
|
|
2148
|
+
// ---- Workflow tool ----
|
|
2149
|
+
/**
|
|
2150
|
+
* Live runs, by task id. The tool returns before the run finishes, so its
|
|
2151
|
+
* result card looks the task up here on every render rather than freezing a
|
|
2152
|
+
* snapshot into `details` — that is what makes the inline card follow a
|
|
2153
|
+
* background run.
|
|
2154
|
+
*/
|
|
2155
|
+
const workflowTasks = new Map();
|
|
2156
|
+
/**
|
|
2157
|
+
* Workflow runs as the fleet list wants them.
|
|
2158
|
+
*
|
|
2159
|
+
* Mapped here rather than handing `WorkflowTask` over the seam: the list is
|
|
2160
|
+
* deliberately ignorant of the workflow engine, and a run's counters live in
|
|
2161
|
+
* the progress log rather than on the record, so they are derived per call
|
|
2162
|
+
* the same way the card derives them.
|
|
2163
|
+
*/
|
|
2164
|
+
function fleetWorkflows() {
|
|
2165
|
+
// Cached counters only, no derivation: the fleet list calls this on a
|
|
2166
|
+
// 200ms tick and reads the roster several times per update, so walking a
|
|
2167
|
+
// run's progress log here would put O(log) work in the render loop.
|
|
2168
|
+
return [...workflowTasks.values()].map(task => ({
|
|
2169
|
+
id: task.id,
|
|
2170
|
+
name: task.meta?.name ?? task.workflowName ?? task.id,
|
|
2171
|
+
status: task.status,
|
|
2172
|
+
doneCount: task.doneCount,
|
|
2173
|
+
totalCount: task.agentCount,
|
|
2174
|
+
startedAt: task.startTime,
|
|
2175
|
+
...(task.endTime !== undefined ? { completedAt: task.endTime } : {}),
|
|
2176
|
+
tokens: task.totalTokens,
|
|
2177
|
+
}));
|
|
2178
|
+
}
|
|
2179
|
+
/**
|
|
2180
|
+
* Run a task to completion against the real manager, settling the record
|
|
2181
|
+
* either way. Never rejects: a run that cannot start (bad `meta`, oversized
|
|
2182
|
+
* source, non-JSON `args`) is a failed workflow, and both callers here are
|
|
2183
|
+
* detached — a rejection would surface as an unhandled one.
|
|
2184
|
+
*/
|
|
2185
|
+
async function runWorkflowTask(ctx, task) {
|
|
2186
|
+
try {
|
|
2187
|
+
const result = await runWorkflow({
|
|
2188
|
+
script: task.script,
|
|
2189
|
+
args: task.args,
|
|
2190
|
+
signal: task.abortController.signal,
|
|
2191
|
+
host: createWorkflowHost({
|
|
2192
|
+
pi,
|
|
2193
|
+
ctx,
|
|
2194
|
+
manager,
|
|
2195
|
+
signal: task.abortController.signal,
|
|
2196
|
+
rootSessionId: ctx.sessionManager.getSessionId(),
|
|
2197
|
+
workflowId: task.id,
|
|
2198
|
+
}),
|
|
2199
|
+
onProgress: entries => updateWorkflowProgressBatch(task, entries),
|
|
2200
|
+
// The dialog's pause / skip / retry keys run through this; it is dropped
|
|
2201
|
+
// again when the task settles.
|
|
2202
|
+
onControl: control => { task.control = control; },
|
|
2203
|
+
journal: {
|
|
2204
|
+
...(task.replay !== undefined ? { entries: task.replay } : {}),
|
|
2205
|
+
...(task.journalPath !== undefined
|
|
2206
|
+
? { append: (entry) => appendJournal(task.journalPath, entry) }
|
|
2207
|
+
: {}),
|
|
2208
|
+
},
|
|
2209
|
+
});
|
|
2210
|
+
completeWorkflowTask(task, result);
|
|
2211
|
+
}
|
|
2212
|
+
catch (err) {
|
|
2213
|
+
failWorkflowTask(task, err instanceof Error ? err.message : String(err));
|
|
2214
|
+
}
|
|
2215
|
+
}
|
|
2216
|
+
/**
|
|
2217
|
+
* Hand a finished run back to the model through the SAME channel a background
|
|
2218
|
+
* agent uses — held briefly by `scheduleNudge`, delivered as a follow-up that
|
|
2219
|
+
* triggers a turn, rendered by the existing `subagent-notification` renderer.
|
|
2220
|
+
*/
|
|
2221
|
+
function notifyWorkflowFinished(task) {
|
|
2222
|
+
widget.update();
|
|
2223
|
+
fleet.update();
|
|
2224
|
+
const result = workflowResultText(task);
|
|
2225
|
+
scheduleNudge(task.id, () => {
|
|
2226
|
+
pi.sendMessage({
|
|
2227
|
+
customType: "subagent-notification",
|
|
2228
|
+
content: formatWorkflowNotification(task),
|
|
2229
|
+
display: true,
|
|
2230
|
+
details: {
|
|
2231
|
+
id: task.id,
|
|
2232
|
+
description: `Workflow ${task.workflowName ?? task.id}`,
|
|
2233
|
+
status: task.status === "completed" ? "completed" : task.status === "killed" ? "stopped" : "error",
|
|
2234
|
+
toolUses: task.totalToolCalls,
|
|
2235
|
+
// A workflow has agents, not turns; rendering "↻0" would be noise.
|
|
2236
|
+
turnCount: 0,
|
|
2237
|
+
totalTokens: task.totalTokens,
|
|
2238
|
+
durationMs: elapsedMs(task, Date.now()),
|
|
2239
|
+
error: task.error,
|
|
2240
|
+
resultPreview: result.length > 500 ? `${result.slice(0, 500)}…` : result,
|
|
2241
|
+
},
|
|
2242
|
+
}, { deliverAs: "followUp", triggerTurn: true });
|
|
2243
|
+
});
|
|
2244
|
+
}
|
|
2245
|
+
// Defined unconditionally, registered only when the feature is on — the same
|
|
2246
|
+
// shape the Agent tool uses. Keeping the definition out of the `if` means the
|
|
2247
|
+
// switch changes exactly one thing: whether pi is ever told about the tool.
|
|
2248
|
+
const workflowTool = defineTool({
|
|
2249
|
+
name: SUBAGENT_TOOL_NAMES.WORKFLOW,
|
|
2250
|
+
label: "SubagentWorkflow",
|
|
2251
|
+
description: renderToolDescriptionTemplate(fullWorkflowToolDescription),
|
|
2252
|
+
promptSnippet: "Run a deterministic script that orchestrates many subagents",
|
|
2253
|
+
promptGuidelines: [
|
|
2254
|
+
"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.",
|
|
2255
|
+
"Prefer `pipeline` over `parallel` — a barrier costs wall-clock whenever the stages are unevenly sized.",
|
|
2256
|
+
"A workflow runs in the background and notifies you when it finishes — do not poll or sleep waiting for it.",
|
|
2257
|
+
],
|
|
2258
|
+
parameters: Type.Object({
|
|
2259
|
+
script: Type.Optional(Type.String({
|
|
2260
|
+
maxLength: 524288,
|
|
2261
|
+
description: "Inline workflow source. Must begin with `export const meta = { name, description }`.",
|
|
2262
|
+
})),
|
|
2263
|
+
scriptPath: Type.Optional(Type.String({
|
|
2264
|
+
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.",
|
|
2265
|
+
})),
|
|
2266
|
+
name: Type.Optional(Type.String({
|
|
2267
|
+
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.",
|
|
2268
|
+
})),
|
|
2269
|
+
args: Type.Optional(Type.Any({
|
|
2270
|
+
description: "Exposed to the script as the global `args`, verbatim. Must be JSON-shaped.",
|
|
2271
|
+
})),
|
|
2272
|
+
resumeFromRunId: Type.Optional(Type.String({
|
|
2273
|
+
pattern: "^wf_[a-z0-9-]{6,}$",
|
|
2274
|
+
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.",
|
|
2275
|
+
})),
|
|
2276
|
+
// Accepted and ignored, as in Claude Code. Models reach for them because
|
|
2277
|
+
// every other tool has them, and a hard schema rejection would cost a
|
|
2278
|
+
// whole turn to re-emit a script that was already correct. The `meta`
|
|
2279
|
+
// block is the one place a workflow is named.
|
|
2280
|
+
title: Type.Optional(Type.String({ description: "Ignored — set the workflow title in the script's `meta` block." })),
|
|
2281
|
+
description: Type.Optional(Type.String({ description: "Ignored — set the workflow description in the script's `meta` block." })),
|
|
2282
|
+
}),
|
|
2283
|
+
renderCall(args, theme) {
|
|
2284
|
+
return new Text(`${theme.fg("toolTitle", "▸ ")}${theme.bold(theme.fg("toolTitle", "SubagentWorkflow"))} ${theme.fg("muted", workflowCallName(args))}`, 0, 0);
|
|
2285
|
+
},
|
|
2286
|
+
renderResult(result, _options, theme, renderContext) {
|
|
2287
|
+
const text = result.content[0]?.type === "text" ? result.content[0].text : "";
|
|
2288
|
+
const taskId = result.details?.taskId;
|
|
2289
|
+
const task = taskId !== undefined ? workflowTasks.get(taskId) : undefined;
|
|
2290
|
+
// No task means the run predates this session (a reloaded transcript) or
|
|
2291
|
+
// the call never started one — show what `execute` said instead.
|
|
2292
|
+
if (renderContext.isError || !task)
|
|
2293
|
+
return new Text(text, 0, 0);
|
|
2294
|
+
return renderWorkflowCard({
|
|
2295
|
+
progress: task.workflowProgress,
|
|
2296
|
+
task: {
|
|
2297
|
+
status: task.status,
|
|
2298
|
+
workflowName: task.workflowName,
|
|
2299
|
+
startTime: task.startTime,
|
|
2300
|
+
endTime: task.endTime,
|
|
2301
|
+
totalPausedMs: task.totalPausedMs,
|
|
2302
|
+
},
|
|
2303
|
+
meta: task.meta,
|
|
2304
|
+
agentCount: task.agentCount,
|
|
2305
|
+
totalTokens: task.totalTokens,
|
|
2306
|
+
}, theme);
|
|
2307
|
+
},
|
|
2308
|
+
execute: async (toolCallId, params, _signal, _onUpdate, ctx) => {
|
|
2309
|
+
const resumeFrom = resolveResumeTarget(params.resumeFromRunId, workflowTasks);
|
|
2310
|
+
if (resumeFrom !== undefined && !resumeFrom.ok)
|
|
2311
|
+
return textResult(resumeFrom.message);
|
|
2312
|
+
// A resume with no source of its own re-runs what that run ran. The
|
|
2313
|
+
// common case is an edited script, but "run that again, cheaply" should
|
|
2314
|
+
// not require repeating a path the run already knows.
|
|
2315
|
+
const resolved = resolveWorkflowScript(params.script === undefined && params.scriptPath === undefined && params.name === undefined
|
|
2316
|
+
&& resumeFrom !== undefined
|
|
2317
|
+
? { scriptPath: resumeFrom.scriptPath }
|
|
2318
|
+
: params, ctx.cwd);
|
|
2319
|
+
if (!resolved.ok)
|
|
2320
|
+
return textResult(resolved.message);
|
|
2321
|
+
// Parsed before anything is scheduled: a bad `meta` is an authoring error
|
|
2322
|
+
// the model can fix immediately, and reporting it as a background run
|
|
2323
|
+
// that failed a second later would just cost a turn.
|
|
2324
|
+
let meta;
|
|
2325
|
+
try {
|
|
2326
|
+
meta = extractMeta(resolved.script).meta;
|
|
2327
|
+
}
|
|
2328
|
+
catch (err) {
|
|
2329
|
+
return textResult(err instanceof Error ? err.message : String(err));
|
|
2330
|
+
}
|
|
2331
|
+
const runId = workflowRunId();
|
|
2332
|
+
// Every invocation lands on disk next to the agent transcripts, so
|
|
2333
|
+
// iterating is edit-the-file-then-rerun-with-scriptPath rather than
|
|
2334
|
+
// re-emitting the whole source. The journal sits beside it under the same
|
|
2335
|
+
// id, which is what makes a run id enough to resume from.
|
|
2336
|
+
let savedPath;
|
|
2337
|
+
let journalPath;
|
|
2338
|
+
try {
|
|
2339
|
+
const dir = sessionTaskDir(ctx.cwd, ctx.sessionManager.getSessionId());
|
|
2340
|
+
savedPath = join(dir, `${runId}.workflow.js`);
|
|
2341
|
+
writeFileSync(savedPath, resolved.script, "utf-8");
|
|
2342
|
+
journalPath = join(dir, `${runId}.workflow.jsonl`);
|
|
2343
|
+
}
|
|
2344
|
+
catch (err) {
|
|
2345
|
+
savedPath = undefined;
|
|
2346
|
+
journalPath = undefined;
|
|
2347
|
+
console.warn(`[pi-subagents] could not persist workflow script: ${err instanceof Error ? err.message : String(err)}`);
|
|
2348
|
+
}
|
|
2349
|
+
const replay = resumeFrom !== undefined ? readJournal(resumeFrom.journalPath) : undefined;
|
|
2350
|
+
const task = createWorkflowTask({
|
|
2351
|
+
id: runId,
|
|
2352
|
+
script: resolved.script,
|
|
2353
|
+
scriptPath: resolved.scriptPath ?? savedPath,
|
|
2354
|
+
args: params.args,
|
|
2355
|
+
meta,
|
|
2356
|
+
toolCallId,
|
|
2357
|
+
...(journalPath !== undefined ? { journalPath } : {}),
|
|
2358
|
+
...(replay !== undefined && replay.length > 0 ? { replay, resumedFrom: resumeFrom.runId } : {}),
|
|
2359
|
+
});
|
|
2360
|
+
workflowTasks.set(runId, task);
|
|
2361
|
+
// The run's own row has to appear now, not when it settles. Its agents
|
|
2362
|
+
// are owned by it, so their lifecycle callbacks no longer refresh these
|
|
2363
|
+
// surfaces — nothing else would register the widget for a run whose
|
|
2364
|
+
// first agent has not started yet.
|
|
2365
|
+
widget.update();
|
|
2366
|
+
fleet.update();
|
|
2367
|
+
// Background, like Claude Code: the id comes back now and the run keeps
|
|
2368
|
+
// going without the tool call.
|
|
2369
|
+
void runWorkflowTask(ctx, task).then(() => notifyWorkflowFinished(task));
|
|
2370
|
+
return {
|
|
2371
|
+
content: [{
|
|
2372
|
+
type: "text",
|
|
2373
|
+
text: `Workflow "${meta.name}" started in the background.\n` +
|
|
2374
|
+
`Task ID: ${runId}\n` +
|
|
2375
|
+
(task.scriptPath ? `Script: ${task.scriptPath}\n` : "") +
|
|
2376
|
+
(task.resumedFrom !== undefined
|
|
2377
|
+
? `Resuming ${task.resumedFrom}: ${task.replay?.length ?? 0} recorded call(s) available to replay.\n`
|
|
2378
|
+
: params.resumeFromRunId !== undefined
|
|
2379
|
+
? `Nothing to replay from ${params.resumeFromRunId} — every agent runs live.\n`
|
|
2380
|
+
: "") +
|
|
2381
|
+
`\nYou will be notified when it finishes — do NOT poll or sleep waiting for it.\n` +
|
|
2382
|
+
`To iterate, edit the script file and call SubagentWorkflow again with scriptPath.`,
|
|
2383
|
+
}],
|
|
2384
|
+
details: { taskId: runId },
|
|
2385
|
+
};
|
|
2386
|
+
},
|
|
2387
|
+
});
|
|
2388
|
+
if (isWorkflowsEnabled())
|
|
2389
|
+
pi.registerTool(workflowTool);
|
|
2390
|
+
/**
|
|
2391
|
+
* Act on {@link decideWorkflowCollision} — the half that needs the host.
|
|
2392
|
+
*
|
|
2393
|
+
* The policy (what counts as a conflict, what a pin changes, whether there is
|
|
2394
|
+
* anything left to withdraw) lives in `workflow/collisions.ts`; this is the
|
|
2395
|
+
* host-facing shell around it: read the registry, warn, and take our tool out
|
|
2396
|
+
* of the active set.
|
|
2397
|
+
*
|
|
2398
|
+
* ## Why this can only happen at session_start
|
|
2399
|
+
*
|
|
2400
|
+
* `getAllTools` throws during extension loading ("Action methods cannot be
|
|
2401
|
+
* called during extension loading"), and load order means a check at
|
|
2402
|
+
* registration time could not see an extension that has not loaded yet. So
|
|
2403
|
+
* the decision cannot gate `registerTool`; it has to undo it. `setActiveTools`
|
|
2404
|
+
* is what makes that real rather than cosmetic — pi rebuilds the system
|
|
2405
|
+
* prompt from the new set, and `session_start` runs before any turn, so the
|
|
2406
|
+
* model never sees a spec we withdrew. A later `_refreshToolRegistry` keeps
|
|
2407
|
+
* the active set it had and only adds names new to the registry, so ours does
|
|
2408
|
+
* not creep back.
|
|
2409
|
+
*
|
|
2410
|
+
* Best-effort and swallowed. A diagnostic that took the session down would be
|
|
2411
|
+
* worse than the collision it reports.
|
|
2412
|
+
*/
|
|
2413
|
+
let collisionsChecked = false;
|
|
2414
|
+
function resolveWorkflowCollisions(ctx) {
|
|
2415
|
+
if (collisionsChecked)
|
|
2416
|
+
return;
|
|
2417
|
+
collisionsChecked = true;
|
|
2418
|
+
const warn = (message) => {
|
|
2419
|
+
if (ctx.hasUI)
|
|
2420
|
+
ctx.ui.notify(message, "warning");
|
|
2421
|
+
else
|
|
2422
|
+
console.warn(`[pi-subagents] ${message}`);
|
|
2423
|
+
};
|
|
2424
|
+
try {
|
|
2425
|
+
if (!isWorkflowsEnabled())
|
|
2426
|
+
return;
|
|
2427
|
+
const verdict = decideWorkflowCollision({
|
|
2428
|
+
tools: pi.getAllTools(),
|
|
2429
|
+
// Identifies our own registration: this extension does not know its
|
|
2430
|
+
// install path, and the description is the one field certainly ours.
|
|
2431
|
+
ownDescription: workflowTool.description,
|
|
2432
|
+
pinned: isWorkflowsPinned(),
|
|
2433
|
+
});
|
|
2434
|
+
if (verdict.kind === "none")
|
|
2435
|
+
return;
|
|
2436
|
+
if (verdict.kind === "report") {
|
|
2437
|
+
warn(verdict.message);
|
|
2438
|
+
return;
|
|
2439
|
+
}
|
|
2440
|
+
workflowsEnabled = false; // not setWorkflowsEnabled: this is not the user pinning it
|
|
2441
|
+
widget.update();
|
|
2442
|
+
fleet.update();
|
|
2443
|
+
warn(verdict.message);
|
|
2444
|
+
if (!verdict.withdraw)
|
|
2445
|
+
return;
|
|
2446
|
+
const active = pi.getActiveTools();
|
|
2447
|
+
if (active.includes(SUBAGENT_TOOL_NAMES.WORKFLOW)) {
|
|
2448
|
+
pi.setActiveTools(active.filter(name => name !== SUBAGENT_TOOL_NAMES.WORKFLOW));
|
|
2449
|
+
}
|
|
2450
|
+
}
|
|
2451
|
+
catch {
|
|
2452
|
+
// getAllTools/setActiveTools are unavailable in some hosts (print mode,
|
|
2453
|
+
// RPC). Not being able to check is not a reason to fail the session.
|
|
2454
|
+
}
|
|
2455
|
+
}
|
|
2456
|
+
/**
|
|
2457
|
+
* `--subagents-workflow-file=<path>` — run a script at startup, with no LLM
|
|
2458
|
+
* round-trip deciding whether to call the tool.
|
|
2459
|
+
*
|
|
2460
|
+
* Read here rather than at activation because that is the only place the real
|
|
2461
|
+
* value exists: the host activates extensions first and applies collected CLI
|
|
2462
|
+
* flags second, so `getFlag` during activation returns the registered default
|
|
2463
|
+
* and nothing else. `examples/extensions/ssh.ts` reads its flag from
|
|
2464
|
+
* session_start for exactly this reason.
|
|
2465
|
+
*/
|
|
2466
|
+
let workflowFlagHandled = false;
|
|
2467
|
+
function runWorkflowFlag(ctx) {
|
|
2468
|
+
if (workflowFlagHandled)
|
|
2469
|
+
return;
|
|
2470
|
+
const flag = typeof pi.getFlag === "function" ? pi.getFlag(WORKFLOW_FILE_FLAG) : undefined;
|
|
2471
|
+
if (flag === undefined || flag === false)
|
|
2472
|
+
return;
|
|
2473
|
+
workflowFlagHandled = true;
|
|
2474
|
+
const report = (message, level) => {
|
|
2475
|
+
if (ctx.hasUI)
|
|
2476
|
+
ctx.ui.notify(message, level);
|
|
2477
|
+
else
|
|
2478
|
+
console.warn(`[pi-subagents] ${message}`);
|
|
2479
|
+
};
|
|
2480
|
+
// The flag is the same machinery by another door, so the master switch has
|
|
2481
|
+
// to close it too — silently ignoring a flag the user typed would be worse
|
|
2482
|
+
// than saying why nothing ran.
|
|
2483
|
+
if (!isWorkflowsEnabled()) {
|
|
2484
|
+
report(`--${WORKFLOW_FILE_FLAG} ignored: workflows are off. Turn them on in /agents → Settings → Workflows, ` +
|
|
2485
|
+
'or set `"workflowsEnabled": true` in .pi/subagents.json.', "warning");
|
|
2486
|
+
return;
|
|
2487
|
+
}
|
|
2488
|
+
// A bare `--subagents-workflow-file` parses to boolean `true`. Say what was
|
|
2489
|
+
// missing rather than reading a file called "true".
|
|
2490
|
+
if (typeof flag !== "string" || flag.trim() === "") {
|
|
2491
|
+
report(`--${WORKFLOW_FILE_FLAG} needs a path: --${WORKFLOW_FILE_FLAG}=<path>`, "warning");
|
|
2492
|
+
return;
|
|
2493
|
+
}
|
|
2494
|
+
const path = isAbsolute(flag.trim()) ? flag.trim() : join(ctx.cwd, flag.trim());
|
|
2495
|
+
let script;
|
|
2496
|
+
try {
|
|
2497
|
+
script = readFileSync(path, "utf-8");
|
|
2498
|
+
}
|
|
2499
|
+
catch (err) {
|
|
2500
|
+
report(`Could not read ${path}: ${err instanceof Error ? err.message : String(err)}`, "warning");
|
|
2501
|
+
return;
|
|
2502
|
+
}
|
|
2503
|
+
let meta;
|
|
2504
|
+
try {
|
|
2505
|
+
meta = extractMeta(script).meta;
|
|
2506
|
+
}
|
|
2507
|
+
catch (err) {
|
|
2508
|
+
report(err instanceof Error ? err.message : String(err), "warning");
|
|
2509
|
+
return;
|
|
2510
|
+
}
|
|
2511
|
+
const task = createWorkflowTask({ id: workflowRunId(), script, scriptPath: path, meta });
|
|
2512
|
+
workflowTasks.set(task.id, task);
|
|
2513
|
+
widget.update();
|
|
2514
|
+
fleet.update();
|
|
2515
|
+
report(`Running workflow ${meta.name}…`, "info");
|
|
2516
|
+
// Detached: session_start is awaited by the host, and a workflow can run for
|
|
2517
|
+
// minutes — blocking here would hold the whole session's startup.
|
|
2518
|
+
void runWorkflowTask(ctx, task).then(() => {
|
|
2519
|
+
// No tool call to attach a result card to, so the card becomes a session
|
|
2520
|
+
// entry (same layout), and the outcome is handed to the model as context
|
|
2521
|
+
// for its next turn rather than forcing one.
|
|
2522
|
+
pi.appendEntry(WORKFLOW_ENTRY_TYPE, workflowEntryData(task));
|
|
2523
|
+
pi.sendMessage({
|
|
2524
|
+
customType: "workflow-result",
|
|
2525
|
+
content: formatWorkflowNotification(task),
|
|
2526
|
+
display: false,
|
|
2527
|
+
}, { deliverAs: "nextTurn" });
|
|
2528
|
+
widget.update();
|
|
2529
|
+
fleet.update();
|
|
2530
|
+
});
|
|
2531
|
+
}
|
|
1328
2532
|
// ---- get_subagent_result tool ----
|
|
1329
|
-
|
|
2533
|
+
registerToolReportingUsage(defineTool({
|
|
1330
2534
|
name: SUBAGENT_TOOL_NAMES.GET_RESULT,
|
|
1331
2535
|
label: "Get Agent Result",
|
|
1332
|
-
description: "Check status and retrieve
|
|
2536
|
+
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
2537
|
promptSnippet: "Check status and retrieve results from a background agent",
|
|
1334
2538
|
parameters: Type.Object({
|
|
1335
2539
|
agent_id: Type.String({
|
|
1336
|
-
description: "The agent ID to check.",
|
|
2540
|
+
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
2541
|
}),
|
|
1338
2542
|
wait: Type.Optional(Type.Boolean({
|
|
1339
2543
|
description: "If true, wait for the agent to complete before returning. Default: false.",
|
|
@@ -1343,8 +2547,8 @@ Terse command-style prompts produce shallow, generic work.
|
|
|
1343
2547
|
})),
|
|
1344
2548
|
}),
|
|
1345
2549
|
execute: async (_toolCallId, params, signal, _onUpdate, _ctx) => {
|
|
1346
|
-
const record =
|
|
1347
|
-
if (!record) {
|
|
2550
|
+
const record = resolveAgentRef(params.agent_id);
|
|
2551
|
+
if (!record || !isTopLevelAgent(record)) {
|
|
1348
2552
|
return textResult(`Agent not found: "${params.agent_id}". It may have been cleaned up.`);
|
|
1349
2553
|
}
|
|
1350
2554
|
// Wait for completion if requested. Cancellation stops only this tool
|
|
@@ -1359,9 +2563,6 @@ Terse command-style prompts produce shallow, generic work.
|
|
|
1359
2563
|
if (record.promise)
|
|
1360
2564
|
await abortable(record.promise, signal);
|
|
1361
2565
|
}
|
|
1362
|
-
const durableResult = !record.result?.trim() && record.transcriptPath && currentCtx?.cwd
|
|
1363
|
-
? readAgentHistoryResult(currentCtx.cwd, record.transcriptPath)
|
|
1364
|
-
: undefined;
|
|
1365
2566
|
const displayName = getDisplayName(record.type);
|
|
1366
2567
|
const duration = formatDuration(record.startedAt, record.completedAt);
|
|
1367
2568
|
const tokens = formatLifetimeTokens(record);
|
|
@@ -1369,6 +2570,11 @@ Terse command-style prompts produce shallow, generic work.
|
|
|
1369
2570
|
const statsParts = [`Tool uses: ${record.toolUses}`];
|
|
1370
2571
|
if (tokens)
|
|
1371
2572
|
statsParts.push(tokens);
|
|
2573
|
+
if (showCost) {
|
|
2574
|
+
const costText = formatCost(getLifetimeCost(record.lifetimeUsage));
|
|
2575
|
+
if (costText)
|
|
2576
|
+
statsParts.push(`Cost: ${costText}`);
|
|
2577
|
+
}
|
|
1372
2578
|
if (contextPercent !== null)
|
|
1373
2579
|
statsParts.push(`Context: ${Math.round(contextPercent)}%`);
|
|
1374
2580
|
if (record.compactionCount)
|
|
@@ -1381,10 +2587,10 @@ Terse command-style prompts produce shallow, generic work.
|
|
|
1381
2587
|
output += "Agent is still running. Use wait: true or check back later.";
|
|
1382
2588
|
}
|
|
1383
2589
|
else if (record.status === "error") {
|
|
1384
|
-
output += `Error: ${record.error}${partialOutputSuffix(record
|
|
2590
|
+
output += `Error: ${record.error}${partialOutputSuffix(record)}`;
|
|
1385
2591
|
}
|
|
1386
2592
|
else {
|
|
1387
|
-
output +=
|
|
2593
|
+
output += record.result?.trim() || "No output.";
|
|
1388
2594
|
}
|
|
1389
2595
|
// Mark result as consumed — suppresses the completion notification
|
|
1390
2596
|
if (record.status !== "running" && record.status !== "queued") {
|
|
@@ -1402,7 +2608,7 @@ Terse command-style prompts produce shallow, generic work.
|
|
|
1402
2608
|
},
|
|
1403
2609
|
}));
|
|
1404
2610
|
// ---- steer_subagent tool ----
|
|
1405
|
-
|
|
2611
|
+
registerToolReportingUsage(defineTool({
|
|
1406
2612
|
name: SUBAGENT_TOOL_NAMES.STEER,
|
|
1407
2613
|
label: "Steer Agent",
|
|
1408
2614
|
description: "Send a steering message to a running agent. The message will interrupt the agent after its current tool execution " +
|
|
@@ -1410,15 +2616,15 @@ Terse command-style prompts produce shallow, generic work.
|
|
|
1410
2616
|
promptSnippet: "Send a steering message to redirect a running background agent",
|
|
1411
2617
|
parameters: Type.Object({
|
|
1412
2618
|
agent_id: Type.String({
|
|
1413
|
-
description: "The agent ID to steer (must be currently running).",
|
|
2619
|
+
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
2620
|
}),
|
|
1415
2621
|
message: Type.String({
|
|
1416
2622
|
description: "The steering message to send. This will appear as a user message in the agent's conversation.",
|
|
1417
2623
|
}),
|
|
1418
2624
|
}),
|
|
1419
2625
|
execute: async (_toolCallId, params, _signal, _onUpdate, _ctx) => {
|
|
1420
|
-
const record =
|
|
1421
|
-
if (!record) {
|
|
2626
|
+
const record = resolveAgentRef(params.agent_id);
|
|
2627
|
+
if (!record || !isTopLevelAgent(record)) {
|
|
1422
2628
|
return textResult(`Agent not found: "${params.agent_id}". It may have been cleaned up.`);
|
|
1423
2629
|
}
|
|
1424
2630
|
if (record.status !== "running") {
|
|
@@ -1440,6 +2646,11 @@ Terse command-style prompts produce shallow, generic work.
|
|
|
1440
2646
|
const stateParts = [];
|
|
1441
2647
|
if (tokens)
|
|
1442
2648
|
stateParts.push(tokens);
|
|
2649
|
+
if (showCost) {
|
|
2650
|
+
const costText = formatCost(getLifetimeCost(record.lifetimeUsage));
|
|
2651
|
+
if (costText)
|
|
2652
|
+
stateParts.push(costText);
|
|
2653
|
+
}
|
|
1443
2654
|
stateParts.push(`${record.toolUses} tool ${record.toolUses === 1 ? "use" : "uses"}`);
|
|
1444
2655
|
if (contextPercent !== null)
|
|
1445
2656
|
stateParts.push(`context ${Math.round(contextPercent)}% full`);
|
|
@@ -1454,22 +2665,9 @@ Terse command-style prompts produce shallow, generic work.
|
|
|
1454
2665
|
},
|
|
1455
2666
|
}));
|
|
1456
2667
|
// ---- /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
|
-
}
|
|
2668
|
+
// Directory resolution and the frontmatter edits live in agent-file-toggle.ts
|
|
2669
|
+
// so they are reachable from tests — this command handler is only registered
|
|
2670
|
+
// through `registerCommand`, which every test mocks.
|
|
1473
2671
|
function getModelLabel(type, registry) {
|
|
1474
2672
|
const cfg = getAgentConfig(type);
|
|
1475
2673
|
if (!cfg?.model)
|
|
@@ -1496,10 +2694,16 @@ Terse command-style prompts produce shallow, generic work.
|
|
|
1496
2694
|
const allNames = getAllTypes();
|
|
1497
2695
|
// Build select options
|
|
1498
2696
|
const options = [];
|
|
1499
|
-
// Keep active
|
|
1500
|
-
const
|
|
1501
|
-
const { active, history } = splitAgentRecords(
|
|
1502
|
-
|
|
2697
|
+
// Keep active sessions and durable terminal history as separate menu rows.
|
|
2698
|
+
const agents = manager.listAgents().filter(isTopLevelAgent);
|
|
2699
|
+
const { active, history } = splitAgentRecords(agents, ctx.cwd);
|
|
2700
|
+
if (active.length > 0) {
|
|
2701
|
+
const running = active.filter(a => a.status === "running").length;
|
|
2702
|
+
const queued = active.filter(a => a.status === "queued").length;
|
|
2703
|
+
options.push(`Running agents (${active.length}) — ${running} running, ${queued} queued`);
|
|
2704
|
+
}
|
|
2705
|
+
if (history.length > 0)
|
|
2706
|
+
options.push(`Agent history (${history.length})`);
|
|
1503
2707
|
// Agent types list
|
|
1504
2708
|
if (allNames.length > 0) {
|
|
1505
2709
|
options.push(`Agent types (${allNames.length})`);
|
|
@@ -1509,10 +2713,15 @@ Terse command-style prompts produce shallow, generic work.
|
|
|
1509
2713
|
const jobCount = scheduler.list().length;
|
|
1510
2714
|
options.push(`Scheduled jobs (${jobCount})`);
|
|
1511
2715
|
}
|
|
2716
|
+
// Workflow runs, on the same terms as scheduled jobs: shown only when the
|
|
2717
|
+
// feature is on, so the menu never advertises something switched off.
|
|
2718
|
+
if (isWorkflowsEnabled()) {
|
|
2719
|
+
options.push(`Workflows (${workflowTasks.size})`);
|
|
2720
|
+
}
|
|
1512
2721
|
// Actions
|
|
1513
2722
|
options.push("Create new agent");
|
|
1514
2723
|
options.push("Settings");
|
|
1515
|
-
const noAgentsMsg = allNames.length === 0 &&
|
|
2724
|
+
const noAgentsMsg = allNames.length === 0 && agents.length === 0
|
|
1516
2725
|
? "No agents found. Create specialized subagents that can be delegated to.\n\n" +
|
|
1517
2726
|
"Each subagent has its own context window, custom system prompt, and specific tools.\n\n" +
|
|
1518
2727
|
"Try creating: Code Reviewer, Security Auditor, Test Writer, or Documentation Writer.\n\n"
|
|
@@ -1539,6 +2748,10 @@ Terse command-style prompts produce shallow, generic work.
|
|
|
1539
2748
|
await showSchedulesMenu(ctx, scheduler);
|
|
1540
2749
|
await showAgentsMenu(ctx);
|
|
1541
2750
|
}
|
|
2751
|
+
else if (choice.startsWith("Workflows (")) {
|
|
2752
|
+
await showWorkflowsMenu(ctx, workflowMenuDeps);
|
|
2753
|
+
await showAgentsMenu(ctx);
|
|
2754
|
+
}
|
|
1542
2755
|
else if (choice === "Create new agent") {
|
|
1543
2756
|
await showCreateWizard(ctx);
|
|
1544
2757
|
}
|
|
@@ -1610,137 +2823,80 @@ Terse command-style prompts produce shallow, generic work.
|
|
|
1610
2823
|
await showAllAgentsList(ctx);
|
|
1611
2824
|
}
|
|
1612
2825
|
}
|
|
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
2826
|
async function showRunningAgents(ctx) {
|
|
1676
|
-
const
|
|
2827
|
+
const agents = manager.listAgents().filter(record => isTopLevelAgent(record) && (record.status === "running" || record.status === "queued"));
|
|
1677
2828
|
if (agents.length === 0) {
|
|
1678
2829
|
ctx.ui.notify("No agents.", "info");
|
|
1679
2830
|
return;
|
|
1680
2831
|
}
|
|
1681
|
-
const
|
|
1682
|
-
|
|
1683
|
-
|
|
1684
|
-
|
|
2832
|
+
const record = await ctx.ui.custom((_tui, _theme, _keys, done) => {
|
|
2833
|
+
let index = Math.min(runningSelectionIndex, agents.length - 1);
|
|
2834
|
+
return {
|
|
2835
|
+
render: (width) => agents.map((agent, row) => `${row === index ? "→" : " "} ${agent.description}`.slice(0, width)),
|
|
2836
|
+
invalidate() { },
|
|
2837
|
+
handleInput(data) {
|
|
2838
|
+
if (data === "\u001b[B")
|
|
2839
|
+
index = Math.min(agents.length - 1, index + 1);
|
|
2840
|
+
else if (data === "\u001b[A")
|
|
2841
|
+
index = Math.max(0, index - 1);
|
|
2842
|
+
else if (data === "\r" || data === "\n") {
|
|
2843
|
+
runningSelectionIndex = index;
|
|
2844
|
+
done(agents[index]);
|
|
2845
|
+
}
|
|
2846
|
+
else if (data === "\u001b") {
|
|
2847
|
+
runningSelectionIndex = index;
|
|
2848
|
+
done(undefined);
|
|
2849
|
+
}
|
|
2850
|
+
},
|
|
2851
|
+
};
|
|
1685
2852
|
});
|
|
1686
|
-
makeUniqueAgentOptionLabels(pairs);
|
|
1687
|
-
const record = await selectAgentFromReadOnlyList(ctx, "Running agents", pairs, runningAgentSelection);
|
|
1688
2853
|
if (!record)
|
|
1689
2854
|
return;
|
|
1690
|
-
await viewAgentConversation(ctx, record
|
|
1691
|
-
// Back-navigation: re-show the list at the previously selected agent.
|
|
2855
|
+
await viewAgentConversation(ctx, record);
|
|
1692
2856
|
await showRunningAgents(ctx);
|
|
1693
2857
|
}
|
|
1694
2858
|
async function showAgentHistory(ctx) {
|
|
1695
|
-
const
|
|
1696
|
-
if (history.length === 0)
|
|
1697
|
-
ctx.ui.notify("No agent history.", "info");
|
|
2859
|
+
const history = manager.listAgents().filter(record => isTopLevelAgent(record) && canOpenAgentHistory(record, ctx.cwd));
|
|
2860
|
+
if (history.length === 0)
|
|
1698
2861
|
return;
|
|
2862
|
+
const selected = await ctx.ui.custom((_tui, _theme, _keys, done) => {
|
|
2863
|
+
let index = Math.min(historySelectionIndex, history.length - 1);
|
|
2864
|
+
return {
|
|
2865
|
+
render: (width) => history.map((record, row) => `${row === index ? "→" : " "} ${record.description}`.slice(0, width)),
|
|
2866
|
+
invalidate() { },
|
|
2867
|
+
handleInput(data) {
|
|
2868
|
+
if (data === "\u001b[B")
|
|
2869
|
+
index = Math.min(history.length - 1, index + 1);
|
|
2870
|
+
else if (data === "\u001b[A")
|
|
2871
|
+
index = Math.max(0, index - 1);
|
|
2872
|
+
else if (data === "\r" || data === "\n") {
|
|
2873
|
+
historySelectionIndex = index;
|
|
2874
|
+
done(history[index]);
|
|
2875
|
+
}
|
|
2876
|
+
else if (data === "\u001b")
|
|
2877
|
+
done(undefined);
|
|
2878
|
+
},
|
|
2879
|
+
};
|
|
2880
|
+
});
|
|
2881
|
+
if (selected) {
|
|
2882
|
+
await viewAgentConversation(ctx, selected);
|
|
2883
|
+
await showAgentHistory(ctx);
|
|
1699
2884
|
}
|
|
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
2885
|
}
|
|
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
|
-
}
|
|
2886
|
+
async function viewAgentConversation(ctx, record) {
|
|
1718
2887
|
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
|
-
})();
|
|
2888
|
+
const messages = record.transcriptPath ? readAgentHistory(ctx.cwd, record.transcriptPath) : undefined;
|
|
2889
|
+
const session = record.session ?? (messages ? createStaticConversationSource(messages) : undefined);
|
|
1731
2890
|
if (!session) {
|
|
1732
|
-
ctx.ui.notify(
|
|
2891
|
+
ctx.ui.notify(`Agent is ${record.status === "queued" ? "queued" : "expired"} — no session available.`, "info");
|
|
1733
2892
|
return;
|
|
1734
2893
|
}
|
|
2894
|
+
const isHistory = record.session === undefined;
|
|
1735
2895
|
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
|
-
}, {
|
|
2896
|
+
await ctx.ui.custom((tui, theme, keybindings, done) => new ConversationViewer(tui, session, record, activity, theme, done, isHistory ? undefined : () => {
|
|
2897
|
+
if (manager.abort(record.id))
|
|
2898
|
+
ctx.ui.notify(`Stopped "${record.description}".`, "info");
|
|
2899
|
+
}, keybindings, isHistory ? undefined : (message) => manager.steer(record.id, message), { pi, ctx, readOnly: isHistory }), {
|
|
1744
2900
|
overlay: true,
|
|
1745
2901
|
overlayOptions: { anchor: "center", width: "90%", maxHeight: `${VIEWPORT_HEIGHT_PCT}%` },
|
|
1746
2902
|
});
|
|
@@ -1751,7 +2907,7 @@ Terse command-style prompts produce shallow, generic work.
|
|
|
1751
2907
|
ctx.ui.notify(`Agent config not found for "${name}".`, "warning");
|
|
1752
2908
|
return;
|
|
1753
2909
|
}
|
|
1754
|
-
const file =
|
|
2910
|
+
const file = locateAgentFile(name, cfg.sourcePath);
|
|
1755
2911
|
const isDefault = cfg.isDefault === true;
|
|
1756
2912
|
const disabled = cfg.enabled === false;
|
|
1757
2913
|
let menuOptions;
|
|
@@ -1830,44 +2986,7 @@ Terse command-style prompts produce shallow, generic work.
|
|
|
1830
2986
|
if (!overwrite)
|
|
1831
2987
|
return;
|
|
1832
2988
|
}
|
|
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`;
|
|
2989
|
+
const content = serializeAgentFile(cfg);
|
|
1871
2990
|
const { writeFileSync } = await import("node:fs");
|
|
1872
2991
|
writeFileSync(targetPath, content, "utf-8");
|
|
1873
2992
|
reloadCustomAgents();
|
|
@@ -1875,15 +2994,21 @@ Terse command-style prompts produce shallow, generic work.
|
|
|
1875
2994
|
}
|
|
1876
2995
|
/** Disable an agent: set enabled: false in its .md file, or create a stub for built-in defaults. */
|
|
1877
2996
|
async function disableAgent(ctx, name) {
|
|
1878
|
-
const file =
|
|
2997
|
+
const file = locateAgentFile(name, getAgentConfig(name)?.sourcePath);
|
|
1879
2998
|
if (file) {
|
|
1880
2999
|
// Existing file — set enabled: false in frontmatter (idempotent)
|
|
1881
3000
|
const content = readFileSync(file.path, "utf-8");
|
|
1882
|
-
|
|
3001
|
+
const { content: updated, outcome } = disableInContent(content);
|
|
3002
|
+
if (outcome === "already-disabled") {
|
|
1883
3003
|
ctx.ui.notify(`${name} is already disabled.`, "info");
|
|
1884
3004
|
return;
|
|
1885
3005
|
}
|
|
1886
|
-
|
|
3006
|
+
if (outcome === "no-frontmatter") {
|
|
3007
|
+
// Nothing to edit — say so rather than rewriting the file unchanged and
|
|
3008
|
+
// reporting success for a change that never happened.
|
|
3009
|
+
ctx.ui.notify(`Cannot disable ${name}: ${file.path} has no frontmatter block.`, "error");
|
|
3010
|
+
return;
|
|
3011
|
+
}
|
|
1887
3012
|
const { writeFileSync } = await import("node:fs");
|
|
1888
3013
|
writeFileSync(file.path, updated, "utf-8");
|
|
1889
3014
|
reloadCustomAgents();
|
|
@@ -1907,14 +3032,20 @@ Terse command-style prompts produce shallow, generic work.
|
|
|
1907
3032
|
}
|
|
1908
3033
|
/** Enable a disabled agent by removing enabled: false from its frontmatter. */
|
|
1909
3034
|
async function enableAgent(ctx, name) {
|
|
1910
|
-
const file =
|
|
3035
|
+
const file = locateAgentFile(name, getAgentConfig(name)?.sourcePath);
|
|
1911
3036
|
if (!file)
|
|
1912
3037
|
return;
|
|
1913
3038
|
const content = readFileSync(file.path, "utf-8");
|
|
1914
|
-
const updated = content
|
|
3039
|
+
const { content: updated, changed } = enableInContent(content);
|
|
3040
|
+
if (!changed && !isEmptyStub(updated)) {
|
|
3041
|
+
// The file carries no `enabled: false` to remove, so it was never disabled
|
|
3042
|
+
// by us — reporting success here would hide a no-op.
|
|
3043
|
+
ctx.ui.notify(`${name} is not disabled in ${file.path}.`, "info");
|
|
3044
|
+
return;
|
|
3045
|
+
}
|
|
1915
3046
|
const { writeFileSync } = await import("node:fs");
|
|
1916
3047
|
// If the file was just a stub ("---\n---\n"), delete it to restore the built-in default
|
|
1917
|
-
if (
|
|
3048
|
+
if (isEmptyStub(updated)) {
|
|
1918
3049
|
unlinkSync(file.path);
|
|
1919
3050
|
reloadCustomAgents();
|
|
1920
3051
|
ctx.ui.notify(`Enabled ${name} (removed ${file.path})`, "info");
|
|
@@ -1970,6 +3101,7 @@ The file format is a markdown file with YAML frontmatter and a system prompt bod
|
|
|
1970
3101
|
\`\`\`markdown
|
|
1971
3102
|
---
|
|
1972
3103
|
description: <one-line description shown in UI>
|
|
3104
|
+
color: <optional agent name badge color: red, blue, green, yellow, purple, orange, pink, cyan, an Agency Agents alias, or quoted "#RRGGBB">
|
|
1973
3105
|
tools: <comma-separated built-in tools: read, bash, edit, write, grep, find, ls. Use "none" for no tools. Omit for all tools>
|
|
1974
3106
|
model: <optional model as "provider/modelId", e.g. "anthropic/claude-haiku-4-5". Omit to inherit parent model>
|
|
1975
3107
|
thinking: <optional thinking level: ${THINKING_LEVELS.join(", ")}. Omit to inherit>
|
|
@@ -1979,11 +3111,17 @@ extensions: <true (inherit all MCP/extension tools), false (none), or comma-sepa
|
|
|
1979
3111
|
skills: <true (inherit all), false (none), or comma-separated skill names to preload into prompt. Default: true>
|
|
1980
3112
|
disallowed_tools: <comma-separated tool names to block, even if otherwise available. Omit for none>
|
|
1981
3113
|
inherit_context: <true to fork parent conversation into agent so it sees chat history. Default: false>
|
|
1982
|
-
run_in_background: <
|
|
3114
|
+
run_in_background: <pin this agent to background (true) or foreground (false). Omit to follow the backgroundByDefault setting, which is background>
|
|
1983
3115
|
output_transcript: <false to write no transcript file or path for this agent. Independent of persist_session. Default: true>
|
|
1984
3116
|
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
|
-
|
|
3117
|
+
memory: <"user" (global), "project" (per-project), or "local" (gitignored per-project) for persistent memory. Omit for none>${
|
|
3118
|
+
// Offering the field on a project that turned worktrees off would bake a
|
|
3119
|
+
// request that is refused at spawn time into a file that outlives the
|
|
3120
|
+
// session — the #231 pathology (models fill the fields they are shown)
|
|
3121
|
+
// one layer up. Built per invocation, so this read is live.
|
|
3122
|
+
isWorktreeIsolationEnabled()
|
|
3123
|
+
? `\nisolation: <"worktree" to run in isolated git worktree; "off" to refuse one even when the caller asks. Omit for normal>`
|
|
3124
|
+
: ""}
|
|
1987
3125
|
---
|
|
1988
3126
|
|
|
1989
3127
|
<system prompt body — instructions for the agent>
|
|
@@ -2003,6 +3141,12 @@ Write the file using the write tool. Only write the file, nothing else.`;
|
|
|
2003
3141
|
const { record } = await manager.spawnAndWait(pi, ctx, "general-purpose", generatePrompt, {
|
|
2004
3142
|
description: `Generate ${name} agent`,
|
|
2005
3143
|
maxTurns: 5,
|
|
3144
|
+
// Exempt from maxConcurrentForeground. This runs from a modal wizard, not
|
|
3145
|
+
// a tool call: it passes no signal, and Esc in `ctx.ui` never reaches the
|
|
3146
|
+
// manager — so a user waiting behind a full pool would have no way to
|
|
3147
|
+
// cancel at all. It is also one human action that cannot fan out, which
|
|
3148
|
+
// is what the limit exists to bound. It still counts once started.
|
|
3149
|
+
bypassQueue: true,
|
|
2006
3150
|
});
|
|
2007
3151
|
if (record.status === "error") {
|
|
2008
3152
|
ctx.ui.notify(`Generation failed: ${record.error}`, "warning");
|
|
@@ -2055,39 +3199,32 @@ Write the file using the write tool. Only write the file, nothing else.`;
|
|
|
2055
3199
|
]);
|
|
2056
3200
|
if (!modelChoice)
|
|
2057
3201
|
return;
|
|
2058
|
-
let
|
|
3202
|
+
let model;
|
|
2059
3203
|
if (modelChoice === "haiku")
|
|
2060
|
-
|
|
3204
|
+
model = "anthropic/claude-haiku-4-5";
|
|
2061
3205
|
else if (modelChoice === "sonnet")
|
|
2062
|
-
|
|
3206
|
+
model = "anthropic/claude-sonnet-4-6";
|
|
2063
3207
|
else if (modelChoice === "opus")
|
|
2064
|
-
|
|
3208
|
+
model = "anthropic/claude-opus-4-6";
|
|
2065
3209
|
else if (modelChoice === "custom...") {
|
|
2066
|
-
|
|
2067
|
-
if (customModel)
|
|
2068
|
-
modelLine = `\nmodel: ${customModel}`;
|
|
3210
|
+
model = (await ctx.ui.input("Model (provider/modelId)")) || undefined;
|
|
2069
3211
|
}
|
|
2070
3212
|
// 5. Thinking
|
|
2071
3213
|
// "inherit" is a UI-only pseudo-choice (omit the field); the rest mirror pi.
|
|
2072
3214
|
const thinkingChoice = await ctx.ui.select("Thinking level", ["inherit", ...THINKING_LEVELS]);
|
|
2073
3215
|
if (!thinkingChoice)
|
|
2074
3216
|
return;
|
|
2075
|
-
let thinkingLine = "";
|
|
2076
|
-
if (thinkingChoice !== "inherit")
|
|
2077
|
-
thinkingLine = `\nthinking: ${thinkingChoice}`;
|
|
2078
3217
|
// 6. System prompt
|
|
2079
3218
|
const systemPrompt = await ctx.ui.editor("System prompt", "");
|
|
2080
3219
|
if (systemPrompt === undefined)
|
|
2081
3220
|
return;
|
|
2082
|
-
|
|
2083
|
-
|
|
2084
|
-
|
|
2085
|
-
|
|
2086
|
-
|
|
2087
|
-
|
|
2088
|
-
|
|
2089
|
-
${systemPrompt}
|
|
2090
|
-
`;
|
|
3221
|
+
const content = buildNewAgentFile({
|
|
3222
|
+
description,
|
|
3223
|
+
tools,
|
|
3224
|
+
model,
|
|
3225
|
+
thinking: thinkingChoice === "inherit" ? undefined : thinkingChoice,
|
|
3226
|
+
systemPrompt,
|
|
3227
|
+
});
|
|
2091
3228
|
mkdirSync(targetDir, { recursive: true });
|
|
2092
3229
|
const targetPath = join(targetDir, `${name}.md`);
|
|
2093
3230
|
if (existsSync(targetPath)) {
|
|
@@ -2100,28 +3237,76 @@ ${systemPrompt}
|
|
|
2100
3237
|
reloadCustomAgents();
|
|
2101
3238
|
ctx.ui.notify(`Created ${targetPath}`, "info");
|
|
2102
3239
|
}
|
|
3240
|
+
/**
|
|
3241
|
+
* Every settings mutation writes this WHOLE object back to disk, so a field
|
|
3242
|
+
* missing here is erased from the user's subagents.json the next time they
|
|
3243
|
+
* toggle something unrelated. `SubagentsSettings` has every field optional,
|
|
3244
|
+
* so a `: SubagentsSettings` return annotation would let a newly-added setting
|
|
3245
|
+
* be forgotten here and still type-check. `satisfies` instead: it still checks
|
|
3246
|
+
* each value's type and rejects a mistyped key, but leaves the return type
|
|
3247
|
+
* inferred so `_NoMissingSettingsKeys` below can check completeness.
|
|
3248
|
+
*/
|
|
2103
3249
|
function snapshotSettings() {
|
|
2104
3250
|
return {
|
|
2105
3251
|
maxConcurrent: manager.getMaxConcurrent(),
|
|
3252
|
+
// 0 = unlimited, and the default — see SubagentsSettings.
|
|
3253
|
+
maxConcurrentForeground: manager.getMaxConcurrentForeground(),
|
|
2106
3254
|
// 0 = unlimited — per SubagentsSettings.defaultMaxTurns docstring and
|
|
2107
3255
|
// normalizeMaxTurns() in agent-runner.ts (which maps 0 → undefined).
|
|
2108
3256
|
defaultMaxTurns: getDefaultMaxTurns() ?? 0,
|
|
2109
3257
|
graceTurns: getGraceTurns(),
|
|
2110
3258
|
defaultJoinMode: getDefaultJoinMode(),
|
|
3259
|
+
backgroundByDefault: getBackgroundByDefault(),
|
|
2111
3260
|
schedulingEnabled: isSchedulingEnabled(),
|
|
2112
3261
|
scopeModels: isScopeModelsEnabled(),
|
|
3262
|
+
strictAgentFiles,
|
|
2113
3263
|
disableDefaultAgents: isDefaultsDisabled(),
|
|
2114
3264
|
toolDescriptionMode: getToolDescriptionMode(),
|
|
3265
|
+
fleetView: isFleetViewEnabled(),
|
|
3266
|
+
agentMentions: getAgentMentionMode(),
|
|
3267
|
+
rememberAgents: getRememberAgents(),
|
|
2115
3268
|
widgetMode: getWidgetMode(),
|
|
2116
3269
|
outputTranscript: getOutputTranscriptDefault(),
|
|
3270
|
+
worktreeIsolation: isWorktreeIsolationEnabled(),
|
|
3271
|
+
// The user's answer, not the effective one. A stand-down for another
|
|
3272
|
+
// extension's workflow tool is scoped to the session it was detected in;
|
|
3273
|
+
// writing it here would let an unrelated settings change three menus away
|
|
3274
|
+
// freeze it into the file as an explicit `false`, which then survives
|
|
3275
|
+
// uninstalling the extension it was deferring to. undefined is dropped by
|
|
3276
|
+
// JSON.stringify, so unset stays unset — same reasoning as
|
|
3277
|
+
// `fallbackSubagent` below.
|
|
3278
|
+
workflowsEnabled: isWorkflowsPinned() ? isWorkflowsEnabled() : undefined,
|
|
3279
|
+
maxSubagentDepth: getMaxSubagentDepth(),
|
|
3280
|
+
// Deliberately NOT `?? "general-purpose"`: every settings change writes the
|
|
3281
|
+
// whole snapshot, and materializing the implicit default would turn it into
|
|
3282
|
+
// explicit configuration — which then fails loudly if general-purpose later
|
|
3283
|
+
// goes away. undefined is dropped by JSON.stringify.
|
|
3284
|
+
fallbackSubagent: getFallbackSubagent(),
|
|
3285
|
+
reportUsage: isReportUsageEnabled(),
|
|
3286
|
+
showCost: isShowCostEnabled(),
|
|
3287
|
+
showModel: isShowModelEnabled(),
|
|
3288
|
+
viewerMarkdown: getViewerMarkdown(),
|
|
2117
3289
|
};
|
|
2118
3290
|
}
|
|
2119
|
-
const
|
|
3291
|
+
const _settingsSnapshotIsComplete = true;
|
|
3292
|
+
void _settingsSnapshotIsComplete;
|
|
3293
|
+
const NUMERIC_IDS = new Set([
|
|
3294
|
+
"maxConcurrent", "maxConcurrentForeground", "defaultMaxTurns", "graceTurns", "maxSubagentDepth",
|
|
3295
|
+
]);
|
|
2120
3296
|
async function showSettings(ctx) {
|
|
2121
3297
|
function buildItems() {
|
|
2122
3298
|
const mc = manager.getMaxConcurrent();
|
|
3299
|
+
const mcf = manager.getMaxConcurrentForeground();
|
|
2123
3300
|
const dmt = getDefaultMaxTurns() ?? 0;
|
|
2124
3301
|
const gt = getGraceTurns();
|
|
3302
|
+
const msd = getMaxSubagentDepth();
|
|
3303
|
+
// Label what unset actually does — it targets general-purpose even when
|
|
3304
|
+
// that is unregistered (the permissive hardcoded tier), so showing "none"
|
|
3305
|
+
// there would advertise strict dispatch for the most permissive state.
|
|
3306
|
+
// `values` still offers only resolvable targets, so the user cannot
|
|
3307
|
+
// persist a fallback that would hard-error on every dispatch.
|
|
3308
|
+
const fallbackValue = getFallbackSubagent() ?? "general-purpose";
|
|
3309
|
+
const fallbackValues = [...new Set([...getAvailableTypes(), NO_FALLBACK])];
|
|
2125
3310
|
return [
|
|
2126
3311
|
{
|
|
2127
3312
|
id: "maxConcurrent",
|
|
@@ -2130,6 +3315,13 @@ ${systemPrompt}
|
|
|
2130
3315
|
currentValue: String(mc),
|
|
2131
3316
|
values: [String(mc)],
|
|
2132
3317
|
},
|
|
3318
|
+
{
|
|
3319
|
+
id: "maxConcurrentForeground",
|
|
3320
|
+
label: "Max foreground concurrency",
|
|
3321
|
+
description: "Max concurrent foreground (blocking) agents (0 = unlimited, Enter to type)",
|
|
3322
|
+
currentValue: String(mcf),
|
|
3323
|
+
values: [String(mcf)],
|
|
3324
|
+
},
|
|
2133
3325
|
{
|
|
2134
3326
|
id: "defaultMaxTurns",
|
|
2135
3327
|
label: "Default max turns",
|
|
@@ -2144,6 +3336,13 @@ ${systemPrompt}
|
|
|
2144
3336
|
currentValue: String(gt),
|
|
2145
3337
|
values: [String(gt)],
|
|
2146
3338
|
},
|
|
3339
|
+
{
|
|
3340
|
+
id: "maxSubagentDepth",
|
|
3341
|
+
label: "Nested depth",
|
|
3342
|
+
description: "Hard cap on nested delegation — main is 0, its subagents 1 (0/1 = nesting off, Enter to type)",
|
|
3343
|
+
currentValue: String(msd),
|
|
3344
|
+
values: [String(msd)],
|
|
3345
|
+
},
|
|
2147
3346
|
{
|
|
2148
3347
|
id: "joinMode",
|
|
2149
3348
|
label: "Join mode",
|
|
@@ -2151,6 +3350,13 @@ ${systemPrompt}
|
|
|
2151
3350
|
currentValue: getDefaultJoinMode(),
|
|
2152
3351
|
values: ["smart", "async", "group"],
|
|
2153
3352
|
},
|
|
3353
|
+
{
|
|
3354
|
+
id: "backgroundByDefault",
|
|
3355
|
+
label: "Background by default",
|
|
3356
|
+
description: "An Agent call that doesn't say runs detached (off = blocks the turn and returns inline)",
|
|
3357
|
+
currentValue: getBackgroundByDefault() ? "on" : "off",
|
|
3358
|
+
values: ["on", "off"],
|
|
3359
|
+
},
|
|
2154
3360
|
{
|
|
2155
3361
|
id: "schedulingEnabled",
|
|
2156
3362
|
label: "Scheduling",
|
|
@@ -2158,6 +3364,14 @@ ${systemPrompt}
|
|
|
2158
3364
|
currentValue: isSchedulingEnabled() ? "on" : "off",
|
|
2159
3365
|
values: ["on", "off"],
|
|
2160
3366
|
},
|
|
3367
|
+
{
|
|
3368
|
+
id: "workflowsEnabled",
|
|
3369
|
+
label: "Workflows",
|
|
3370
|
+
description: "Scripted workflows, on unless another extension provides a workflow tool "
|
|
3371
|
+
+ "(off keeps the SubagentWorkflow tool out of the tool spec; applies on next pi session)",
|
|
3372
|
+
currentValue: isWorkflowsEnabled() ? "on" : "off",
|
|
3373
|
+
values: ["on", "off"],
|
|
3374
|
+
},
|
|
2161
3375
|
{
|
|
2162
3376
|
id: "scopeModels",
|
|
2163
3377
|
label: "Scope models",
|
|
@@ -2165,6 +3379,13 @@ ${systemPrompt}
|
|
|
2165
3379
|
currentValue: isScopeModelsEnabled() ? "on" : "off",
|
|
2166
3380
|
values: ["on", "off"],
|
|
2167
3381
|
},
|
|
3382
|
+
{
|
|
3383
|
+
id: "strictAgentFiles",
|
|
3384
|
+
label: "Strict agent files",
|
|
3385
|
+
description: "Fail startup on an unreadable/unparseable agent .md instead of skipping it with a warning",
|
|
3386
|
+
currentValue: strictAgentFiles ? "on" : "off",
|
|
3387
|
+
values: ["on", "off"],
|
|
3388
|
+
},
|
|
2168
3389
|
{
|
|
2169
3390
|
id: "disableDefaultAgents",
|
|
2170
3391
|
label: "Disable defaults",
|
|
@@ -2172,6 +3393,13 @@ ${systemPrompt}
|
|
|
2172
3393
|
currentValue: isDefaultsDisabled() ? "on" : "off",
|
|
2173
3394
|
values: ["on", "off"],
|
|
2174
3395
|
},
|
|
3396
|
+
{
|
|
3397
|
+
id: "fallbackSubagent",
|
|
3398
|
+
label: "Fallback agent",
|
|
3399
|
+
description: `Agent used when subagent_type is unknown, disabled, or ambiguous; "${NO_FALLBACK}" rejects the call instead (strict dispatch)`,
|
|
3400
|
+
currentValue: fallbackValue,
|
|
3401
|
+
values: fallbackValues,
|
|
3402
|
+
},
|
|
2175
3403
|
{
|
|
2176
3404
|
id: "outputTranscript",
|
|
2177
3405
|
label: "Output transcript",
|
|
@@ -2179,6 +3407,62 @@ ${systemPrompt}
|
|
|
2179
3407
|
currentValue: getOutputTranscriptDefault() ? "on" : "off",
|
|
2180
3408
|
values: ["on", "off"],
|
|
2181
3409
|
},
|
|
3410
|
+
{
|
|
3411
|
+
id: "worktreeIsolation",
|
|
3412
|
+
label: "Worktree isolation",
|
|
3413
|
+
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.",
|
|
3414
|
+
currentValue: isWorktreeIsolationEnabled() ? "on" : "off",
|
|
3415
|
+
values: ["on", "off"],
|
|
3416
|
+
},
|
|
3417
|
+
{
|
|
3418
|
+
id: "reportUsage",
|
|
3419
|
+
label: "Report usage to session",
|
|
3420
|
+
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.",
|
|
3421
|
+
currentValue: isReportUsageEnabled() ? "on" : "off",
|
|
3422
|
+
values: ["on", "off"],
|
|
3423
|
+
},
|
|
3424
|
+
{
|
|
3425
|
+
id: "showCost",
|
|
3426
|
+
label: "Show cost",
|
|
3427
|
+
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.",
|
|
3428
|
+
currentValue: isShowCostEnabled() ? "on" : "off",
|
|
3429
|
+
values: ["on", "off"],
|
|
3430
|
+
},
|
|
3431
|
+
{
|
|
3432
|
+
id: "showModel",
|
|
3433
|
+
label: "Show model",
|
|
3434
|
+
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.",
|
|
3435
|
+
currentValue: isShowModelEnabled() ? "on" : "off",
|
|
3436
|
+
values: ["on", "off"],
|
|
3437
|
+
},
|
|
3438
|
+
{
|
|
3439
|
+
id: "viewerMarkdown",
|
|
3440
|
+
label: "Viewer markdown",
|
|
3441
|
+
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+).",
|
|
3442
|
+
currentValue: getViewerMarkdown(),
|
|
3443
|
+
values: ["off", "assistant", "all"],
|
|
3444
|
+
},
|
|
3445
|
+
{
|
|
3446
|
+
id: "fleetView",
|
|
3447
|
+
label: "Fleet view",
|
|
3448
|
+
description: "Claude Code-style main+subagents list below the editor (↓/← to navigate, Enter to view)",
|
|
3449
|
+
currentValue: isFleetViewEnabled() ? "on" : "off",
|
|
3450
|
+
values: ["on", "off"],
|
|
3451
|
+
},
|
|
3452
|
+
{
|
|
3453
|
+
id: "agentMentions",
|
|
3454
|
+
label: "Agent mentions",
|
|
3455
|
+
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.",
|
|
3456
|
+
currentValue: getAgentMentionMode(),
|
|
3457
|
+
values: ["model", "direct", "off"],
|
|
3458
|
+
},
|
|
3459
|
+
{
|
|
3460
|
+
id: "rememberAgents",
|
|
3461
|
+
label: "Remember agents",
|
|
3462
|
+
description: "Persist subagent sessions so `@handle` can resume one long after it finished (they also appear in /resume)",
|
|
3463
|
+
currentValue: getRememberAgents() ? "on" : "off",
|
|
3464
|
+
values: ["on", "off"],
|
|
3465
|
+
},
|
|
2182
3466
|
{
|
|
2183
3467
|
id: "widgetMode",
|
|
2184
3468
|
label: "Widget",
|
|
@@ -2203,6 +3487,16 @@ ${systemPrompt}
|
|
|
2203
3487
|
notifyApplied(ctx, `Max concurrency set to ${n}`);
|
|
2204
3488
|
}
|
|
2205
3489
|
}
|
|
3490
|
+
else if (id === "maxConcurrentForeground") {
|
|
3491
|
+
// 0 is meaningful here, unlike maxConcurrent above: it means unlimited.
|
|
3492
|
+
const n = parseInt(value, 10);
|
|
3493
|
+
if (n >= 0) {
|
|
3494
|
+
manager.setMaxConcurrentForeground(n);
|
|
3495
|
+
notifyApplied(ctx, n === 0
|
|
3496
|
+
? "Max foreground concurrency set to unlimited"
|
|
3497
|
+
: `Max foreground concurrency set to ${n}`);
|
|
3498
|
+
}
|
|
3499
|
+
}
|
|
2206
3500
|
else if (id === "defaultMaxTurns") {
|
|
2207
3501
|
const n = parseInt(value, 10);
|
|
2208
3502
|
if (n === 0) {
|
|
@@ -2221,10 +3515,26 @@ ${systemPrompt}
|
|
|
2221
3515
|
notifyApplied(ctx, `Grace turns set to ${n}`);
|
|
2222
3516
|
}
|
|
2223
3517
|
}
|
|
3518
|
+
else if (id === "maxSubagentDepth") {
|
|
3519
|
+
const n = parseInt(value, 10);
|
|
3520
|
+
if (n >= 0) {
|
|
3521
|
+
setMaxSubagentDepth(n);
|
|
3522
|
+
notifyApplied(ctx, n <= 1
|
|
3523
|
+
? "Nested delegation disabled"
|
|
3524
|
+
: `Nested depth set to ${n}. Applies to agents started from now on.`);
|
|
3525
|
+
}
|
|
3526
|
+
}
|
|
2224
3527
|
else if (id === "joinMode") {
|
|
2225
3528
|
setDefaultJoinMode(value);
|
|
2226
3529
|
notifyApplied(ctx, `Default join mode set to ${value}`);
|
|
2227
3530
|
}
|
|
3531
|
+
else if (id === "backgroundByDefault") {
|
|
3532
|
+
const enabled = value === "on";
|
|
3533
|
+
setBackgroundByDefault(enabled);
|
|
3534
|
+
notifyApplied(ctx, enabled
|
|
3535
|
+
? "Agent calls run in the background unless they pass run_in_background: false"
|
|
3536
|
+
: "Agent calls block and return inline unless they pass run_in_background: true");
|
|
3537
|
+
}
|
|
2228
3538
|
else if (id === "schedulingEnabled") {
|
|
2229
3539
|
const enabled = value === "on";
|
|
2230
3540
|
if (enabled === isSchedulingEnabled()) {
|
|
@@ -2237,25 +3547,96 @@ ${systemPrompt}
|
|
|
2237
3547
|
notifyApplied(ctx, `Scheduling ${enabled ? "enabled" : "disabled"}. Tool spec change takes effect on next pi session.`);
|
|
2238
3548
|
}
|
|
2239
3549
|
}
|
|
3550
|
+
else if (id === "workflowsEnabled") {
|
|
3551
|
+
const enabled = value === "on";
|
|
3552
|
+
if (enabled === isWorkflowsEnabled()) {
|
|
3553
|
+
ctx.ui.notify(`Workflows already ${enabled ? "enabled" : "disabled"}.`, "info");
|
|
3554
|
+
}
|
|
3555
|
+
else {
|
|
3556
|
+
setWorkflowsEnabled(enabled);
|
|
3557
|
+
// Runs already in flight keep going: the switch governs whether the
|
|
3558
|
+
// tool is offered, and killing live agents on a settings toggle would
|
|
3559
|
+
// lose work the user never asked to discard.
|
|
3560
|
+
notifyApplied(ctx, `Workflows ${enabled ? "enabled" : "disabled"}. Tool spec change takes effect on next pi session.`);
|
|
3561
|
+
}
|
|
3562
|
+
}
|
|
2240
3563
|
else if (id === "scopeModels") {
|
|
2241
3564
|
const enabled = value === "on";
|
|
2242
3565
|
setScopeModelsEnabled(enabled);
|
|
2243
3566
|
notifyApplied(ctx, `Scope models ${enabled ? "enabled" : "disabled"}`);
|
|
2244
3567
|
}
|
|
3568
|
+
else if (id === "strictAgentFiles") {
|
|
3569
|
+
const enabled = value === "on";
|
|
3570
|
+
strictAgentFiles = enabled;
|
|
3571
|
+
notifyApplied(ctx, `Strict agent files ${enabled ? "enabled" : "disabled"}. Takes effect on next pi session.`);
|
|
3572
|
+
}
|
|
2245
3573
|
else if (id === "disableDefaultAgents") {
|
|
2246
3574
|
const enabled = value === "on";
|
|
2247
3575
|
setDisableDefaultAgents(enabled);
|
|
2248
3576
|
notifyApplied(ctx, `Default agents ${enabled ? "disabled" : "enabled"}. Tool spec change takes effect on next pi session.`);
|
|
2249
3577
|
}
|
|
3578
|
+
else if (id === "fallbackSubagent") {
|
|
3579
|
+
setFallbackSubagent(value);
|
|
3580
|
+
notifyApplied(ctx, value === NO_FALLBACK
|
|
3581
|
+
? "Unknown or disabled agent types will now be rejected"
|
|
3582
|
+
: `Unknown agent types will fall back to ${value}`);
|
|
3583
|
+
}
|
|
2250
3584
|
else if (id === "outputTranscript") {
|
|
2251
3585
|
const enabled = value === "on";
|
|
2252
|
-
|
|
3586
|
+
setOutputTranscriptDefault(enabled);
|
|
2253
3587
|
notifyApplied(ctx, `Output transcript ${enabled ? "enabled" : "disabled"} by default`);
|
|
2254
3588
|
}
|
|
3589
|
+
else if (id === "worktreeIsolation") {
|
|
3590
|
+
const enabled = value === "on";
|
|
3591
|
+
setWorktreeIsolationEnabled(enabled);
|
|
3592
|
+
// The refusal is live, but the tool schema is built at registration, so
|
|
3593
|
+
// the isolation parameter only appears/disappears next session.
|
|
3594
|
+
notifyApplied(ctx, `Worktree isolation ${enabled ? "enabled" : "disabled"}. Tool parameter updates on next pi session.`);
|
|
3595
|
+
}
|
|
2255
3596
|
else if (id === "toolDescriptionMode") {
|
|
2256
3597
|
setToolDescriptionMode(value);
|
|
2257
3598
|
notifyApplied(ctx, `Tool description set to ${value}. Takes effect on next pi session.`);
|
|
2258
3599
|
}
|
|
3600
|
+
else if (id === "reportUsage") {
|
|
3601
|
+
const enabled = value === "on";
|
|
3602
|
+
setReportUsage(enabled);
|
|
3603
|
+
notifyApplied(ctx, enabled
|
|
3604
|
+
? "Subagent usage now counted in this session's totals"
|
|
3605
|
+
: "Subagent usage no longer counted in this session's totals");
|
|
3606
|
+
}
|
|
3607
|
+
else if (id === "showCost") {
|
|
3608
|
+
const enabled = value === "on";
|
|
3609
|
+
setShowCost(enabled);
|
|
3610
|
+
notifyApplied(ctx, `Cost display ${enabled ? "enabled" : "disabled"}`);
|
|
3611
|
+
}
|
|
3612
|
+
else if (id === "showModel") {
|
|
3613
|
+
const enabled = value === "on";
|
|
3614
|
+
setShowModel(enabled);
|
|
3615
|
+
notifyApplied(ctx, `Model display ${enabled ? "enabled" : "disabled"}`);
|
|
3616
|
+
}
|
|
3617
|
+
else if (id === "viewerMarkdown") {
|
|
3618
|
+
setViewerMarkdown(value);
|
|
3619
|
+
notifyApplied(ctx, `Viewer markdown set to ${value}`);
|
|
3620
|
+
}
|
|
3621
|
+
else if (id === "fleetView") {
|
|
3622
|
+
const enabled = value === "on";
|
|
3623
|
+
setFleetViewEnabled(enabled);
|
|
3624
|
+
notifyApplied(ctx, `Fleet view ${enabled ? "enabled" : "disabled"}`);
|
|
3625
|
+
}
|
|
3626
|
+
else if (id === "agentMentions") {
|
|
3627
|
+
const mode = value;
|
|
3628
|
+
setAgentMentionMode(mode);
|
|
3629
|
+
notifyApplied(ctx, mode === "off"
|
|
3630
|
+
? "Agent mentions disabled"
|
|
3631
|
+
: mode === "model"
|
|
3632
|
+
? "Agent mentions on — a conversation clone starts a mentioned agent off-screen"
|
|
3633
|
+
: "Agent mentions on — a mentioned agent starts here, with no model call");
|
|
3634
|
+
}
|
|
3635
|
+
else if (id === "rememberAgents") {
|
|
3636
|
+
const enabled = value === "on";
|
|
3637
|
+
setRememberAgents(enabled);
|
|
3638
|
+
notifyApplied(ctx, `Remember agents ${enabled ? "enabled" : "disabled"}`);
|
|
3639
|
+
}
|
|
2259
3640
|
else if (id === "widgetMode") {
|
|
2260
3641
|
setWidgetMode(value);
|
|
2261
3642
|
notifyApplied(ctx, `Widget set to ${value}`);
|
|
@@ -2298,14 +3679,22 @@ ${systemPrompt}
|
|
|
2298
3679
|
if (result && NUMERIC_IDS.has(result)) {
|
|
2299
3680
|
const current = result === "maxConcurrent"
|
|
2300
3681
|
? String(manager.getMaxConcurrent())
|
|
2301
|
-
: result === "
|
|
2302
|
-
? String(
|
|
2303
|
-
:
|
|
3682
|
+
: result === "maxConcurrentForeground"
|
|
3683
|
+
? String(manager.getMaxConcurrentForeground())
|
|
3684
|
+
: result === "defaultMaxTurns"
|
|
3685
|
+
? String(getDefaultMaxTurns() ?? 0)
|
|
3686
|
+
: result === "maxSubagentDepth"
|
|
3687
|
+
? String(getMaxSubagentDepth())
|
|
3688
|
+
: String(getGraceTurns());
|
|
2304
3689
|
const label = result === "maxConcurrent"
|
|
2305
3690
|
? "Max concurrency (1+)"
|
|
2306
|
-
: result === "
|
|
2307
|
-
? "
|
|
2308
|
-
:
|
|
3691
|
+
: result === "maxConcurrentForeground"
|
|
3692
|
+
? "Max foreground concurrency (0 = unlimited)"
|
|
3693
|
+
: result === "defaultMaxTurns"
|
|
3694
|
+
? "Default max turns (0 = unlimited)"
|
|
3695
|
+
: result === "maxSubagentDepth"
|
|
3696
|
+
? "Nested depth (0/1 = nesting off)"
|
|
3697
|
+
: "Grace turns (1+)";
|
|
2309
3698
|
// Loop until user enters a valid integer or cancels (Esc / null).
|
|
2310
3699
|
// Silently trims whitespace; rejects non-numeric input by re-prompting.
|
|
2311
3700
|
let input = await ctx.ui.input(label, current);
|
|
@@ -2326,6 +3715,23 @@ ${systemPrompt}
|
|
|
2326
3715
|
// the right toast. Successful saves show info; persistence failures downgrade
|
|
2327
3716
|
// to warning so users aren't silently reverted on restart. Event fires regardless
|
|
2328
3717
|
// of outcome so listeners see the in-memory change.
|
|
3718
|
+
/**
|
|
3719
|
+
* Persist + broadcast the settings, silent on success — for a change whose
|
|
3720
|
+
* feedback is the UI it just changed: the viewer's `m` key, where a
|
|
3721
|
+
* notification per press would talk over the overlay it is describing.
|
|
3722
|
+
*
|
|
3723
|
+
* A *failed* write still speaks. Every other settings path warns when the
|
|
3724
|
+
* value is session-only, and swallowing it here would leave a preference
|
|
3725
|
+
* looking persisted when the next session will not have it.
|
|
3726
|
+
*/
|
|
3727
|
+
function persistSettings(ctx, changeMsg) {
|
|
3728
|
+
const { message, level } = saveAndEmitChanged(snapshotSettings(), changeMsg, (event, payload) => pi.events.emit(event, payload));
|
|
3729
|
+
// `ctx` is absent only on the fleet path between sessions, where
|
|
3730
|
+
// `currentCtx` has been cleared and there is no UI to carry the warning to.
|
|
3731
|
+
// The write still happens.
|
|
3732
|
+
if (level === "warning")
|
|
3733
|
+
ctx?.ui.notify(message, level);
|
|
3734
|
+
}
|
|
2329
3735
|
function notifyApplied(ctx, successMsg) {
|
|
2330
3736
|
const { message, level } = saveAndEmitChanged(snapshotSettings(), successMsg, (event, payload) => pi.events.emit(event, payload));
|
|
2331
3737
|
ctx.ui.notify(message, level);
|
|
@@ -2334,5 +3740,19 @@ ${systemPrompt}
|
|
|
2334
3740
|
description: "Manage agents",
|
|
2335
3741
|
handler: async (_args, ctx) => { await showAgentsMenu(ctx); },
|
|
2336
3742
|
});
|
|
3743
|
+
/**
|
|
3744
|
+
* What `/agents → Workflows` and the fleet list's `workflow` rows need from
|
|
3745
|
+
* here. One object, built once: both entry points open the same inspector,
|
|
3746
|
+
* and handing them different views of the session would let the two drift.
|
|
3747
|
+
*/
|
|
3748
|
+
const workflowMenuDeps = {
|
|
3749
|
+
tasks: workflowTasks,
|
|
3750
|
+
getRecord: id => manager.getRecord(id),
|
|
3751
|
+
viewAgentConversation,
|
|
3752
|
+
// Read lazily: `currentCtx` is rebound on every session_start, and the
|
|
3753
|
+
// fleet list may act between sessions, when there is none.
|
|
3754
|
+
getCtx: () => currentCtx,
|
|
3755
|
+
};
|
|
3756
|
+
fleet.setWorkflowSource(fleetWorkflows, id => openWorkflowFromFleet(id, workflowMenuDeps));
|
|
2337
3757
|
}
|
|
2338
3758
|
//# sourceMappingURL=index.js.map
|