@cad0p/pi-subagents-tintinweb 0.14.2 → 0.22.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +20 -1
- package/CONTRIBUTING.md +8 -0
- package/README.md +98 -58
- package/docs/fork-differences.md +51 -0
- package/docs/verification-checklist.md +132 -0
- package/examples/agent-tool-description.md +3 -9
- package/package.json +13 -11
- package/pnpm-workspace.yaml +9 -0
- package/src/abortable.ts +43 -0
- package/src/agent-manager.ts +404 -140
- package/src/agent-runner.ts +462 -89
- package/src/agent-types.ts +269 -23
- package/src/checkpoint.ts +49 -0
- package/src/child-context.ts +30 -0
- package/src/cross-extension-rpc.ts +65 -5
- package/src/custom-agents.ts +161 -14
- package/src/default-agents.ts +1 -1
- package/src/index.ts +1014 -980
- package/src/invocation-config.ts +81 -12
- package/src/model-resolver.ts +18 -0
- package/src/model-scope.ts +62 -0
- package/src/nested-tools.ts +406 -0
- package/src/output-file.ts +18 -1
- package/src/prompts.ts +71 -0
- package/src/result-report.ts +170 -0
- package/src/schedule-store.ts +300 -13
- package/src/schedule.ts +222 -77
- package/src/settings.ts +206 -32
- package/src/status-note.ts +18 -1
- package/src/text-safety.ts +69 -0
- package/src/types.ts +101 -58
- package/src/ui/agent-widget.ts +104 -36
- package/src/ui/conversation-viewer.ts +36 -18
- package/src/ui/fleet-list.ts +19 -8
- package/src/ui/schedule-menu.ts +63 -19
- package/src/usage.ts +128 -8
- package/src/worktree.ts +19 -0
- package/src/group-join.ts +0 -141
package/src/index.ts
CHANGED
|
@@ -10,46 +10,50 @@
|
|
|
10
10
|
* /agents — Interactive agent management menu
|
|
11
11
|
*/
|
|
12
12
|
|
|
13
|
-
import {
|
|
13
|
+
import { existsSync, mkdirSync, readFileSync, unlinkSync } from "node:fs";
|
|
14
14
|
import { join } from "node:path";
|
|
15
|
-
import { defineTool, type ExtensionAPI, type ExtensionCommandContext, type ExtensionContext, getAgentDir, getMarkdownTheme, getSettingsListTheme } from "@earendil-works/pi-coding-agent";
|
|
16
|
-
import { Container, Key, Markdown, matchesKey, type SettingItem, SettingsList, Spacer, Text } from "@earendil-works/pi-tui";
|
|
15
|
+
import { defineTool, type ExtensionAPI, type ExtensionCommandContext, type ExtensionContext, getAgentDir, getMarkdownTheme, getSettingsListTheme, keyText, type ToolDefinition } from "@earendil-works/pi-coding-agent";
|
|
16
|
+
import { Container, Key, Markdown, matchesKey, type SettingItem, SettingsList, Spacer, Text, truncateToWidth } from "@earendil-works/pi-tui";
|
|
17
17
|
import { Type } from "@sinclair/typebox";
|
|
18
|
-
import { AgentManager } from "./agent-manager.js";
|
|
18
|
+
import { AgentManager, type SpawnOptions } from "./agent-manager.js";
|
|
19
19
|
import { getDefaultMaxTurns, getGraceTurns, normalizeMaxTurns, SUBAGENT_TOOL_NAMES, setDefaultMaxTurns, setGraceTurns, steerAgent } from "./agent-runner.js";
|
|
20
|
-
import { BUILTIN_TOOL_NAMES, getAgentConfig, getAllTypes, getAvailableTypes, isDefaultsDisabled, registerAgents, resolveType, setDefaultsDisabled } from "./agent-types.js";
|
|
20
|
+
import { BUILTIN_TOOL_NAMES, getAgentConfig, getAllTypes, getAvailableTypes, getFallbackSubagent, IMPLICIT_FALLBACK, isDefaultsDisabled, isFallbackResolvable, NO_FALLBACK, registerAgents, resolveSpawnType, resolveType, setDefaultsDisabled, setFallbackSubagent } from "./agent-types.js";
|
|
21
|
+
import { saveCheckpoint } from "./checkpoint.js";
|
|
22
|
+
import { inChildSessionContext } from "./child-context.js";
|
|
21
23
|
import { type RpcHandle, registerRpcHandlers } from "./cross-extension-rpc.js";
|
|
22
24
|
import { loadCustomAgents } from "./custom-agents.js";
|
|
23
|
-
import {
|
|
24
|
-
import {
|
|
25
|
-
import {
|
|
26
|
-
import {
|
|
27
|
-
import { createOutputFilePath, streamToOutputFile, writeInitialEntry } from "./output-file.js";
|
|
25
|
+
import { isolationParam, resolveAgentInvocationConfig } from "./invocation-config.js";
|
|
26
|
+
import { describeModel, type ModelRegistry, resolveModel } from "./model-resolver.js";
|
|
27
|
+
import { isScopeModelsEnabled, modelScopeWarning, setScopeModelsEnabled } from "./model-scope.js";
|
|
28
|
+
import { getMaxSubagentDepth, setMaxSubagentDepth } from "./nested-tools.js";
|
|
29
|
+
import { createOutputFilePath, getOutputTranscriptDefault, setOutputTranscriptDefault, streamToOutputFile, writeInitialEntry } from "./output-file.js";
|
|
30
|
+
import { formatLifetimeTokens, renderAgentReport, setReportShowCost } from "./result-report.js";
|
|
28
31
|
import { SubagentScheduler } from "./schedule.js";
|
|
29
32
|
import { resolveStorePath, ScheduleStore } from "./schedule-store.js";
|
|
30
|
-
import { applyAndEmitLoaded,
|
|
33
|
+
import { applyAndEmitLoaded, DEFAULT_SHOW_COST, FAILURE_PREVIEW_MAX_CHARS_CEILING, fallbackRow, loadSettings, type SubagentsSettings, saveAndEmitChanged, type ToolDescriptionMode } from "./settings.js";
|
|
31
34
|
import { getStatusNote } from "./status-note.js";
|
|
32
|
-
import {
|
|
35
|
+
import { safeTruncate, stripControlChars, toSingleLine } from "./text-safety.js";
|
|
36
|
+
import { type AgentConfig, type AgentInvocation, type AgentRecord, type SubagentType, type WidgetMode } from "./types.js";
|
|
33
37
|
import {
|
|
34
38
|
type AgentActivity,
|
|
35
39
|
type AgentDetails,
|
|
36
40
|
AgentWidget,
|
|
37
41
|
buildInvocationTags,
|
|
38
|
-
describeActivity,
|
|
39
42
|
fgPreservingNestedStyles,
|
|
43
|
+
formatCost,
|
|
40
44
|
formatDuration,
|
|
41
45
|
formatMs,
|
|
42
46
|
formatTokens,
|
|
43
47
|
formatTurns,
|
|
44
48
|
getDisplayName,
|
|
45
49
|
getPromptModeLabel,
|
|
46
|
-
SPINNER,
|
|
47
50
|
type Theme,
|
|
48
51
|
type UICtx,
|
|
49
52
|
} from "./ui/agent-widget.js";
|
|
50
53
|
import { FleetList, type FleetUICtx } from "./ui/fleet-list.js";
|
|
51
54
|
import { showSchedulesMenu } from "./ui/schedule-menu.js";
|
|
52
|
-
import { addUsage, getLifetimeTotal, getSessionContextPercent, type LifetimeUsage } from "./usage.js";
|
|
55
|
+
import { addUsage, formatSessionContext, getLifetimeCost, getLifetimeTotal, getSessionContextPercent, type LifetimeUsage, PendingUsagePool, toReportedUsage } from "./usage.js";
|
|
56
|
+
import { isWorktreeIsolationEnabled, setWorktreeIsolationEnabled } from "./worktree.js";
|
|
53
57
|
|
|
54
58
|
// ---- Shared helpers ----
|
|
55
59
|
|
|
@@ -58,38 +62,11 @@ function textResult<D>(msg: string, details?: D) {
|
|
|
58
62
|
return { content: [{ type: "text" as const, text: msg }], details };
|
|
59
63
|
}
|
|
60
64
|
|
|
61
|
-
/** Derive the `.checkpoints.md` path from `outputFile`. Mirrors the
|
|
62
|
-
* `record.outputFile` gate (undefined when suppressed). Appends the suffix
|
|
63
|
-
* unconditionally — a `replace(/\.output$/, ...)` would silently return the
|
|
64
|
-
* transcript path unchanged if `outputFile` ever lacked the suffix. */
|
|
65
|
-
function checkpointsFilePath(outputFile: string | undefined): string | undefined {
|
|
66
|
-
if (!outputFile) return undefined;
|
|
67
|
-
return `${outputFile}.checkpoints.md`;
|
|
68
|
-
}
|
|
69
|
-
|
|
70
|
-
export function renderRunningAgentStatus(
|
|
71
|
-
frame: string,
|
|
72
|
-
statsText: string,
|
|
73
|
-
activity: string,
|
|
74
|
-
theme: Pick<Theme, "fg">,
|
|
75
|
-
): Container {
|
|
76
|
-
const container = new Container();
|
|
77
|
-
container.addChild(new Text(theme.fg("accent", frame) + (statsText ? " " + statsText : ""), 0, 0));
|
|
78
|
-
container.addChild(new Text(theme.fg("dim", ` ⎿ ${activity}`), 0, 0));
|
|
79
|
-
return container;
|
|
80
|
-
}
|
|
81
|
-
|
|
82
|
-
/** Format an agent's lifetime token total, or "" when zero. */
|
|
83
|
-
function formatLifetimeTokens(o: { lifetimeUsage: LifetimeUsage }): string {
|
|
84
|
-
const t = getLifetimeTotal(o.lifetimeUsage);
|
|
85
|
-
return t > 0 ? formatTokens(t) : "";
|
|
86
|
-
}
|
|
87
|
-
|
|
88
65
|
/**
|
|
89
66
|
* Create an AgentActivity state and spawn callbacks for tracking tool usage.
|
|
90
|
-
* Used by
|
|
67
|
+
* Used by the background spawn and resume paths to avoid duplication.
|
|
91
68
|
*/
|
|
92
|
-
function createActivityTracker(maxTurns?: number
|
|
69
|
+
function createActivityTracker(maxTurns?: number) {
|
|
93
70
|
const state: AgentActivity = {
|
|
94
71
|
activeTools: new Map(),
|
|
95
72
|
toolUses: 0,
|
|
@@ -110,22 +87,18 @@ function createActivityTracker(maxTurns?: number, onStreamUpdate?: () => void) {
|
|
|
110
87
|
}
|
|
111
88
|
state.toolUses++;
|
|
112
89
|
}
|
|
113
|
-
onStreamUpdate?.();
|
|
114
90
|
},
|
|
115
91
|
onTextDelta: (_delta: string, fullText: string) => {
|
|
116
92
|
state.responseText = fullText;
|
|
117
|
-
onStreamUpdate?.();
|
|
118
93
|
},
|
|
119
94
|
onTurnEnd: (turnCount: number) => {
|
|
120
95
|
state.turnCount = turnCount;
|
|
121
|
-
onStreamUpdate?.();
|
|
122
96
|
},
|
|
123
97
|
onSessionCreated: (session: any) => {
|
|
124
98
|
state.session = session;
|
|
125
99
|
},
|
|
126
|
-
onAssistantUsage: (usage:
|
|
100
|
+
onAssistantUsage: (usage: LifetimeUsage) => {
|
|
127
101
|
addUsage(state.lifetimeUsage, usage);
|
|
128
|
-
onStreamUpdate?.();
|
|
129
102
|
},
|
|
130
103
|
};
|
|
131
104
|
|
|
@@ -134,244 +107,248 @@ function createActivityTracker(maxTurns?: number, onStreamUpdate?: () => void) {
|
|
|
134
107
|
|
|
135
108
|
/**
|
|
136
109
|
* Advertised thinking levels, ordered to mirror pi-ai's EXTENDED_THINKING_LEVELS
|
|
137
|
-
* (`off` + every `ThinkingLevel`). Single source for the
|
|
138
|
-
*
|
|
139
|
-
*
|
|
140
|
-
*
|
|
110
|
+
* (`off` + every `ThinkingLevel`). Single source for the generated-agent template
|
|
111
|
+
* and the `/agents` wizard so these lists can't drift behind pi again (#147).
|
|
112
|
+
* Availability of any level still depends on the host pi version and the
|
|
113
|
+
* selected model — pi clamps unsupported levels down.
|
|
141
114
|
*/
|
|
142
115
|
const THINKING_LEVELS = ["off", "minimal", "low", "medium", "high", "xhigh", "max"] as const;
|
|
143
116
|
|
|
144
|
-
/**
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
117
|
+
/** Human-readable status word for the completion report header. */
|
|
118
|
+
function getStatusWord(status: string): string {
|
|
119
|
+
switch (status) {
|
|
120
|
+
case "completed": return "completed";
|
|
121
|
+
case "error": return "error";
|
|
122
|
+
case "stopped": return "stopped";
|
|
123
|
+
case "aborted": return "aborted";
|
|
124
|
+
case "steered": return "wrapped up (turn limit)";
|
|
125
|
+
default: return sanitizeHeaderText(String(status)) || "unknown";
|
|
126
|
+
}
|
|
152
127
|
}
|
|
153
128
|
|
|
154
|
-
/**
|
|
155
|
-
function
|
|
129
|
+
/** Completion-report glyph: success only for terminal success statuses. */
|
|
130
|
+
function getStatusGlyph(status: string): string {
|
|
156
131
|
switch (status) {
|
|
157
|
-
case "
|
|
158
|
-
case "
|
|
159
|
-
case "
|
|
160
|
-
case "stopped":
|
|
161
|
-
|
|
132
|
+
case "completed":
|
|
133
|
+
case "steered": return "✓";
|
|
134
|
+
case "error":
|
|
135
|
+
case "stopped":
|
|
136
|
+
case "aborted": return "✗";
|
|
137
|
+
default: return "○";
|
|
162
138
|
}
|
|
163
139
|
}
|
|
164
140
|
|
|
165
|
-
/**
|
|
166
|
-
|
|
167
|
-
|
|
141
|
+
/**
|
|
142
|
+
* Collapse newlines/CRs/tabs and strip control/invisible characters from text
|
|
143
|
+
* that is composed into the report header or metadata lines.
|
|
144
|
+
*/
|
|
145
|
+
function sanitizeHeaderText(s: string): string {
|
|
146
|
+
return toSingleLine(s);
|
|
168
147
|
}
|
|
169
148
|
|
|
170
|
-
// Collapsed preview line limit - matches pi's read/write tool precedent
|
|
171
|
-
const COLLAPSED_PREVIEW_LINES = 10;
|
|
172
|
-
const PLAIN_MODE_EXPANDED_LINE_CAP = 30; // Preserves upstream renderer's expanded-mode line cap. Plain mode is the backward-compatibility path; markdown mode is uncapped per spec.
|
|
173
|
-
const PLAIN_MODE_COLLAPSED_CHAR_CAP = 80; // Preserves upstream renderer's first-line preview char cap. Plain mode is the backward-compatibility path.
|
|
174
149
|
const DEFAULT_FAILURE_PREVIEW_MAX_CHARS = 65536; // 64 KiB at ASCII.
|
|
150
|
+
const HEADER_PREVIEW_MAX_CHARS = 300; // ~one wrapped line; description/error are untrusted input.
|
|
175
151
|
|
|
176
|
-
/**
|
|
177
|
-
function
|
|
178
|
-
|
|
179
|
-
const high = s.charCodeAt(maxChars - 1);
|
|
180
|
-
return high >= 0xD800 && high <= 0xDBFF ? s.slice(0, maxChars - 1) : s.slice(0, maxChars);
|
|
152
|
+
/** Bound a sanitized header field to the shared preview cap, marking truncation. */
|
|
153
|
+
function headerPreview(s: string): string {
|
|
154
|
+
return s.length > HEADER_PREVIEW_MAX_CHARS ? `${safeTruncate(s, HEADER_PREVIEW_MAX_CHARS)}…` : s;
|
|
181
155
|
}
|
|
182
156
|
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
: body;
|
|
157
|
+
const INSTRUCTION_PREVIEW_SCAN_CHARS = 512; // per-render sanitizer window for the steer preview
|
|
158
|
+
const INSTRUCTION_PREVIEW_COLS = 80; // column budget for the steer preview
|
|
159
|
+
|
|
160
|
+
/** Expanded body copy of a parent→child instruction — full text, no caps. */
|
|
161
|
+
function instructionBody(raw: string, theme: Pick<Theme, "fg">): string {
|
|
162
|
+
const text = stripControlChars(raw).trimEnd();
|
|
163
|
+
if (!text.trim()) return ""; // whitespace-only → no stray blank line
|
|
164
|
+
return text
|
|
165
|
+
.split("\n")
|
|
166
|
+
.map((l) => theme.fg("dim", ` ${l}`))
|
|
167
|
+
.join("\n");
|
|
195
168
|
}
|
|
196
169
|
|
|
197
|
-
/**
|
|
198
|
-
export function
|
|
199
|
-
|
|
200
|
-
const durationMs = record.completedAt ? record.completedAt - record.startedAt : 0;
|
|
201
|
-
const totalTokens = getLifetimeTotal(record.lifetimeUsage);
|
|
202
|
-
const contextPercent = getSessionContextPercent(record.session);
|
|
203
|
-
const ctxXml = contextPercent !== null ? `<context_percent>${Math.round(contextPercent)}</context_percent>` : "";
|
|
204
|
-
const compactXml = record.compactionCount ? `<compactions>${record.compactionCount}</compactions>` : "";
|
|
205
|
-
|
|
206
|
-
const resultPreview = buildResultPreview(record, settings);
|
|
207
|
-
|
|
208
|
-
return [
|
|
209
|
-
`<task-notification>`,
|
|
210
|
-
`<task-id>${record.id}</task-id>`,
|
|
211
|
-
record.toolCallId ? `<tool-use-id>${escapeXml(record.toolCallId)}</tool-use-id>` : null,
|
|
212
|
-
record.outputFile ? `<output-file>${escapeXml(record.outputFile)}</output-file>` : null,
|
|
213
|
-
`<status>${escapeXml(status)}</status>`,
|
|
214
|
-
`<summary>Agent "${escapeXml(record.description)}" ${record.status}${getStatusNote(record.status)}</summary>`,
|
|
215
|
-
`<result>${escapeXml(resultPreview)}</result>`,
|
|
216
|
-
`<usage><total_tokens>${totalTokens}</total_tokens><tool_uses>${record.toolUses}</tool_uses>${ctxXml}${compactXml}<duration_ms>${durationMs}</duration_ms></usage>`,
|
|
217
|
-
`</task-notification>`,
|
|
218
|
-
].filter(Boolean).join('\n');
|
|
170
|
+
/** Pure hint formatter — no global keybinding dependency, exported for tests. */
|
|
171
|
+
export function formatInstructionHint(key: string, theme: Pick<Theme, "fg">): string {
|
|
172
|
+
return key ? theme.fg("dim", ` (${key} to expand)`) : "";
|
|
219
173
|
}
|
|
220
174
|
|
|
221
|
-
/**
|
|
222
|
-
function
|
|
223
|
-
|
|
224
|
-
record: { toolUses: number; startedAt: number; completedAt?: number; status: string; error?: string; id?: string; session?: any; lifetimeUsage: LifetimeUsage },
|
|
225
|
-
activity?: AgentActivity,
|
|
226
|
-
overrides?: Partial<AgentDetails>,
|
|
227
|
-
): AgentDetails {
|
|
228
|
-
return {
|
|
229
|
-
...base,
|
|
230
|
-
toolUses: record.toolUses,
|
|
231
|
-
tokens: formatLifetimeTokens(record),
|
|
232
|
-
turnCount: activity?.turnCount,
|
|
233
|
-
maxTurns: activity?.maxTurns,
|
|
234
|
-
durationMs: (record.completedAt ?? Date.now()) - record.startedAt,
|
|
235
|
-
status: record.status as AgentDetails["status"],
|
|
236
|
-
agentId: record.id,
|
|
237
|
-
error: record.error,
|
|
238
|
-
...overrides,
|
|
239
|
-
};
|
|
175
|
+
/** ` (ctrl+o to expand)` for the user's current binding — omitted when unbound/headless. */
|
|
176
|
+
function instructionHint(theme: Pick<Theme, "fg">): string {
|
|
177
|
+
return formatInstructionHint(keyText("app.tools.expand"), theme);
|
|
240
178
|
}
|
|
241
179
|
|
|
242
|
-
/**
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
const resultPreview = buildResultPreview(record, settings);
|
|
247
|
-
|
|
248
|
-
return {
|
|
249
|
-
id: record.id,
|
|
250
|
-
description: record.description,
|
|
251
|
-
status: record.status,
|
|
252
|
-
toolUses: record.toolUses,
|
|
253
|
-
turnCount: activity?.turnCount ?? 0,
|
|
254
|
-
maxTurns: activity?.maxTurns,
|
|
255
|
-
totalTokens,
|
|
256
|
-
durationMs: record.completedAt ? record.completedAt - record.startedAt : 0,
|
|
257
|
-
outputFile: record.outputFile,
|
|
258
|
-
error: record.error,
|
|
259
|
-
resultPreview,
|
|
260
|
-
};
|
|
180
|
+
/** The settings contract for the failure preview cap: a positive integer within the settings ceiling. */
|
|
181
|
+
function isValidFailurePreviewCap(value: number): boolean {
|
|
182
|
+
return Number.isInteger(value) && value >= 1 && value <= FAILURE_PREVIEW_MAX_CHARS_CEILING;
|
|
261
183
|
}
|
|
262
184
|
|
|
263
|
-
/**
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
const parts: string[] = [];
|
|
276
|
-
if (d.turnCount > 0) parts.push(formatTurns(d.turnCount, d.maxTurns));
|
|
277
|
-
if (d.toolUses > 0) parts.push(`${d.toolUses} tool use${d.toolUses === 1 ? "" : "s"}`);
|
|
278
|
-
if (d.totalTokens > 0) parts.push(formatTokens(d.totalTokens));
|
|
279
|
-
if (d.durationMs > 0) parts.push(formatMs(d.durationMs));
|
|
280
|
-
if (parts.length) {
|
|
281
|
-
line += "\n " + parts.map(p => theme.fg("dim", p)).join(" " + theme.fg("dim", "·") + " ");
|
|
185
|
+
/**
|
|
186
|
+
* @internal Coerce the in-memory failure preview cap for the send path:
|
|
187
|
+
* an out-of-contract value (a settings regression) falls back to the default
|
|
188
|
+
* instead of throwing away the completion. Warns once per invalid episode —
|
|
189
|
+
* a valid value re-arms the latch so a later distinct regression warns again,
|
|
190
|
+
* while a still-broken value does not log on every completion.
|
|
191
|
+
*/
|
|
192
|
+
let warnedInvalidFailurePreviewCap = false;
|
|
193
|
+
export function effectiveFailurePreviewCap(value: number): number {
|
|
194
|
+
if (isValidFailurePreviewCap(value)) {
|
|
195
|
+
warnedInvalidFailurePreviewCap = false;
|
|
196
|
+
return value;
|
|
282
197
|
}
|
|
283
|
-
|
|
284
|
-
|
|
198
|
+
if (!warnedInvalidFailurePreviewCap) {
|
|
199
|
+
warnedInvalidFailurePreviewCap = true;
|
|
200
|
+
console.warn(
|
|
201
|
+
`[pi-subagents] ignoring out-of-contract failurePreviewMaxChars (${String(value)}); using the default ${DEFAULT_FAILURE_PREVIEW_MAX_CHARS} for this notification`,
|
|
202
|
+
);
|
|
203
|
+
}
|
|
204
|
+
return DEFAULT_FAILURE_PREVIEW_MAX_CHARS;
|
|
285
205
|
}
|
|
286
206
|
|
|
287
|
-
/**
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
let body = d.resultPreview;
|
|
291
|
-
if (!expanded) {
|
|
292
|
-
const lines = body.split("\n");
|
|
293
|
-
if (lines.length > COLLAPSED_PREVIEW_LINES) {
|
|
294
|
-
const remaining = lines.length - COLLAPSED_PREVIEW_LINES;
|
|
295
|
-
body = lines.slice(0, COLLAPSED_PREVIEW_LINES).join("\n") + `\n… (${remaining} more lines, ctrl+O to expand)`;
|
|
296
|
-
}
|
|
297
|
-
}
|
|
298
|
-
|
|
299
|
-
const container = new Container();
|
|
300
|
-
if (body.trim()) {
|
|
301
|
-
container.addChild(new Markdown(body, 2, 0, getMarkdownTheme()));
|
|
302
|
-
}
|
|
303
|
-
if (d.outputFile) {
|
|
304
|
-
container.addChild(new Text(theme.fg("muted", ` transcript: ${d.outputFile}`), 0, 0));
|
|
305
|
-
}
|
|
306
|
-
return container;
|
|
307
|
-
} else {
|
|
308
|
-
let bodyText = "";
|
|
309
|
-
if (expanded) {
|
|
310
|
-
const lines = d.resultPreview.split("\n").slice(0, PLAIN_MODE_EXPANDED_LINE_CAP);
|
|
311
|
-
for (const l of lines) bodyText += (bodyText ? "\n" : "") + theme.fg("dim", ` ${l}`);
|
|
312
|
-
} else {
|
|
313
|
-
const preview = d.resultPreview.split("\n")[0]?.slice(0, PLAIN_MODE_COLLAPSED_CHAR_CAP) ?? "";
|
|
314
|
-
bodyText = theme.fg("dim", ` ⎿ ${preview}`);
|
|
315
|
-
}
|
|
207
|
+
/** Grace window between an agent settling and its completion notification being
|
|
208
|
+
* sent, so a parent that pulls the result first can still cancel the nudge. */
|
|
209
|
+
export const NUDGE_HOLD_MS = 200;
|
|
316
210
|
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
211
|
+
/** Max turns for the AI-generation wizard run. Exported for the wizard tests,
|
|
212
|
+
* which tell the generator run apart from other spawns by this value. */
|
|
213
|
+
export const WIZARD_MAX_TURNS = 5;
|
|
320
214
|
|
|
321
|
-
|
|
215
|
+
/** Validate and return `failurePreviewMaxChars` — user-set input, untrusted until checked. */
|
|
216
|
+
function failurePreviewCap(settings: SubagentsSettings): number {
|
|
217
|
+
const cap = settings.failurePreviewMaxChars;
|
|
218
|
+
if (typeof cap !== "number" || !isValidFailurePreviewCap(cap)) {
|
|
219
|
+
throw new Error("failurePreviewMaxChars must be a positive integer within the settings ceiling on failure status");
|
|
322
220
|
}
|
|
221
|
+
return cap;
|
|
323
222
|
}
|
|
324
223
|
|
|
325
|
-
/**
|
|
326
|
-
|
|
327
|
-
const
|
|
328
|
-
|
|
224
|
+
/** Build the `Result:` body. Caps failure-mode bodies; success/aborted/steered uncapped. */
|
|
225
|
+
function buildResultPreview(record: AgentRecord, settings: SubagentsSettings): string {
|
|
226
|
+
const isFailure = record.status === "error" || record.status === "stopped";
|
|
227
|
+
// Terminal-safety strip only: markdown structure and XML-ish text pass
|
|
228
|
+
// through untouched, while escape/control bytes (OSC clipboard writes and
|
|
229
|
+
// links, CSI screen clears, C0/C1, invisible format characters) never reach
|
|
230
|
+
// the terminal.
|
|
231
|
+
const body = stripControlChars(String(record.result ?? record.error ?? ""));
|
|
232
|
+
// Validate before the empty-body return: the metadata `Error:` line uses the
|
|
233
|
+
// same cap and must see the same validated value.
|
|
234
|
+
const cap = isFailure ? failurePreviewCap(settings) : Number.POSITIVE_INFINITY;
|
|
235
|
+
if (!body) return "No output.";
|
|
236
|
+
return body.length > cap
|
|
237
|
+
? safeTruncate(body, cap) + "\n…(truncated, see transcript)"
|
|
238
|
+
: body;
|
|
239
|
+
}
|
|
329
240
|
|
|
330
|
-
|
|
241
|
+
/**
|
|
242
|
+
* @internal Format a background completion as a markdown report. The report is
|
|
243
|
+
* both the parent model's context and the text pi renders in its default
|
|
244
|
+
* custom-message box: terminal control sequences are stripped from every field
|
|
245
|
+
* while markdown stays intact, and the body goes last (an unbalanced fence in
|
|
246
|
+
* it cannot swallow the metadata above).
|
|
247
|
+
*
|
|
248
|
+
* `showCost` is the user's setting, passed in rather than read here so the
|
|
249
|
+
* formatter stays a pure function of its arguments. It gates this report only:
|
|
250
|
+
* a figure the orchestrator was not asked to track is one it may start reporting
|
|
251
|
+
* unprompted, so the setting covers LLM context too, not just what a human sees.
|
|
252
|
+
*/
|
|
253
|
+
export function formatTaskNotification(record: AgentRecord, settings: SubagentsSettings, showCost = false): string {
|
|
254
|
+
const durationMs = record.completedAt ? record.completedAt - record.startedAt : 0;
|
|
255
|
+
const totalTokens = getLifetimeTotal(record.lifetimeUsage);
|
|
256
|
+
const context = formatSessionContext(record.session);
|
|
331
257
|
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
container.addChild(header);
|
|
337
|
-
container.addChild(body);
|
|
338
|
-
return container;
|
|
258
|
+
const stats: string[] = [];
|
|
259
|
+
const turnCount = record.turnCount ?? 0;
|
|
260
|
+
if (Number.isFinite(turnCount) && turnCount > 0) {
|
|
261
|
+
stats.push(formatTurns(turnCount, Number.isFinite(record.effectiveMaxTurns) ? record.effectiveMaxTurns : undefined));
|
|
339
262
|
}
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
263
|
+
if (Number.isFinite(record.toolUses) && record.toolUses > 0) stats.push(`${record.toolUses} tool use${record.toolUses === 1 ? "" : "s"}`);
|
|
264
|
+
if (Number.isFinite(totalTokens) && totalTokens > 0) stats.push(formatTokens(totalTokens));
|
|
265
|
+
// Omitted when there is no cost to show, so an unpriced model reports tokens
|
|
266
|
+
// and no dollars rather than a `$0.00` that reads as measured-and-free.
|
|
267
|
+
const cost = showCost ? formatCost(getLifetimeCost(record.lifetimeUsage)) : "";
|
|
268
|
+
if (cost) stats.push(cost);
|
|
269
|
+
if (context !== null) stats.push(`ctx ${context}`);
|
|
270
|
+
if (Number.isFinite(record.compactionCount) && record.compactionCount > 0) stats.push(`⇊${record.compactionCount}`);
|
|
271
|
+
if (Number.isFinite(durationMs) && durationMs > 0) stats.push(formatMs(durationMs));
|
|
272
|
+
|
|
273
|
+
const descriptionText = sanitizeHeaderText(String(record.description ?? ""));
|
|
274
|
+
const description = headerPreview(descriptionText) || "(no description)";
|
|
275
|
+
let header = `**${getStatusGlyph(String(record.status))} Subagent ${getStatusWord(record.status)}: ${description}**`;
|
|
276
|
+
const errorText = record.error && (record.status === "error" || record.status === "stopped")
|
|
277
|
+
? sanitizeHeaderText(String(record.error))
|
|
278
|
+
: "";
|
|
279
|
+
const errorPreview = headerPreview(errorText);
|
|
280
|
+
if (errorPreview) header += ` — ${errorPreview}`;
|
|
281
|
+
header += getStatusNote(record.status);
|
|
282
|
+
if (stats.length > 0) header += ` · ${stats.join(" · ")}`;
|
|
283
|
+
|
|
284
|
+
const metadata = [`Agent: ${sanitizeHeaderText(String(record.id))}`];
|
|
285
|
+
if (record.outputFile) metadata.push(`Transcript: ${sanitizeHeaderText(String(record.outputFile))}`);
|
|
286
|
+
const body = buildResultPreview(record, settings);
|
|
287
|
+
// The header only previews the error. When a partial result replaces the error
|
|
288
|
+
// in the body, carry the full sanitized error here so its tail is not lost.
|
|
289
|
+
if (errorText.length > HEADER_PREVIEW_MAX_CHARS && record.result != null) {
|
|
290
|
+
const cap = failurePreviewCap(settings); // same validated cap as the body
|
|
291
|
+
const fullError = errorText.length > cap ? `${safeTruncate(errorText, cap)}\n…(truncated, see transcript)` : errorText;
|
|
292
|
+
metadata.push(`Error: ${fullError}`);
|
|
351
293
|
}
|
|
294
|
+
|
|
295
|
+
return [header, "", metadata.join("\n"), "", "Result:", "", body].join("\n");
|
|
352
296
|
}
|
|
353
297
|
|
|
354
298
|
export default function (pi: ExtensionAPI) {
|
|
355
|
-
//
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
(message, { expanded }, theme) => {
|
|
359
|
-
return subagentNotificationRenderer(message, { expanded }, theme, resultPreviewMode, resultPreviewExpanded);
|
|
360
|
-
}
|
|
361
|
-
);
|
|
299
|
+
// Read directly rather than waiting for applyAndEmitLoaded below: this decides
|
|
300
|
+
// the initial load, which happens hundreds of lines before settings are applied.
|
|
301
|
+
let strictAgentFiles = loadSettings(process.cwd()).strictAgentFiles === true;
|
|
362
302
|
|
|
363
303
|
/** Reload agents from project/global custom agent dirs and merge with defaults (called on init and each Agent invocation). */
|
|
364
|
-
const reloadCustomAgents = () => {
|
|
365
|
-
const userAgents = loadCustomAgents(process.cwd());
|
|
304
|
+
const reloadCustomAgents = (strict = false) => {
|
|
305
|
+
const userAgents = loadCustomAgents(process.cwd(), strict);
|
|
366
306
|
registerAgents(userAgents);
|
|
367
307
|
};
|
|
368
308
|
|
|
369
|
-
// Initial load
|
|
370
|
-
|
|
309
|
+
// Initial load — the only strict one. A bad edit mid-session must not kill the
|
|
310
|
+
// session on the next unrelated spawn, so every later reload keeps warning.
|
|
311
|
+
reloadCustomAgents(strictAgentFiles);
|
|
371
312
|
|
|
372
313
|
// ---- Agent activity tracking + widget ----
|
|
373
314
|
const agentActivity = new Map<string, AgentActivity>();
|
|
374
315
|
|
|
316
|
+
// ---- Usage reporting (`reportUsage`/`showCost` default on, `showModel` off; see SubagentsSettings) ----
|
|
317
|
+
/** Attach subagent spend to tool results, so the parent session counts it. */
|
|
318
|
+
let reportUsage = true;
|
|
319
|
+
function isReportUsageEnabled(): boolean { return reportUsage; }
|
|
320
|
+
function setReportUsage(b: boolean): void {
|
|
321
|
+
reportUsage = b;
|
|
322
|
+
// Whatever accumulated while it was on is stale the moment it goes off:
|
|
323
|
+
// draining it later would bill the parent for a window the user opted out
|
|
324
|
+
// of, in one lump, on some unrelated later tool call.
|
|
325
|
+
if (!b) pendingUsage.drain();
|
|
326
|
+
}
|
|
327
|
+
/** Show `~$X` next to token counts in the subagent surfaces. */
|
|
328
|
+
let showCost = DEFAULT_SHOW_COST;
|
|
329
|
+
function isShowCostEnabled(): boolean { return showCost; }
|
|
330
|
+
function setShowCost(b: boolean): void {
|
|
331
|
+
showCost = b;
|
|
332
|
+
// The report renderer keeps its own copy of this flag because both report
|
|
333
|
+
// callers must agree (see `setReportShowCost`). That copy is module state
|
|
334
|
+
// shared with every activation in this process, so a CHILD session's
|
|
335
|
+
// settings load must not clobber the root's decision — the nested
|
|
336
|
+
// `get_subagent_result` renders for the same user, in the same session.
|
|
337
|
+
if (!inChildSessionContext()) setReportShowCost(b);
|
|
338
|
+
widget.update();
|
|
339
|
+
fleet.update();
|
|
340
|
+
}
|
|
341
|
+
/** Name the model and thinking level on the widget's running rows. */
|
|
342
|
+
let showModel = false;
|
|
343
|
+
function isShowModelEnabled(): boolean { return showModel; }
|
|
344
|
+
function setShowModel(b: boolean): void { showModel = b; widget.update(); }
|
|
345
|
+
/**
|
|
346
|
+
* Subagent spend the parent session has not been told about yet. Fed from the
|
|
347
|
+
* manager's per-message hook (the one place each message is seen exactly once)
|
|
348
|
+
* and drained onto the next tool result this extension returns.
|
|
349
|
+
*/
|
|
350
|
+
const pendingUsage = new PendingUsagePool();
|
|
351
|
+
|
|
375
352
|
// ---- Cancellable pending notifications ----
|
|
376
353
|
// Holds notifications briefly so get_subagent_result can cancel them before
|
|
377
354
|
// they reach pi.sendMessage (fire-and-forget — once sent, a custom message
|
|
@@ -389,7 +366,10 @@ export default function (pi: ExtensionAPI) {
|
|
|
389
366
|
// firing; the send closures re-check it at fire time.
|
|
390
367
|
const pendingNudges = new Map<string, ReturnType<typeof setTimeout>>();
|
|
391
368
|
const parkedNudges = new Map<string, () => void>();
|
|
392
|
-
|
|
369
|
+
/** Poll interval while the wizard waits for a queued generator to start. The
|
|
370
|
+
* wizard is interactive, so it re-checks the record until it leaves `queued`
|
|
371
|
+
* (or is stopped) instead of blocking on a promise that does not exist yet. */
|
|
372
|
+
const WIZARD_QUEUE_POLL_MS = 100;
|
|
393
373
|
let mainTurnActive = false;
|
|
394
374
|
|
|
395
375
|
function scheduleNudge(key: string, send: () => void, delay = NUDGE_HOLD_MS) {
|
|
@@ -447,19 +427,20 @@ export default function (pi: ExtensionAPI) {
|
|
|
447
427
|
for (const [key, send] of parked) scheduleNudge(key, send);
|
|
448
428
|
});
|
|
449
429
|
|
|
450
|
-
// ----
|
|
430
|
+
// ---- Completion notification ----
|
|
451
431
|
function emitIndividualNudge(record: AgentRecord) {
|
|
452
432
|
if (record.resultConsumed) return; // re-check at send time
|
|
453
433
|
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
434
|
+
try {
|
|
435
|
+
pi.sendMessage({
|
|
436
|
+
customType: "subagent-notification",
|
|
437
|
+
content: formatTaskNotification(record, { failurePreviewMaxChars: effectiveFailurePreviewCap(failurePreviewMaxChars) }, showCost),
|
|
438
|
+
display: true,
|
|
439
|
+
}, { deliverAs: "steer", triggerTurn: true });
|
|
440
|
+
} catch (err) {
|
|
441
|
+
// Nudge release paths swallow throws; surface the drop with the agent id.
|
|
442
|
+
console.warn(`[pi-subagents] failed to send completion notification for ${record.id}: ${err instanceof Error ? err.message : String(err)}`);
|
|
443
|
+
}
|
|
463
444
|
}
|
|
464
445
|
|
|
465
446
|
function sendIndividualNudge(record: AgentRecord) {
|
|
@@ -470,40 +451,6 @@ export default function (pi: ExtensionAPI) {
|
|
|
470
451
|
widget.update();
|
|
471
452
|
}
|
|
472
453
|
|
|
473
|
-
// ---- Group join manager ----
|
|
474
|
-
const groupJoin = new GroupJoinManager(
|
|
475
|
-
(records, partial) => {
|
|
476
|
-
for (const r of records) { agentActivity.delete(r.id); widget.markFinished(r.id); fleet.onAgentFinished(r.id); }
|
|
477
|
-
|
|
478
|
-
const groupKey = `group:${records.map(r => r.id).join(",")}`;
|
|
479
|
-
scheduleNudge(groupKey, () => {
|
|
480
|
-
// Re-check at send time
|
|
481
|
-
const unconsumed = records.filter(r => !r.resultConsumed);
|
|
482
|
-
if (unconsumed.length === 0) { widget.update(); return; }
|
|
483
|
-
|
|
484
|
-
const notifications = unconsumed.map(r => formatTaskNotification(r, { failurePreviewMaxChars })).join('\n\n');
|
|
485
|
-
const label = partial
|
|
486
|
-
? `${unconsumed.length} agent(s) finished (partial — others still running)`
|
|
487
|
-
: `${unconsumed.length} agent(s) finished`;
|
|
488
|
-
|
|
489
|
-
const [first, ...rest] = unconsumed;
|
|
490
|
-
const details = buildNotificationDetails(first, { failurePreviewMaxChars }, agentActivity.get(first.id));
|
|
491
|
-
if (rest.length > 0) {
|
|
492
|
-
details.others = rest.map(r => buildNotificationDetails(r, { failurePreviewMaxChars }, agentActivity.get(r.id)));
|
|
493
|
-
}
|
|
494
|
-
|
|
495
|
-
pi.sendMessage<NotificationDetails>({
|
|
496
|
-
customType: "subagent-notification",
|
|
497
|
-
content: `Background agent group completed: ${label}\n\n${notifications}\n\nUse get_subagent_result for full output.`,
|
|
498
|
-
display: true,
|
|
499
|
-
details,
|
|
500
|
-
}, { deliverAs: "steer", triggerTurn: true });
|
|
501
|
-
});
|
|
502
|
-
widget.update();
|
|
503
|
-
},
|
|
504
|
-
30_000,
|
|
505
|
-
);
|
|
506
|
-
|
|
507
454
|
/** Helper: build event data for lifecycle events from an AgentRecord. */
|
|
508
455
|
function buildEventData(record: AgentRecord) {
|
|
509
456
|
const durationMs = record.completedAt ? record.completedAt - record.startedAt : Date.now() - record.startedAt;
|
|
@@ -516,6 +463,19 @@ export default function (pi: ExtensionAPI) {
|
|
|
516
463
|
const tokens = total > 0
|
|
517
464
|
? { input: u.input, output: u.output, total }
|
|
518
465
|
: undefined;
|
|
466
|
+
// The whole run's spend as a pi `Usage` — pi's convention for handing spend
|
|
467
|
+
// to a consumer, so `usage.cost.total` and `usage.cacheRead` are where a
|
|
468
|
+
// listener already expects them and anything pi adds to `Usage` arrives
|
|
469
|
+
// without a change here. Omitted when nothing was spent, so "spent nothing"
|
|
470
|
+
// and "never ran" stay distinguishable. Ungated by either setting: those
|
|
471
|
+
// govern what a human is shown and what the parent session counts, not what
|
|
472
|
+
// a subscriber asked for.
|
|
473
|
+
//
|
|
474
|
+
// `tokens` above is the other convention, kept as it shipped: a flat view
|
|
475
|
+
// model like pi's own `SessionStats`, carrying the DISPLAY total, which
|
|
476
|
+
// excludes cacheRead (#38). The two answer different questions and neither
|
|
477
|
+
// derives from the other.
|
|
478
|
+
const usage = toReportedUsage(u);
|
|
519
479
|
return {
|
|
520
480
|
id: record.id,
|
|
521
481
|
type: record.type,
|
|
@@ -526,11 +486,16 @@ export default function (pi: ExtensionAPI) {
|
|
|
526
486
|
toolUses: record.toolUses,
|
|
527
487
|
durationMs,
|
|
528
488
|
tokens,
|
|
489
|
+
usage,
|
|
529
490
|
};
|
|
530
491
|
}
|
|
531
492
|
|
|
532
|
-
// Background completion:
|
|
493
|
+
// Background completion: send one notification per agent
|
|
533
494
|
const manager = new AgentManager((record) => {
|
|
495
|
+
// Nested children report only through their owning parent's scoped tools.
|
|
496
|
+
// Keep them out of top-level lifecycle, transcript, notification, and UI
|
|
497
|
+
// channels — nothing in the main session can act on them.
|
|
498
|
+
if (record.parentAgentId) return;
|
|
534
499
|
// Emit lifecycle event based on terminal status
|
|
535
500
|
const isError = record.status === "error" || record.status === "stopped" || record.status === "aborted";
|
|
536
501
|
const eventData = buildEventData(record);
|
|
@@ -556,21 +521,10 @@ export default function (pi: ExtensionAPI) {
|
|
|
556
521
|
return;
|
|
557
522
|
}
|
|
558
523
|
|
|
559
|
-
|
|
560
|
-
// don't send an individual nudge — finalizeBatch will pick it up retroactively.
|
|
561
|
-
if (currentBatchAgents.some(a => a.id === record.id)) {
|
|
562
|
-
widget.update();
|
|
563
|
-
return;
|
|
564
|
-
}
|
|
565
|
-
|
|
566
|
-
const result = groupJoin.onAgentComplete(record);
|
|
567
|
-
if (result === 'pass') {
|
|
568
|
-
sendIndividualNudge(record);
|
|
569
|
-
}
|
|
570
|
-
// 'held' → do nothing, group will fire later
|
|
571
|
-
// 'delivered' → group callback already fired
|
|
524
|
+
sendIndividualNudge(record);
|
|
572
525
|
widget.update();
|
|
573
526
|
}, undefined, (record) => {
|
|
527
|
+
if (record.parentAgentId) return;
|
|
574
528
|
// Emit started event when agent transitions to running (including from queue)
|
|
575
529
|
pi.events.emit("subagents:started", {
|
|
576
530
|
id: record.id,
|
|
@@ -578,6 +532,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
578
532
|
description: record.description,
|
|
579
533
|
});
|
|
580
534
|
}, (record, info) => {
|
|
535
|
+
if (record.parentAgentId) return;
|
|
581
536
|
// Emit compacted event when agent's session compacts (preserves count on record).
|
|
582
537
|
pi.events.emit("subagents:compacted", {
|
|
583
538
|
id: record.id,
|
|
@@ -587,6 +542,48 @@ export default function (pi: ExtensionAPI) {
|
|
|
587
542
|
tokensBefore: info.tokensBefore,
|
|
588
543
|
compactionCount: record.compactionCount,
|
|
589
544
|
});
|
|
545
|
+
}, (record, usage) => {
|
|
546
|
+
// Every assistant message from every agent — nested included, exactly once.
|
|
547
|
+
// Parked here until a tool result can carry it back to the parent session;
|
|
548
|
+
// see `PendingUsagePool`. Skipped entirely when the feature is off, so no
|
|
549
|
+
// pool grows in a session that will never drain it.
|
|
550
|
+
if (reportUsage) pendingUsage.add(usage);
|
|
551
|
+
// The same message, live, for anyone keeping their own ledger — a footer
|
|
552
|
+
// that breaks spend down by agent type and thinking level, say. This is the
|
|
553
|
+
// one place every agent's spend passes through, which is why it is emitted
|
|
554
|
+
// here and not from the lifecycle events: those fire for top-level agents
|
|
555
|
+
// only, once, at the end, so a nested child would never be counted and a
|
|
556
|
+
// running agent would read as free until it stopped. Deliberately ungated by
|
|
557
|
+
// `reportUsage` — that decides whether the parent session's own totals
|
|
558
|
+
// include this spend, not whether it happened.
|
|
559
|
+
//
|
|
560
|
+
// `model` and `thinking` are read off the record per message, not captured
|
|
561
|
+
// at spawn: the record is authoritative only once the session exists and pi
|
|
562
|
+
// has clamped the level to the model (see `onSessionCreated` in
|
|
563
|
+
// agent-manager.ts).
|
|
564
|
+
const reported = toReportedUsage(usage);
|
|
565
|
+
if (!reported) return;
|
|
566
|
+
// A key whose value is unknown is omitted, never `undefined`: `model` and
|
|
567
|
+
// `thinking` until the session has reported them, `parentAgentId` on a
|
|
568
|
+
// top-level agent.
|
|
569
|
+
const invocation = record.invocation;
|
|
570
|
+
pi.events.emit("subagents:usage", {
|
|
571
|
+
id: record.id,
|
|
572
|
+
type: record.type,
|
|
573
|
+
description: record.description,
|
|
574
|
+
...(invocation?.modelId !== undefined ? { model: invocation.modelId } : {}),
|
|
575
|
+
...(invocation?.thinking !== undefined ? { thinking: invocation.thinking } : {}),
|
|
576
|
+
// Set only when the caller did not get the level it asked for (pi clamped
|
|
577
|
+
// it to the model), so a ledger can show `low (asked minimal)` instead of
|
|
578
|
+
// silently relabelling.
|
|
579
|
+
...(invocation?.requestedThinking !== undefined ? { requestedThinking: invocation.requestedThinking } : {}),
|
|
580
|
+
depth: record.depth ?? 1,
|
|
581
|
+
...(record.parentAgentId !== undefined ? { parentAgentId: record.parentAgentId } : {}),
|
|
582
|
+
usage: reported,
|
|
583
|
+
});
|
|
584
|
+
}, (record, message) => {
|
|
585
|
+
if (record.parentAgentId) return;
|
|
586
|
+
pi.events.emit("subagents:steered", { id: record.id, message });
|
|
590
587
|
});
|
|
591
588
|
|
|
592
589
|
// Expose manager via Symbol.for() global registry for cross-package access.
|
|
@@ -599,12 +596,40 @@ export default function (pi: ExtensionAPI) {
|
|
|
599
596
|
// session's entry. The first activation (the root session) wins; child
|
|
600
597
|
// activations leave it alone.
|
|
601
598
|
const MANAGER_KEY = Symbol.for("pi-subagents:manager");
|
|
599
|
+
// Every caller-supplied spawn that reaches the manager from outside the Agent
|
|
600
|
+
// tool goes through here, so `fallbackSubagent` gates cross-extension callers
|
|
601
|
+
// too (#183). The RPC layer already throws for an unresolvable model rather
|
|
602
|
+
// than falling back silently; a bad agent type should not be quieter. Throws
|
|
603
|
+
// become error envelopes at the RPC boundary. Reload first so an agent file
|
|
604
|
+
// added mid-session is spawnable here too, not only through the Agent tool.
|
|
605
|
+
//
|
|
606
|
+
// Cross-package callers may supply arbitrary options. Nested ownership and
|
|
607
|
+
// config-root metadata are internal capabilities issued only by the scoped
|
|
608
|
+
// nested tools — a forged `parentAgentId` would hide a top-level agent from
|
|
609
|
+
// every surface, and `rootSessionId` names a transcript directory, so a
|
|
610
|
+
// forged value is a path-traversal primitive.
|
|
611
|
+
const spawnTopLevel = (piRef: ExtensionAPI, ctxRef: ExtensionContext, type: SubagentType, prompt: string, options: SpawnOptions) => {
|
|
612
|
+
reloadCustomAgents();
|
|
613
|
+
const dispatch = resolveSpawnType(type);
|
|
614
|
+
if (!dispatch.ok) throw new Error(dispatch.message);
|
|
615
|
+
const safeOptions: SpawnOptions = { ...options };
|
|
616
|
+
delete safeOptions.parentAgentId;
|
|
617
|
+
delete safeOptions.depth;
|
|
618
|
+
delete safeOptions.maxSubagentDepth;
|
|
619
|
+
delete safeOptions.configCwd;
|
|
620
|
+
delete safeOptions.rootSessionId;
|
|
621
|
+
return manager.spawn(piRef, ctxRef, dispatch.type, prompt, safeOptions);
|
|
622
|
+
};
|
|
602
623
|
const registryEntry = {
|
|
603
624
|
waitForAll: () => manager.waitForAll(),
|
|
604
625
|
hasRunning: () => manager.hasRunning(),
|
|
605
|
-
spawn:
|
|
606
|
-
|
|
607
|
-
|
|
626
|
+
spawn: spawnTopLevel,
|
|
627
|
+
// `listAgents` stays unfiltered on purpose: the subagent-facing `checkpoint`
|
|
628
|
+
// tool resolves its own nested record through it by session id.
|
|
629
|
+
getRecord: (id: string) => {
|
|
630
|
+
const record = manager.getRecord(id);
|
|
631
|
+
return record?.parentAgentId ? undefined : record;
|
|
632
|
+
},
|
|
608
633
|
listAgents: () => manager.listAgents(),
|
|
609
634
|
};
|
|
610
635
|
const ownsManagerRegistry = (globalThis as any)[MANAGER_KEY] === undefined;
|
|
@@ -612,6 +637,52 @@ export default function (pi: ExtensionAPI) {
|
|
|
612
637
|
(globalThis as any)[MANAGER_KEY] = registryEntry;
|
|
613
638
|
}
|
|
614
639
|
|
|
640
|
+
/**
|
|
641
|
+
* Wrap a tool so its results carry back whatever subagent spend the parent
|
|
642
|
+
* session has not been told about yet (see `PendingUsagePool`).
|
|
643
|
+
*
|
|
644
|
+
* Pi copies `AgentToolResult.usage` onto the persisted tool-result message and
|
|
645
|
+
* folds it into `getSessionStats()`, which is what the footer, the statusline
|
|
646
|
+
* and `/cost` read — so this is the whole of "report usage to the parent".
|
|
647
|
+
*
|
|
648
|
+
* No tool-call-id guard: `ToolDefinition.execute` takes a `string` here, and
|
|
649
|
+
* every caller of a registered tool in this fork is pi itself, which always
|
|
650
|
+
* has one. There is no in-process clone that invokes a tool from a discarded
|
|
651
|
+
* fork of the conversation, so a result this extension produces always becomes
|
|
652
|
+
* a real message and its usage is always counted.
|
|
653
|
+
*/
|
|
654
|
+
function withUsageReporting(tool: ToolDefinition): ToolDefinition {
|
|
655
|
+
const execute = tool.execute;
|
|
656
|
+
return {
|
|
657
|
+
...tool,
|
|
658
|
+
execute: async (toolCallId, params, signal, onUpdate, ctx) => {
|
|
659
|
+
const result = await execute(toolCallId, params, signal, onUpdate, ctx);
|
|
660
|
+
if (!reportUsage) return result;
|
|
661
|
+
const usage = pendingUsage.drain();
|
|
662
|
+
return usage ? { ...result, usage } : result;
|
|
663
|
+
},
|
|
664
|
+
};
|
|
665
|
+
}
|
|
666
|
+
|
|
667
|
+
/**
|
|
668
|
+
* Register a parent-facing tool — never in a subagent session.
|
|
669
|
+
*
|
|
670
|
+
* A subagent session re-activates this extension in the same process, and its
|
|
671
|
+
* local manager is empty: an `Agent` registered there could only spawn into a
|
|
672
|
+
* manager nobody reads. Worse, an opted-in child receives the nested `Agent`
|
|
673
|
+
* as an SDK custom tool, so registering ours too would put two definitions of
|
|
674
|
+
* the same name in one registry and leave the survivor to pi's precedence.
|
|
675
|
+
* Skipping the child activation makes the child's single `Agent` a
|
|
676
|
+
* construction-time guarantee. Every other child has these names stripped from
|
|
677
|
+
* its allowlist (EXCLUDED_TOOL_NAMES), so nothing changes for them.
|
|
678
|
+
*
|
|
679
|
+
* Every parent-facing tool is registered through here, so this is also the one
|
|
680
|
+
* place that attaches pending subagent spend to a tool result.
|
|
681
|
+
*/
|
|
682
|
+
const registerParentTool = (tool: ToolDefinition): void => {
|
|
683
|
+
if (!inChildSessionContext()) pi.registerTool(withUsageReporting(tool));
|
|
684
|
+
};
|
|
685
|
+
|
|
615
686
|
// --- Cross-extension RPC via pi.events ---
|
|
616
687
|
let currentCtx: ExtensionContext | undefined;
|
|
617
688
|
// RPC handlers + the `subagents:ready` broadcast are wired on `session_start`
|
|
@@ -657,7 +728,34 @@ export default function (pi: ExtensionAPI) {
|
|
|
657
728
|
events: pi.events,
|
|
658
729
|
pi,
|
|
659
730
|
getCtx: () => currentCtx,
|
|
660
|
-
manager
|
|
731
|
+
// Same capability boundary as the manager registry: a cross-extension
|
|
732
|
+
// caller cannot forge nested ownership, and cannot stop, steer or
|
|
733
|
+
// consume a nested child it does not own.
|
|
734
|
+
manager: {
|
|
735
|
+
spawn: spawnTopLevel,
|
|
736
|
+
getRecord: (id) => manager.getRecord(id),
|
|
737
|
+
abort: (id) => {
|
|
738
|
+
const record = manager.getRecord(id);
|
|
739
|
+
return !record?.parentAgentId && manager.abort(id);
|
|
740
|
+
},
|
|
741
|
+
// What `get_subagent_result` does when it returns a settled result:
|
|
742
|
+
// mark it read and cancel the parked completion notification, so the
|
|
743
|
+
// caller that already showed the model this result does not pay for a
|
|
744
|
+
// second copy of it.
|
|
745
|
+
consumeResult: (id) => {
|
|
746
|
+
// Ownership is the RPC layer's job (assertOwned); settledness is not —
|
|
747
|
+
// it never looks at status. Consuming a running agent would set
|
|
748
|
+
// resultConsumed and suppress the completion notification it is still
|
|
749
|
+
// owed, so this stays the same gate the local get_subagent_result has.
|
|
750
|
+
const record = manager.getRecord(id);
|
|
751
|
+
if (!record) return false;
|
|
752
|
+
if (record.status === "running" || record.status === "queued") return false;
|
|
753
|
+
record.resultConsumed = true;
|
|
754
|
+
cancelNudge(record.id);
|
|
755
|
+
return true;
|
|
756
|
+
},
|
|
757
|
+
steer: (id, message) => manager.steer(id, message),
|
|
758
|
+
},
|
|
661
759
|
});
|
|
662
760
|
// Broadcast readiness so extensions loaded alongside us can discover us.
|
|
663
761
|
// Emitting after all factories have run (rather than at factory time)
|
|
@@ -674,10 +772,12 @@ export default function (pi: ExtensionAPI) {
|
|
|
674
772
|
|
|
675
773
|
// On shutdown, abort all agents immediately and clean up.
|
|
676
774
|
// If the session is going down, there's nothing left to consume agent results.
|
|
677
|
-
pi.on("session_shutdown", async () => {
|
|
775
|
+
pi.on("session_shutdown", async (event) => {
|
|
678
776
|
rpcHandle?.unsubSpawn();
|
|
679
777
|
rpcHandle?.unsubStop();
|
|
680
778
|
rpcHandle?.unsubPing();
|
|
779
|
+
rpcHandle?.unsubConsume();
|
|
780
|
+
rpcHandle?.unsubSteer();
|
|
681
781
|
rpcHandle = undefined;
|
|
682
782
|
currentCtx = undefined;
|
|
683
783
|
// Only release the global slot if this activation claimed it — a child
|
|
@@ -693,35 +793,37 @@ export default function (pi: ExtensionAPI) {
|
|
|
693
793
|
mainTurnActive = false;
|
|
694
794
|
fleet.dispose();
|
|
695
795
|
manager.dispose();
|
|
796
|
+
// Every agent is stopped and every child session has shut down, so no more
|
|
797
|
+
// `subagents:usage` can arrive from THIS session. A ledger flushing in its
|
|
798
|
+
// own `session_shutdown` may have run before this handler aborted the agents
|
|
799
|
+
// (handlers run in load order) and missed their last messages; this is the
|
|
800
|
+
// point after which a synchronous final flush is complete.
|
|
801
|
+
//
|
|
802
|
+
// Not only an exit: pi also emits `session_shutdown` for `resume`, `reload`,
|
|
803
|
+
// `/new` and `/fork`, where the process lives on and the next session starts
|
|
804
|
+
// emitting `subagents:usage` again. The reason is carried so a listener can
|
|
805
|
+
// tell a final flush from a mid-process one.
|
|
806
|
+
pi.events.emit("subagents:disposed", { reason: event.reason });
|
|
696
807
|
});
|
|
697
808
|
|
|
698
809
|
// Live widget: show running agents above editor.
|
|
699
|
-
// widgetMode (default "
|
|
700
|
-
//
|
|
701
|
-
|
|
702
|
-
// everything else; "off" = hide the widget entirely. Read live at render time.
|
|
703
|
-
let widgetMode: WidgetMode = "background";
|
|
810
|
+
// widgetMode (default "all") selects what the widget shows: "all" = every
|
|
811
|
+
// agent; "off" = hide the widget entirely. Read live at render time.
|
|
812
|
+
let widgetMode: WidgetMode = "all";
|
|
704
813
|
function getWidgetMode(): WidgetMode { return widgetMode; }
|
|
705
|
-
const widget = new AgentWidget(manager, agentActivity, getWidgetMode);
|
|
814
|
+
const widget = new AgentWidget(manager, agentActivity, getWidgetMode, isShowCostEnabled, isShowModelEnabled);
|
|
706
815
|
function setWidgetMode(m: WidgetMode): void { widgetMode = m; widget.update(); }
|
|
707
816
|
|
|
708
817
|
// Claude Code-style FleetView: navigable list of main + subagents below the editor.
|
|
709
|
-
const fleet = new FleetList(manager, agentActivity);
|
|
818
|
+
const fleet = new FleetList(manager, agentActivity, isShowCostEnabled);
|
|
710
819
|
let fleetViewEnabled = true;
|
|
711
820
|
function isFleetViewEnabled(): boolean { return fleetViewEnabled; }
|
|
712
821
|
function setFleetViewEnabled(b: boolean): void { fleetViewEnabled = b; fleet.setEnabled(b); }
|
|
713
822
|
|
|
714
|
-
// Project/global default for writing the subagent .output transcript
|
|
715
|
-
//
|
|
716
|
-
// frontmatter
|
|
717
|
-
|
|
718
|
-
function getOutputTranscriptDefault(): boolean { return outputTranscriptDefault; }
|
|
719
|
-
function setOutputTranscript(b: boolean): void { outputTranscriptDefault = b; }
|
|
720
|
-
|
|
721
|
-
// ---- Join mode configuration ----
|
|
722
|
-
let defaultJoinMode: JoinMode = 'smart';
|
|
723
|
-
function getDefaultJoinMode(): JoinMode { return defaultJoinMode; }
|
|
724
|
-
function setDefaultJoinMode(mode: JoinMode) { defaultJoinMode = mode; }
|
|
823
|
+
// Project/global default for writing the subagent .output transcript lives in
|
|
824
|
+
// output-file.ts (both spawn paths read it). A custom agent's
|
|
825
|
+
// `output_transcript` frontmatter overrides it per spawn; when the frontmatter
|
|
826
|
+
// is silent, that default applies. Read live at spawn time.
|
|
725
827
|
|
|
726
828
|
// Master switch for the schedule subagent feature. Defaults to enabled.
|
|
727
829
|
// Read once at extension init (before tool registration) so the Agent tool's
|
|
@@ -734,22 +836,13 @@ export default function (pi: ExtensionAPI) {
|
|
|
734
836
|
function setSchedulingEnabled(b: boolean) { schedulingEnabled = b; }
|
|
735
837
|
|
|
736
838
|
// ---- Scope models configuration ----
|
|
737
|
-
//
|
|
738
|
-
//
|
|
739
|
-
//
|
|
740
|
-
//
|
|
741
|
-
|
|
742
|
-
//
|
|
743
|
-
let scopeModelsEnabled = false;
|
|
744
|
-
function isScopeModelsEnabled(): boolean { return scopeModelsEnabled; }
|
|
745
|
-
function setScopeModelsEnabled(enabled: boolean): void { scopeModelsEnabled = enabled; }
|
|
746
|
-
|
|
747
|
-
// ---- Result preview configuration ----
|
|
748
|
-
let resultPreviewMode: ResultPreviewMode = "markdown";
|
|
749
|
-
let resultPreviewExpanded = true;
|
|
839
|
+
// The enable flag and the validation policy live in model-scope.ts, so the
|
|
840
|
+
// nested delegation tools enforce the same allowlist the top-level Agent tool
|
|
841
|
+
// does. Off by default; opt-in via `/agents → Settings`. See the
|
|
842
|
+
// SubagentsSettings.scopeModels docstring for the warn-and-proceed policy.
|
|
843
|
+
|
|
844
|
+
// ---- Failure preview configuration ----
|
|
750
845
|
let failurePreviewMaxChars = DEFAULT_FAILURE_PREVIEW_MAX_CHARS;
|
|
751
|
-
function setResultPreviewMode(mode: ResultPreviewMode): void { resultPreviewMode = mode; }
|
|
752
|
-
function setResultPreviewExpanded(expanded: boolean): void { resultPreviewExpanded = expanded; }
|
|
753
846
|
function setFailurePreviewMaxChars(chars: number): void { failurePreviewMaxChars = chars; }
|
|
754
847
|
|
|
755
848
|
// ---- Disable default agents configuration ----
|
|
@@ -772,49 +865,6 @@ export default function (pi: ExtensionAPI) {
|
|
|
772
865
|
function getToolDescriptionMode(): ToolDescriptionMode { return toolDescriptionMode; }
|
|
773
866
|
function setToolDescriptionMode(mode: ToolDescriptionMode): void { toolDescriptionMode = mode; }
|
|
774
867
|
|
|
775
|
-
// ---- Batch tracking for smart join mode ----
|
|
776
|
-
// Collects background agent IDs spawned in the current turn for smart grouping.
|
|
777
|
-
// Uses a debounced timer: each new agent resets the 100ms window so that all
|
|
778
|
-
// parallel tool calls (which may be dispatched across multiple microtasks by the
|
|
779
|
-
// framework) are captured in the same batch.
|
|
780
|
-
let currentBatchAgents: { id: string; joinMode: JoinMode }[] = [];
|
|
781
|
-
let batchFinalizeTimer: ReturnType<typeof setTimeout> | undefined;
|
|
782
|
-
let batchCounter = 0;
|
|
783
|
-
|
|
784
|
-
/** Finalize the current batch: if 2+ smart-mode agents, register as a group. */
|
|
785
|
-
function finalizeBatch() {
|
|
786
|
-
batchFinalizeTimer = undefined;
|
|
787
|
-
const batchAgents = [...currentBatchAgents];
|
|
788
|
-
currentBatchAgents = [];
|
|
789
|
-
|
|
790
|
-
const smartAgents = batchAgents.filter(a => a.joinMode === 'smart' || a.joinMode === 'group');
|
|
791
|
-
if (smartAgents.length >= 2) {
|
|
792
|
-
const groupId = `batch-${++batchCounter}`;
|
|
793
|
-
const ids = smartAgents.map(a => a.id);
|
|
794
|
-
groupJoin.registerGroup(groupId, ids);
|
|
795
|
-
// Retroactively process agents that already completed during the debounce window.
|
|
796
|
-
// Their onComplete fired but was deferred (agent was in currentBatchAgents),
|
|
797
|
-
// so we feed them into the group now.
|
|
798
|
-
for (const id of ids) {
|
|
799
|
-
const record = manager.getRecord(id);
|
|
800
|
-
if (!record) continue;
|
|
801
|
-
record.groupId = groupId;
|
|
802
|
-
if (record.completedAt != null && !record.resultConsumed) {
|
|
803
|
-
groupJoin.onAgentComplete(record);
|
|
804
|
-
}
|
|
805
|
-
}
|
|
806
|
-
} else {
|
|
807
|
-
// No group formed — send individual nudges for any agents that completed
|
|
808
|
-
// during the debounce window and had their notification deferred.
|
|
809
|
-
for (const { id } of batchAgents) {
|
|
810
|
-
const record = manager.getRecord(id);
|
|
811
|
-
if (record?.completedAt != null && !record.resultConsumed) {
|
|
812
|
-
sendIndividualNudge(record);
|
|
813
|
-
}
|
|
814
|
-
}
|
|
815
|
-
}
|
|
816
|
-
}
|
|
817
|
-
|
|
818
868
|
// Grab UI context from first tool execution + clear lingering widget on new turn
|
|
819
869
|
pi.on("tool_execution_start", async (_event, ctx) => {
|
|
820
870
|
widget.setUICtx(ctx.ui as UICtx);
|
|
@@ -873,17 +923,21 @@ export default function (pi: ExtensionAPI) {
|
|
|
873
923
|
setMaxConcurrent: (n) => manager.setMaxConcurrent(n),
|
|
874
924
|
setDefaultMaxTurns,
|
|
875
925
|
setGraceTurns,
|
|
876
|
-
setDefaultJoinMode,
|
|
877
926
|
setSchedulingEnabled,
|
|
878
927
|
setScopeModels: setScopeModelsEnabled,
|
|
879
|
-
|
|
880
|
-
setResultPreviewExpanded,
|
|
928
|
+
setStrictAgentFiles: (b) => { strictAgentFiles = b; },
|
|
881
929
|
setFailurePreviewMaxChars,
|
|
882
930
|
setDisableDefaultAgents: setDisableDefaultAgents,
|
|
883
931
|
setToolDescriptionMode: setToolDescriptionMode,
|
|
884
932
|
setFleetView: setFleetViewEnabled,
|
|
885
933
|
setWidgetMode: setWidgetMode,
|
|
886
|
-
setOutputTranscript:
|
|
934
|
+
setOutputTranscript: setOutputTranscriptDefault,
|
|
935
|
+
setMaxSubagentDepth: setMaxSubagentDepth,
|
|
936
|
+
setWorktreeIsolation: setWorktreeIsolationEnabled,
|
|
937
|
+
setFallbackSubagent: setFallbackSubagent,
|
|
938
|
+
setReportUsage,
|
|
939
|
+
setShowCost,
|
|
940
|
+
setShowModel,
|
|
887
941
|
},
|
|
888
942
|
(event, payload) => pi.events.emit(event, payload),
|
|
889
943
|
);
|
|
@@ -901,7 +955,8 @@ export default function (pi: ExtensionAPI) {
|
|
|
901
955
|
description:
|
|
902
956
|
'Opt-in only — fire later instead of now. Omit to run immediately (the default, almost always correct). ' +
|
|
903
957
|
'Formats: 6-field cron ("0 0 9 * * 1" = 9am Mon), interval ("5m"/"1h"), one-shot ("+10m" or ISO). ' +
|
|
904
|
-
'
|
|
958
|
+
'Interval delays are capped by the JS timer ceiling (~24.8 days); one-shot dates further out stay scheduled and arm at a later session start once closer. ' +
|
|
959
|
+
'Incompatible with inherit_context and resume. Returns job ID.',
|
|
905
960
|
}),
|
|
906
961
|
),
|
|
907
962
|
};
|
|
@@ -912,6 +967,21 @@ export default function (pi: ExtensionAPI) {
|
|
|
912
967
|
? `\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.`
|
|
913
968
|
: "";
|
|
914
969
|
|
|
970
|
+
// Same trade as scheduleParam/scheduleGuideline above: `isolationParam` drops
|
|
971
|
+
// the field from the schema when the project set `worktreeIsolation: false`,
|
|
972
|
+
// so the prose has to go with it. Left in, it would teach the model to pass a
|
|
973
|
+
// parameter that isn't declared — accepted (TypeBox sets no
|
|
974
|
+
// `additionalProperties: false`) and then silently dropped by the resolver.
|
|
975
|
+
// With no per-result note by design, the model would have every reason to go
|
|
976
|
+
// on reporting a `pi-agent-*` branch that was never created.
|
|
977
|
+
const isolationGuideline = isWorktreeIsolationEnabled()
|
|
978
|
+
? `\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.`
|
|
979
|
+
: "";
|
|
980
|
+
|
|
981
|
+
const isolationCompactGuideline = isWorktreeIsolationEnabled()
|
|
982
|
+
? `\n- isolation: "worktree" gives the agent its own git worktree (removed on completion); changes land on a branch named in the result.`
|
|
983
|
+
: "";
|
|
984
|
+
|
|
915
985
|
// Compact Agent tool description (#91, `toolDescriptionMode: "compact"`) —
|
|
916
986
|
// the same load-bearing facts as the full version at ~75% fewer tokens, for
|
|
917
987
|
// small/local models. Per-option details live in the param descriptions.
|
|
@@ -922,10 +992,9 @@ Custom agents: .pi/agents/<name>.md (project) or ${getAgentDir()}/agents/<name>.
|
|
|
922
992
|
|
|
923
993
|
Notes:
|
|
924
994
|
- description: 3-5 words (shown in UI). Prompts must be self-contained — the agent has not seen this conversation.
|
|
925
|
-
-
|
|
926
|
-
-
|
|
927
|
-
- resume continues a previous agent by ID; steer_subagent messages a running one
|
|
928
|
-
- isolation: "worktree" runs the agent in an isolated git worktree; changes land on a branch.`;
|
|
995
|
+
- You will be notified on subagent completion/failure — never poll or sleep.
|
|
996
|
+
- Verify an agent's claimed code changes before reporting work done.
|
|
997
|
+
- resume continues a previous agent by ID; steer_subagent messages a running one.${isolationCompactGuideline}`;
|
|
929
998
|
|
|
930
999
|
const fullAgentToolDescription = `Launch a new agent to handle complex, multi-step tasks autonomously. Each agent type has specific capabilities and tools available to it.
|
|
931
1000
|
|
|
@@ -943,18 +1012,12 @@ If the target is already known, use a direct tool — \`read\` for a known path,
|
|
|
943
1012
|
## Usage notes
|
|
944
1013
|
|
|
945
1014
|
- Always include a short (3-5 word) description summarizing what the agent will do (shown in UI).
|
|
946
|
-
- When
|
|
947
|
-
- 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.
|
|
1015
|
+
- When an agent finishes or fails, you will be notified on subagent completion/failure — do NOT poll or sleep waiting for it.
|
|
948
1016
|
- 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.
|
|
949
|
-
- Use run_in_background for work you don't need immediately. You will be notified when it completes — do NOT poll or sleep waiting for it. Continue with other work or respond to the user instead.
|
|
950
|
-
- Foreground vs background: use foreground (default) when you need the agent's results before you can proceed. Use background when you have genuinely independent work to do in parallel.
|
|
951
1017
|
- 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.
|
|
952
|
-
- Use steer_subagent to send mid-run messages to a running
|
|
1018
|
+
- Use steer_subagent to send mid-run messages to a running subagent.
|
|
953
1019
|
- 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.
|
|
954
|
-
-
|
|
955
|
-
- Use thinking to control extended thinking level.
|
|
956
|
-
- Use inherit_context if the agent needs the parent conversation history.
|
|
957
|
-
- 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}
|
|
1020
|
+
- Use inherit_context if the agent needs the parent conversation history.${isolationGuideline}${scheduleGuideline}
|
|
958
1021
|
|
|
959
1022
|
## Writing the prompt
|
|
960
1023
|
|
|
@@ -978,6 +1041,7 @@ Terse command-style prompts produce shallow, generic work.
|
|
|
978
1041
|
typeList: buildTypeListText,
|
|
979
1042
|
compactTypeList: buildCompactTypeListText,
|
|
980
1043
|
agentDir: getAgentDir,
|
|
1044
|
+
isolationGuideline: () => isolationGuideline,
|
|
981
1045
|
scheduleGuideline: () => scheduleGuideline,
|
|
982
1046
|
};
|
|
983
1047
|
// Replacement callback (not a string) — agent descriptions may contain `$&` etc.
|
|
@@ -1016,7 +1080,7 @@ Terse command-style prompts produce shallow, generic work.
|
|
|
1016
1080
|
return fullAgentToolDescription;
|
|
1017
1081
|
})();
|
|
1018
1082
|
|
|
1019
|
-
|
|
1083
|
+
registerParentTool(defineTool({
|
|
1020
1084
|
name: SUBAGENT_TOOL_NAMES.AGENT,
|
|
1021
1085
|
label: "Agent",
|
|
1022
1086
|
description: agentToolDescription,
|
|
@@ -1024,7 +1088,7 @@ Terse command-style prompts produce shallow, generic work.
|
|
|
1024
1088
|
promptGuidelines: [
|
|
1025
1089
|
"Use Agent with specialized agents when the task matches an agent type's description. Subagents are valuable for parallelizing independent queries or for protecting the main context window from excessive results, but should not be used excessively when not needed. Importantly, avoid duplicating work that subagents are already doing — if you delegate research to a subagent, do not also perform the same searches yourself.",
|
|
1026
1090
|
"For broad codebase exploration or research, spawn Agent with an appropriate subagent_type (e.g. Explore). Otherwise use direct tools (read, grep, find) when the target is already known.",
|
|
1027
|
-
"
|
|
1091
|
+
"You will be notified on subagent completion/failure — do not poll or sleep waiting for it. Continue with other work instead.",
|
|
1028
1092
|
"Trust but verify: an agent's summary describes intent, not outcome. When an agent writes or edits code, check the actual changes before reporting work as done.",
|
|
1029
1093
|
],
|
|
1030
1094
|
parameters: Type.Object({
|
|
@@ -1037,22 +1101,12 @@ Terse command-style prompts produce shallow, generic work.
|
|
|
1037
1101
|
subagent_type: Type.String({
|
|
1038
1102
|
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.`,
|
|
1039
1103
|
}),
|
|
1040
|
-
thinking: Type.Optional(
|
|
1041
|
-
Type.String({
|
|
1042
|
-
description: `Thinking level: ${THINKING_LEVELS.join(", ")}. Overrides agent default.`,
|
|
1043
|
-
}),
|
|
1044
|
-
),
|
|
1045
1104
|
max_turns: Type.Optional(
|
|
1046
1105
|
Type.Number({
|
|
1047
1106
|
description: "Maximum number of agentic turns before stopping. Omit for unlimited (default).",
|
|
1048
1107
|
minimum: 1,
|
|
1049
1108
|
}),
|
|
1050
1109
|
),
|
|
1051
|
-
run_in_background: Type.Optional(
|
|
1052
|
-
Type.Boolean({
|
|
1053
|
-
description: "Set to true to run in background. Returns agent ID immediately. You will be notified on completion.",
|
|
1054
|
-
}),
|
|
1055
|
-
),
|
|
1056
1110
|
resume: Type.Optional(
|
|
1057
1111
|
Type.String({
|
|
1058
1112
|
description: "Optional agent ID to resume from. Continues from previous context.",
|
|
@@ -1068,34 +1122,40 @@ Terse command-style prompts produce shallow, generic work.
|
|
|
1068
1122
|
description: "If true, fork parent conversation into the agent. Default: false (fresh context).",
|
|
1069
1123
|
}),
|
|
1070
1124
|
),
|
|
1071
|
-
|
|
1072
|
-
Type.Literal("worktree", {
|
|
1073
|
-
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.',
|
|
1074
|
-
}),
|
|
1075
|
-
),
|
|
1125
|
+
...isolationParam(isWorktreeIsolationEnabled()),
|
|
1076
1126
|
...scheduleParam,
|
|
1077
1127
|
}),
|
|
1078
1128
|
|
|
1079
1129
|
// ---- Custom rendering: Claude Code style ----
|
|
1080
1130
|
|
|
1081
|
-
renderCall(args, theme) {
|
|
1082
|
-
const displayName = args.subagent_type ? getDisplayName(args.subagent_type) : "Agent";
|
|
1083
|
-
const desc = args.description
|
|
1084
|
-
|
|
1131
|
+
renderCall(args, theme, context) {
|
|
1132
|
+
const displayName = typeof args.subagent_type === "string" ? toSingleLine(getDisplayName(args.subagent_type)) : "Agent";
|
|
1133
|
+
const desc = typeof args.description === "string" ? headerPreview(toSingleLine(args.description)) : "";
|
|
1134
|
+
const marker = context.expanded ? "▾" : "▸";
|
|
1135
|
+
let head = marker + " " + theme.fg("toolTitle", theme.bold(displayName));
|
|
1136
|
+
if (desc) head += " " + theme.fg("muted", desc);
|
|
1137
|
+
|
|
1138
|
+
if (context.expanded) {
|
|
1139
|
+
const raw = typeof (args as { prompt?: unknown })?.prompt === "string" ? (args as { prompt: string }).prompt : "";
|
|
1140
|
+
const body = instructionBody(raw, theme);
|
|
1141
|
+
if (body) return new Text(head + "\n" + body, 0, 0);
|
|
1142
|
+
}
|
|
1143
|
+
return new Text(head, 0, 0);
|
|
1085
1144
|
},
|
|
1086
1145
|
|
|
1087
|
-
renderResult(result, { expanded
|
|
1146
|
+
renderResult(result, { expanded }, theme, context) {
|
|
1088
1147
|
const details = result.details as AgentDetails | undefined;
|
|
1148
|
+
// Display copies only: `execute` hands the model the raw child text.
|
|
1089
1149
|
if (!details) {
|
|
1090
1150
|
const text = result.content[0]?.type === "text" ? result.content[0].text : "";
|
|
1091
|
-
return new Text(text, 0, 0);
|
|
1151
|
+
return new Text(typeof text === "string" ? stripControlChars(text) : "", 0, 0);
|
|
1092
1152
|
}
|
|
1093
1153
|
|
|
1094
1154
|
// Helper: build "haiku · thinking: high · ↻5≤30 · 3 tool uses · 33.8k tokens" stats string
|
|
1095
1155
|
const stats = (d: AgentDetails) => {
|
|
1096
1156
|
const parts: string[] = [];
|
|
1097
|
-
if (d.modelName) parts.push(d.modelName);
|
|
1098
|
-
if (d.tags) parts.push(...d.tags);
|
|
1157
|
+
if (d.modelName) parts.push(toSingleLine(d.modelName));
|
|
1158
|
+
if (d.tags) parts.push(...d.tags.map(t => toSingleLine(t)));
|
|
1099
1159
|
if (d.turnCount != null && d.turnCount > 0) {
|
|
1100
1160
|
parts.push(formatTurns(d.turnCount, d.maxTurns));
|
|
1101
1161
|
}
|
|
@@ -1104,59 +1164,22 @@ Terse command-style prompts produce shallow, generic work.
|
|
|
1104
1164
|
return parts.map(p => fgPreservingNestedStyles(theme, "dim", p)).join(" " + theme.fg("dim", "·") + " ");
|
|
1105
1165
|
};
|
|
1106
1166
|
|
|
1107
|
-
// ---- While running (streaming) ----
|
|
1108
|
-
if (isPartial || details.status === "running") {
|
|
1109
|
-
const frame = SPINNER[details.spinnerFrame ?? 0];
|
|
1110
|
-
const s = stats(details);
|
|
1111
|
-
return renderRunningAgentStatus(frame, s, details.activity ?? "thinking…", theme);
|
|
1112
|
-
}
|
|
1113
|
-
|
|
1114
1167
|
// ---- Background agent launched ----
|
|
1115
1168
|
if (details.status === "background") {
|
|
1116
|
-
|
|
1117
|
-
|
|
1118
|
-
|
|
1119
|
-
|
|
1120
|
-
if (details.status === "completed" || details.status === "steered") {
|
|
1121
|
-
const duration = formatMs(details.durationMs);
|
|
1122
|
-
const isSteered = details.status === "steered";
|
|
1123
|
-
const icon = isSteered ? theme.fg("warning", "✓") : theme.fg("success", "✓");
|
|
1124
|
-
const s = stats(details);
|
|
1125
|
-
let line = icon + (s ? " " + s : "");
|
|
1126
|
-
line += " " + theme.fg("dim", "·") + " " + theme.fg("dim", duration);
|
|
1127
|
-
|
|
1128
|
-
if (expanded) {
|
|
1129
|
-
const resultText = result.content[0]?.type === "text" ? result.content[0].text : "";
|
|
1130
|
-
if (resultText) {
|
|
1131
|
-
const lines = resultText.split("\n").slice(0, 50);
|
|
1132
|
-
for (const l of lines) {
|
|
1133
|
-
line += "\n" + theme.fg("dim", ` ${l}`);
|
|
1134
|
-
}
|
|
1135
|
-
if (resultText.split("\n").length > 50) {
|
|
1136
|
-
line += "\n" + theme.fg("muted", " ... (read the .output transcript file for full detail)");
|
|
1137
|
-
}
|
|
1138
|
-
}
|
|
1139
|
-
} else {
|
|
1140
|
-
const doneText = isSteered ? "Wrapped up (turn limit)" : "Done";
|
|
1141
|
-
line += "\n" + theme.fg("dim", ` ⎿ ${doneText}`);
|
|
1142
|
-
}
|
|
1143
|
-
return new Text(line, 0, 0);
|
|
1144
|
-
}
|
|
1145
|
-
|
|
1146
|
-
// ---- Stopped (user-initiated abort) ----
|
|
1147
|
-
if (details.status === "stopped") {
|
|
1148
|
-
const s = stats(details);
|
|
1149
|
-
let line = theme.fg("dim", "■") + (s ? " " + s : "");
|
|
1150
|
-
line += "\n" + theme.fg("dim", " ⎿ Stopped");
|
|
1151
|
-
return new Text(line, 0, 0);
|
|
1169
|
+
const hint = !expanded && typeof (context.args as { prompt?: unknown })?.prompt === "string"
|
|
1170
|
+
? instructionHint(theme)
|
|
1171
|
+
: "";
|
|
1172
|
+
return new Text(theme.fg("dim", ` ⎿ Running in background (ID: ${details.agentId})${hint}`), 0, 0);
|
|
1152
1173
|
}
|
|
1153
1174
|
|
|
1154
1175
|
// ---- Error / Aborted (hard max_turns) ----
|
|
1176
|
+
// Legacy fallthrough: a replayed pre-change row carrying a retired inline
|
|
1177
|
+
// status (running/completed/steered/stopped) lands here.
|
|
1155
1178
|
const s = stats(details);
|
|
1156
1179
|
let line = theme.fg("error", "✗") + (s ? " " + s : "");
|
|
1157
1180
|
|
|
1158
1181
|
if (details.status === "error") {
|
|
1159
|
-
line += "\n" + theme.fg("error", ` ⎿ Error: ${details.error ?? "unknown"}`);
|
|
1182
|
+
line += "\n" + theme.fg("error", ` ⎿ Error: ${toSingleLine(String(details.error ?? "")) || "unknown"}`);
|
|
1160
1183
|
} else {
|
|
1161
1184
|
line += "\n" + theme.fg("warning", " ⎿ Aborted (max turns exceeded)");
|
|
1162
1185
|
}
|
|
@@ -1166,7 +1189,7 @@ Terse command-style prompts produce shallow, generic work.
|
|
|
1166
1189
|
|
|
1167
1190
|
// ---- Execute ----
|
|
1168
1191
|
|
|
1169
|
-
execute: async (
|
|
1192
|
+
execute: async (_toolCallId, params, _signal, _onUpdate, ctx) => {
|
|
1170
1193
|
// Ensure we have UI context for widget rendering
|
|
1171
1194
|
widget.setUICtx(ctx.ui as UICtx);
|
|
1172
1195
|
|
|
@@ -1174,50 +1197,40 @@ Terse command-style prompts produce shallow, generic work.
|
|
|
1174
1197
|
reloadCustomAgents();
|
|
1175
1198
|
|
|
1176
1199
|
const rawType = params.subagent_type as SubagentType;
|
|
1177
|
-
|
|
1178
|
-
|
|
1179
|
-
|
|
1180
|
-
|
|
1181
|
-
|
|
1200
|
+
// Single decision point for dispatch (#183): unknown, disabled and
|
|
1201
|
+
// case-ambiguous types are refused here, BEFORE anything spawns, so a
|
|
1202
|
+
// background or scheduled call can't start running the wrong agent while
|
|
1203
|
+
// the caller is still unaware. `fallbackSubagent` decides whether an
|
|
1204
|
+
// unresolvable type falls back or fails closed.
|
|
1205
|
+
const dispatch = resolveSpawnType(rawType);
|
|
1206
|
+
// `resume` replays a stored session and ignores `subagent_type` entirely,
|
|
1207
|
+
// but the parameter is required by the schema — so gating it here would
|
|
1208
|
+
// make a live agent unresumable the moment its type is deleted, disabled,
|
|
1209
|
+
// or gains a case-clashing sibling. Only a real spawn is gated.
|
|
1210
|
+
if (!dispatch.ok && !params.resume) return textResult(dispatch.message);
|
|
1211
|
+
const subagentType = dispatch.ok ? dispatch.type : rawType;
|
|
1212
|
+
// What the caller actually asked for, named once: `fellBackFrom` is "" for
|
|
1213
|
+
// a blank request, so reading it inline invites the `??`-vs-`||` slip that
|
|
1214
|
+
// would persist an empty type into a scheduled job.
|
|
1215
|
+
const requestedType = (dispatch.ok && dispatch.fellBackFrom) || subagentType;
|
|
1216
|
+
// Computed at resolution rather than after the run, so the schedule and
|
|
1217
|
+
// background branches carry it too. Resume deliberately doesn't: it
|
|
1218
|
+
// replays the stored session and ignores `subagent_type` entirely, so a
|
|
1219
|
+
// note about type substitution would be describing something that didn't
|
|
1220
|
+
// happen.
|
|
1221
|
+
const fallbackNote = dispatch.ok && dispatch.fellBackFrom
|
|
1222
|
+
? `Note: Unknown agent type "${dispatch.fellBackFrom}" — using ${resolveType(subagentType) ? subagentType : "the fallback agent config"}.\n\n`
|
|
1223
|
+
: "";
|
|
1182
1224
|
|
|
1183
1225
|
// Get agent config (if any)
|
|
1184
1226
|
const customConfig = getAgentConfig(subagentType);
|
|
1185
1227
|
|
|
1186
|
-
const resolvedConfig = resolveAgentInvocationConfig(customConfig, params
|
|
1187
|
-
|
|
1188
|
-
|
|
1189
|
-
let model = ctx.model;
|
|
1190
|
-
if (resolvedConfig.modelInput) {
|
|
1191
|
-
const resolved = resolveModel(resolvedConfig.modelInput, ctx.modelRegistry);
|
|
1192
|
-
if (typeof resolved !== "string") {
|
|
1193
|
-
model = resolved;
|
|
1194
|
-
}
|
|
1195
|
-
// config-specified but unresolvable: silent fallback to parent (existing behavior)
|
|
1196
|
-
}
|
|
1197
|
-
|
|
1198
|
-
// Scope validation: the effective resolved model is checked against the
|
|
1199
|
-
// user's enabledModels list (read in `enabled-models.ts`).
|
|
1200
|
-
//
|
|
1201
|
-
// Design: scopeModels guards against frontmatter-pinned or parent-inherited
|
|
1202
|
-
// models drifting out of the user's allowlist. Both warn and proceed —
|
|
1203
|
-
// frontmatter is authoritative (the agent's author/installer chose it),
|
|
1204
|
-
// and the parent's model was chosen by the user when starting the session.
|
|
1205
|
-
// See SubagentsSettings.scopeModels docstring for the full policy.
|
|
1206
|
-
if (isScopeModelsEnabled() && model) {
|
|
1207
|
-
const allowed = resolveEnabledModels(readEnabledModels(ctx.cwd), ctx.modelRegistry, ctx.cwd);
|
|
1208
|
-
if (allowed && !isModelInScope(model, allowed)) {
|
|
1209
|
-
const agentLabel = customConfig?.displayName ?? subagentType;
|
|
1210
|
-
const modelLabel = resolvedConfig.modelInput ?? `${model.provider}/${model.id}`;
|
|
1211
|
-
ctx.ui.notify(
|
|
1212
|
-
`Agent "${agentLabel}" using out-of-scope model "${modelLabel}"`,
|
|
1213
|
-
"warning",
|
|
1214
|
-
);
|
|
1215
|
-
}
|
|
1216
|
-
}
|
|
1228
|
+
const resolvedConfig = resolveAgentInvocationConfig(customConfig, params, {
|
|
1229
|
+
worktreeAllowed: isWorktreeIsolationEnabled(),
|
|
1230
|
+
});
|
|
1217
1231
|
|
|
1218
1232
|
const thinking = resolvedConfig.thinking;
|
|
1219
1233
|
const inheritContext = resolvedConfig.inheritContext;
|
|
1220
|
-
const runInBackground = resolvedConfig.runInBackground;
|
|
1221
1234
|
const isolated = resolvedConfig.isolated;
|
|
1222
1235
|
const isolation = resolvedConfig.isolation;
|
|
1223
1236
|
// Whether this spawn writes its .output transcript. Per-agent
|
|
@@ -1232,35 +1245,54 @@ Terse command-style prompts produce shallow, generic work.
|
|
|
1232
1245
|
writeInitialEntry(rec.outputFile, agentId, params.prompt, ctx.cwd);
|
|
1233
1246
|
};
|
|
1234
1247
|
|
|
1235
|
-
const parentModelId = ctx.model?.id;
|
|
1236
|
-
const effectiveModelId = model?.id;
|
|
1237
|
-
const modelName = effectiveModelId && effectiveModelId !== parentModelId
|
|
1238
|
-
? (model?.name ?? effectiveModelId).replace(/^Claude\s+/i, "").toLowerCase()
|
|
1239
|
-
: undefined;
|
|
1240
1248
|
const effectiveMaxTurns = normalizeMaxTurns(resolvedConfig.maxTurns ?? getDefaultMaxTurns());
|
|
1241
|
-
|
|
1242
|
-
|
|
1243
|
-
|
|
1244
|
-
|
|
1245
|
-
|
|
1246
|
-
|
|
1247
|
-
|
|
1248
|
-
|
|
1249
|
-
|
|
1250
|
-
|
|
1251
|
-
|
|
1252
|
-
|
|
1253
|
-
|
|
1254
|
-
|
|
1255
|
-
|
|
1256
|
-
|
|
1257
|
-
|
|
1258
|
-
|
|
1259
|
-
|
|
1260
|
-
|
|
1261
|
-
|
|
1249
|
+
|
|
1250
|
+
// Resolve the call's model from agent frontmatter only (no LLM-authored
|
|
1251
|
+
// override). Shared by fresh spawns and scheduled registrations: a
|
|
1252
|
+
// schedule registers a job here and the pinned model resolves at fire
|
|
1253
|
+
// time, but the scope warning still belongs to registration. A resume
|
|
1254
|
+
// continues the record's session and model, so it does not warn, and its
|
|
1255
|
+
// row never uses the call-resolved model.
|
|
1256
|
+
let model = ctx.model;
|
|
1257
|
+
if (resolvedConfig.modelInput) {
|
|
1258
|
+
const resolved = resolveModel(resolvedConfig.modelInput, ctx.modelRegistry);
|
|
1259
|
+
if (typeof resolved !== "string") {
|
|
1260
|
+
model = resolved;
|
|
1261
|
+
}
|
|
1262
|
+
// config-specified but unresolvable: silent fallback to parent (existing behavior)
|
|
1263
|
+
}
|
|
1264
|
+
|
|
1265
|
+
// Scope validation: the effective resolved model is checked against the
|
|
1266
|
+
// user's enabledModels list. Policy lives in model-scope.ts so the nested
|
|
1267
|
+
// delegation tools apply the same allowlist to a nested spawn.
|
|
1268
|
+
//
|
|
1269
|
+
// Call this only where the model is accepted for use — after a schedule
|
|
1270
|
+
// job is registered, or once a spawn is admitted — so a refused call
|
|
1271
|
+
// never warns for work that never happens. The resume path never calls it.
|
|
1272
|
+
// A throwing notify is swallowed: the warning is advisory and cannot
|
|
1273
|
+
// fail the accepted call.
|
|
1274
|
+
const warnOnOutOfScopeModel = (): void => {
|
|
1275
|
+
const warning = modelScopeWarning({
|
|
1276
|
+
model,
|
|
1277
|
+
cwd: ctx.cwd,
|
|
1278
|
+
modelRegistry: ctx.modelRegistry,
|
|
1279
|
+
agentLabel: toSingleLine(customConfig?.displayName ?? subagentType),
|
|
1280
|
+
modelInput: resolvedConfig.modelInput,
|
|
1281
|
+
});
|
|
1282
|
+
if (!warning) return;
|
|
1283
|
+
try {
|
|
1284
|
+
ctx.ui.notify(warning, "warning");
|
|
1285
|
+
} catch { /* ignore notification errors */ }
|
|
1262
1286
|
};
|
|
1263
1287
|
|
|
1288
|
+
// Display name for the resolved model, captured in the fresh-spawn
|
|
1289
|
+
// invocation snapshot. Unconditional, not "only when it differs from the
|
|
1290
|
+
// parent": a thinking level reads as a property of a model, and an agent
|
|
1291
|
+
// that inherited the parent's model used to show the level with nothing to
|
|
1292
|
+
// attach it to. This is the pre-session snapshot — agent-manager overwrites
|
|
1293
|
+
// it with the effective values the moment a session reports them.
|
|
1294
|
+
const { modelName, modelId } = model ? describeModel(model) : { modelName: undefined, modelId: undefined };
|
|
1295
|
+
|
|
1264
1296
|
// ---- Schedule: register a job, don't spawn now ----
|
|
1265
1297
|
if (params.schedule) {
|
|
1266
1298
|
if (!isSchedulingEnabled()) {
|
|
@@ -1272,9 +1304,6 @@ Terse command-style prompts produce shallow, generic work.
|
|
|
1272
1304
|
if (params.inherit_context) {
|
|
1273
1305
|
return textResult("Cannot combine `schedule` with `inherit_context` — there is no parent conversation at fire time.");
|
|
1274
1306
|
}
|
|
1275
|
-
if (params.run_in_background === false) {
|
|
1276
|
-
return textResult("Cannot combine `schedule` with `run_in_background: false` — scheduled jobs always run in background.");
|
|
1277
|
-
}
|
|
1278
1307
|
if (!scheduler.isActive()) {
|
|
1279
1308
|
return textResult("Scheduler is not active in this session yet. Try again after the session has fully started.");
|
|
1280
1309
|
}
|
|
@@ -1283,16 +1312,19 @@ Terse command-style prompts produce shallow, generic work.
|
|
|
1283
1312
|
name: params.description as string,
|
|
1284
1313
|
description: params.description as string,
|
|
1285
1314
|
schedule: params.schedule as string,
|
|
1286
|
-
|
|
1315
|
+
// The caller's own name, not the substitute — the scheduler re-resolves
|
|
1316
|
+
// at fire time, and the original is what a user edits.
|
|
1317
|
+
subagent_type: requestedType,
|
|
1287
1318
|
prompt: params.prompt as string,
|
|
1288
1319
|
thinking: thinking,
|
|
1289
1320
|
max_turns: effectiveMaxTurns,
|
|
1290
1321
|
isolated: isolated,
|
|
1291
1322
|
isolation: isolation,
|
|
1292
1323
|
});
|
|
1324
|
+
warnOnOutOfScopeModel();
|
|
1293
1325
|
const next = scheduler.getNextRun(job.id);
|
|
1294
1326
|
return textResult(
|
|
1295
|
-
|
|
1327
|
+
`${fallbackNote}Scheduled "${job.name}" (id: ${job.id}, type: ${job.scheduleType}). ` +
|
|
1296
1328
|
`Next run: ${next ?? "(unknown)"}. ` +
|
|
1297
1329
|
`Manage via /agents → Scheduled jobs.`,
|
|
1298
1330
|
);
|
|
@@ -1301,32 +1333,103 @@ Terse command-style prompts produce shallow, generic work.
|
|
|
1301
1333
|
}
|
|
1302
1334
|
}
|
|
1303
1335
|
|
|
1304
|
-
// Resume existing agent
|
|
1336
|
+
// Resume existing agent — kick off a background resume and return an envelope.
|
|
1305
1337
|
if (params.resume) {
|
|
1306
1338
|
const existing = manager.getRecord(params.resume);
|
|
1307
|
-
|
|
1339
|
+
// Nested records are reachable only through their spawning agent's
|
|
1340
|
+
// scoped tools; the top-level tool treats them as absent.
|
|
1341
|
+
if (!existing || existing.parentAgentId) {
|
|
1308
1342
|
return textResult(`Agent not found: "${params.resume}". It may have been cleaned up.`);
|
|
1309
1343
|
}
|
|
1310
|
-
|
|
1311
|
-
|
|
1312
|
-
|
|
1313
|
-
|
|
1314
|
-
if (
|
|
1315
|
-
return textResult(`
|
|
1344
|
+
// Running (including the start prologue, where `settled` may still hold
|
|
1345
|
+
// the prior run's `true`), queued, or winding down — starting now would
|
|
1346
|
+
// race the previous run's settle handler. A queued record has no session
|
|
1347
|
+
// yet, so the active check precedes the session check.
|
|
1348
|
+
if (existing.status === "running" || existing.status === "queued" || existing.settled === false) {
|
|
1349
|
+
return textResult(`Agent "${params.resume}" is still active (running, queued, or winding down) — wait for it to finish before resuming.`);
|
|
1316
1350
|
}
|
|
1317
|
-
|
|
1318
|
-
|
|
1319
|
-
|
|
1320
|
-
|
|
1351
|
+
if (!existing.session) return textResult(`Agent "${params.resume}" has no active session to resume.`);
|
|
1352
|
+
const { state: resumeState, callbacks: resumeCallbacks } = createActivityTracker(existing.effectiveMaxTurns);
|
|
1353
|
+
const resumeStart = existing.session.messages.length;
|
|
1354
|
+
const record = manager.resume(params.resume, params.prompt, resumeCallbacks);
|
|
1355
|
+
if (!record) return textResult(`Failed to resume agent "${params.resume}".`);
|
|
1356
|
+
// Only once the resume is accepted — a refused resume must not drop the
|
|
1357
|
+
// prior run's still-pending completion nudge.
|
|
1358
|
+
cancelNudge(params.resume);
|
|
1359
|
+
// The session already exists, so onSessionCreated never fires for a resume;
|
|
1360
|
+
// seed it here so the widget/viewer can render the context-fill (NN%).
|
|
1361
|
+
resumeState.session = existing.session;
|
|
1362
|
+
agentActivity.set(record.id, resumeState);
|
|
1363
|
+
if (record.outputFile && record.session) {
|
|
1364
|
+
record.outputCleanup = streamToOutputFile(record.session, record.outputFile, record.id, ctx.cwd, resumeStart);
|
|
1321
1365
|
}
|
|
1366
|
+
widget.markRunning(record.id);
|
|
1367
|
+
widget.ensureTimer(); widget.update(); fleet.ensureTimer(); fleet.update();
|
|
1368
|
+
// The row's model fields come from the resumed record's resolved
|
|
1369
|
+
// invocation: a resume continues the session's own model, so deriving
|
|
1370
|
+
// them from the call's subagent_type would mix identities. Records
|
|
1371
|
+
// spawned outside the Agent tool (RPC, scheduler, wizard) capture no
|
|
1372
|
+
// invocation — the row then carries only record-owned values (the
|
|
1373
|
+
// effective max turns), never the call's model/strategy settings,
|
|
1374
|
+
// which this resume does not apply. The conversation viewer reads
|
|
1375
|
+
// `record.invocation` directly and renders nothing for those records;
|
|
1376
|
+
// the widget renders from the activity tracker and the record's
|
|
1377
|
+
// type/description.
|
|
1378
|
+
const displayInvocation: AgentInvocation = existing.invocation ?? {
|
|
1379
|
+
maxTurns: existing.effectiveMaxTurns,
|
|
1380
|
+
};
|
|
1381
|
+
const { modelName: resumeModelName, tags: resumeInvocationTags } = buildInvocationTags(displayInvocation);
|
|
1382
|
+
const resumeModeLabel = getPromptModeLabel(existing.type);
|
|
1383
|
+
const resumeTags = resumeModeLabel ? [resumeModeLabel, ...resumeInvocationTags] : resumeInvocationTags;
|
|
1322
1384
|
return textResult(
|
|
1323
|
-
record.
|
|
1324
|
-
|
|
1385
|
+
`Agent resumed in background.\nAgent ID: ${record.id}\nType: ${getDisplayName(existing.type)}\nDescription: ${record.description}\n` +
|
|
1386
|
+
(record.outputFile ? `Output file: ${record.outputFile}\n` : "") +
|
|
1387
|
+
`\nYou will be notified on subagent completion/failure.\n` +
|
|
1388
|
+
`Use get_subagent_result to retrieve full results, or steer_subagent to send it messages.\n` +
|
|
1389
|
+
`Do not duplicate this agent's work.`,
|
|
1390
|
+
{
|
|
1391
|
+
// Row identity mirrors the envelope text above: the record's type
|
|
1392
|
+
// and description, not the resume call's subagent_type/description.
|
|
1393
|
+
displayName: getDisplayName(existing.type),
|
|
1394
|
+
description: record.description,
|
|
1395
|
+
subagentType: existing.type,
|
|
1396
|
+
modelName: resumeModelName,
|
|
1397
|
+
tags: resumeTags.length > 0 ? resumeTags : undefined,
|
|
1398
|
+
toolUses: record.toolUses,
|
|
1399
|
+
tokens: "",
|
|
1400
|
+
durationMs: 0,
|
|
1401
|
+
status: "background" as const,
|
|
1402
|
+
agentId: record.id,
|
|
1403
|
+
},
|
|
1325
1404
|
);
|
|
1326
1405
|
}
|
|
1327
1406
|
|
|
1328
1407
|
// Background execution
|
|
1329
|
-
|
|
1408
|
+
{
|
|
1409
|
+
const displayName = getDisplayName(subagentType);
|
|
1410
|
+
const agentInvocation: AgentInvocation = {
|
|
1411
|
+
modelName,
|
|
1412
|
+
modelId,
|
|
1413
|
+
thinking,
|
|
1414
|
+
// Explicit value only — the default fallback would just add noise.
|
|
1415
|
+
// Normalize so `0` (unlimited) doesn't surface as a misleading "max turns: 0".
|
|
1416
|
+
maxTurns: normalizeMaxTurns(resolvedConfig.maxTurns),
|
|
1417
|
+
isolated,
|
|
1418
|
+
inheritContext,
|
|
1419
|
+
isolation,
|
|
1420
|
+
};
|
|
1421
|
+
// Tool-result render shows the mode label too; viewer's header already does.
|
|
1422
|
+
const modeLabel = getPromptModeLabel(subagentType);
|
|
1423
|
+
const { tags: invocationTags } = buildInvocationTags(agentInvocation);
|
|
1424
|
+
const agentTags = modeLabel ? [modeLabel, ...invocationTags] : invocationTags;
|
|
1425
|
+
const detailBase = {
|
|
1426
|
+
displayName,
|
|
1427
|
+
description: params.description,
|
|
1428
|
+
subagentType,
|
|
1429
|
+
modelName,
|
|
1430
|
+
tags: agentTags.length > 0 ? agentTags : undefined,
|
|
1431
|
+
};
|
|
1432
|
+
|
|
1330
1433
|
const { state: bgState, callbacks: bgCallbacks } = createActivityTracker(effectiveMaxTurns);
|
|
1331
1434
|
|
|
1332
1435
|
// Wrap onSessionCreated to wire output file streaming.
|
|
@@ -1350,192 +1453,68 @@ Terse command-style prompts produce shallow, generic work.
|
|
|
1350
1453
|
isolated,
|
|
1351
1454
|
inheritContext,
|
|
1352
1455
|
thinkingLevel: thinking,
|
|
1353
|
-
isBackground: true,
|
|
1354
1456
|
isolation,
|
|
1355
1457
|
invocation: agentInvocation,
|
|
1458
|
+
rootSessionId: ctx.sessionManager.getSessionId(),
|
|
1356
1459
|
...bgCallbacks,
|
|
1357
1460
|
});
|
|
1358
1461
|
} catch (err) {
|
|
1359
1462
|
return textResult(err instanceof Error ? err.message : String(err));
|
|
1360
1463
|
}
|
|
1464
|
+
warnOnOutOfScopeModel();
|
|
1361
1465
|
|
|
1362
|
-
// Set output file
|
|
1363
|
-
//
|
|
1364
|
-
const joinMode = resolveJoinMode(defaultJoinMode, true);
|
|
1466
|
+
// Set the output file synchronously after spawn, before the event loop
|
|
1467
|
+
// yields — onSessionCreated is async so this is safe.
|
|
1365
1468
|
const record = manager.getRecord(id);
|
|
1366
|
-
if (record
|
|
1367
|
-
|
|
1368
|
-
|
|
1369
|
-
|
|
1469
|
+
if (record) attachTranscript(record, id);
|
|
1470
|
+
|
|
1471
|
+
// A spawned record can settle before spawn returns: a `subagents:started`
|
|
1472
|
+
// listener stopping it, or an already-aborted parent signal. The
|
|
1473
|
+
// completion callback already removed its activity entry, so re-adding
|
|
1474
|
+
// one would render a `↻1` finished row for a run that never executed a
|
|
1475
|
+
// turn, and the widget timers have no live run to update. The stop
|
|
1476
|
+
// notification remains the delivery for these runs.
|
|
1477
|
+
if (record && record.settled !== true) {
|
|
1478
|
+
agentActivity.set(id, bgState);
|
|
1479
|
+
widget.ensureTimer();
|
|
1480
|
+
widget.update();
|
|
1481
|
+
fleet.ensureTimer();
|
|
1482
|
+
fleet.update();
|
|
1370
1483
|
}
|
|
1371
1484
|
|
|
1372
|
-
if (joinMode == null || joinMode === 'async') {
|
|
1373
|
-
// Foreground/no join mode or explicit async — not part of any batch
|
|
1374
|
-
} else {
|
|
1375
|
-
// smart or group — add to current batch
|
|
1376
|
-
currentBatchAgents.push({ id, joinMode });
|
|
1377
|
-
// Debounce: reset timer on each new agent so parallel tool calls
|
|
1378
|
-
// dispatched across multiple event loop ticks are captured together
|
|
1379
|
-
if (batchFinalizeTimer) clearTimeout(batchFinalizeTimer);
|
|
1380
|
-
batchFinalizeTimer = setTimeout(finalizeBatch, 100);
|
|
1381
|
-
}
|
|
1382
|
-
|
|
1383
|
-
agentActivity.set(id, bgState);
|
|
1384
|
-
widget.ensureTimer();
|
|
1385
|
-
widget.update();
|
|
1386
|
-
fleet.ensureTimer();
|
|
1387
|
-
fleet.update();
|
|
1388
|
-
|
|
1389
1485
|
// Emit created event
|
|
1390
1486
|
pi.events.emit("subagents:created", {
|
|
1391
1487
|
id,
|
|
1392
1488
|
type: subagentType,
|
|
1393
1489
|
description: params.description,
|
|
1394
|
-
isBackground: true,
|
|
1395
1490
|
});
|
|
1396
1491
|
|
|
1397
1492
|
const isQueued = record?.status === "queued";
|
|
1398
1493
|
return textResult(
|
|
1399
|
-
|
|
1494
|
+
`${fallbackNote}Agent ${isQueued ? "queued" : "started"} in background.\n` +
|
|
1400
1495
|
`Agent ID: ${id}\n` +
|
|
1401
1496
|
`Type: ${displayName}\n` +
|
|
1402
1497
|
`Description: ${params.description}\n` +
|
|
1403
1498
|
(record?.outputFile ? `Output file: ${record.outputFile}\n` : "") +
|
|
1404
1499
|
(isQueued ? `Position: queued (max ${manager.getMaxConcurrent()} concurrent)\n` : "") +
|
|
1405
|
-
`\nYou will be notified
|
|
1500
|
+
`\nYou will be notified on subagent completion/failure.\n` +
|
|
1406
1501
|
`Use get_subagent_result to retrieve full results, or steer_subagent to send it messages.\n` +
|
|
1407
1502
|
`Do not duplicate this agent's work.`,
|
|
1408
1503
|
{ ...detailBase, toolUses: 0, tokens: "", durationMs: 0, status: "background" as const, agentId: id },
|
|
1409
1504
|
);
|
|
1410
1505
|
}
|
|
1411
1506
|
|
|
1412
|
-
// Foreground (synchronous) execution — stream progress via onUpdate
|
|
1413
|
-
let spinnerFrame = 0;
|
|
1414
|
-
const startedAt = Date.now();
|
|
1415
|
-
let fgId: string | undefined;
|
|
1416
|
-
|
|
1417
|
-
const streamUpdate = () => {
|
|
1418
|
-
const details: AgentDetails = {
|
|
1419
|
-
...detailBase,
|
|
1420
|
-
toolUses: fgState.toolUses,
|
|
1421
|
-
tokens: formatLifetimeTokens(fgState),
|
|
1422
|
-
turnCount: fgState.turnCount,
|
|
1423
|
-
maxTurns: fgState.maxTurns,
|
|
1424
|
-
durationMs: Date.now() - startedAt,
|
|
1425
|
-
status: "running",
|
|
1426
|
-
activity: describeActivity(fgState.activeTools, fgState.responseText),
|
|
1427
|
-
spinnerFrame: spinnerFrame % SPINNER.length,
|
|
1428
|
-
};
|
|
1429
|
-
onUpdate?.({
|
|
1430
|
-
content: [{ type: "text", text: `${fgState.toolUses} tool uses...` }],
|
|
1431
|
-
details: details as any,
|
|
1432
|
-
});
|
|
1433
|
-
};
|
|
1434
|
-
|
|
1435
|
-
const { state: fgState, callbacks: fgCallbacks } = createActivityTracker(effectiveMaxTurns, streamUpdate);
|
|
1436
|
-
|
|
1437
|
-
// Wire session creation: register in widget + stream to output file.
|
|
1438
|
-
// The output file path is set synchronously after spawn (below),
|
|
1439
|
-
// before onSessionCreated fires — same pattern as background agents.
|
|
1440
|
-
const origOnSession = fgCallbacks.onSessionCreated;
|
|
1441
|
-
fgCallbacks.onSessionCreated = (session: any) => {
|
|
1442
|
-
origOnSession(session);
|
|
1443
|
-
for (const a of manager.listAgents()) {
|
|
1444
|
-
if (a.session === session) {
|
|
1445
|
-
fgId = a.id;
|
|
1446
|
-
agentActivity.set(a.id, fgState);
|
|
1447
|
-
widget.ensureTimer();
|
|
1448
|
-
fleet.ensureTimer();
|
|
1449
|
-
fleet.update();
|
|
1450
|
-
break;
|
|
1451
|
-
}
|
|
1452
|
-
}
|
|
1453
|
-
// Stream conversation to output file (foreground agent logging)
|
|
1454
|
-
if (fgId) {
|
|
1455
|
-
const rec = manager.getRecord(fgId);
|
|
1456
|
-
if (rec?.outputFile) {
|
|
1457
|
-
rec.outputCleanup = streamToOutputFile(session, rec.outputFile, fgId, ctx.cwd);
|
|
1458
|
-
}
|
|
1459
|
-
}
|
|
1460
|
-
};
|
|
1461
|
-
|
|
1462
|
-
// Animate spinner at ~80ms (smooth rotation through 10 braille frames)
|
|
1463
|
-
const spinnerInterval = setInterval(() => {
|
|
1464
|
-
spinnerFrame++;
|
|
1465
|
-
streamUpdate();
|
|
1466
|
-
}, 80);
|
|
1467
|
-
|
|
1468
|
-
streamUpdate();
|
|
1469
|
-
|
|
1470
|
-
let record: AgentRecord;
|
|
1471
|
-
try {
|
|
1472
|
-
const fgResult = await manager.spawnAndWait(pi, ctx, subagentType, params.prompt, {
|
|
1473
|
-
description: params.description,
|
|
1474
|
-
model,
|
|
1475
|
-
maxTurns: effectiveMaxTurns,
|
|
1476
|
-
isolated,
|
|
1477
|
-
inheritContext,
|
|
1478
|
-
thinkingLevel: thinking,
|
|
1479
|
-
isolation,
|
|
1480
|
-
invocation: agentInvocation,
|
|
1481
|
-
signal,
|
|
1482
|
-
...fgCallbacks,
|
|
1483
|
-
}, (fgAgentId) => {
|
|
1484
|
-
// onSpawned: called synchronously after spawn, before onSessionCreated fires.
|
|
1485
|
-
// Set up the output file so streamToOutputFile can pick it up.
|
|
1486
|
-
const fgRec = manager.getRecord(fgAgentId);
|
|
1487
|
-
attachTranscript(fgRec, fgAgentId);
|
|
1488
|
-
});
|
|
1489
|
-
record = fgResult.record;
|
|
1490
|
-
} catch (err) {
|
|
1491
|
-
clearInterval(spinnerInterval);
|
|
1492
|
-
return textResult(err instanceof Error ? err.message : String(err));
|
|
1493
|
-
}
|
|
1494
|
-
|
|
1495
|
-
clearInterval(spinnerInterval);
|
|
1496
|
-
|
|
1497
|
-
// Clean up foreground agent from widget
|
|
1498
|
-
if (fgId) {
|
|
1499
|
-
agentActivity.delete(fgId);
|
|
1500
|
-
widget.markFinished(fgId);
|
|
1501
|
-
fleet.onAgentFinished(fgId);
|
|
1502
|
-
}
|
|
1503
|
-
|
|
1504
|
-
// Get final token count
|
|
1505
|
-
const tokenText = formatLifetimeTokens(fgState);
|
|
1506
|
-
|
|
1507
|
-
const details = buildDetails(detailBase, record, fgState, { tokens: tokenText });
|
|
1508
|
-
|
|
1509
|
-
// "general-purpose" may itself be unregistered (defaults disabled, no
|
|
1510
|
-
// user override) — getConfig then uses the hardcoded fallback config.
|
|
1511
|
-
const fallbackNote = fellBack
|
|
1512
|
-
? `Note: Unknown agent type "${rawType}" — using ${resolveType("general-purpose") ? "general-purpose" : "the fallback agent config"}.\n\n`
|
|
1513
|
-
: "";
|
|
1514
|
-
|
|
1515
|
-
if (record.status === "error") {
|
|
1516
|
-
// Error headline + any partial output the run produced before failing.
|
|
1517
|
-
return textResult(`${fallbackNote}Agent failed: ${record.error}${partialOutputSuffix(record)}`, details);
|
|
1518
|
-
}
|
|
1519
|
-
|
|
1520
|
-
const durationMs = (record.completedAt ?? Date.now()) - record.startedAt;
|
|
1521
|
-
const statsParts = [`${record.toolUses} tool uses`];
|
|
1522
|
-
if (tokenText) statsParts.push(tokenText);
|
|
1523
|
-
return textResult(
|
|
1524
|
-
`${fallbackNote}Agent completed in ${formatMs(durationMs)} (${statsParts.join(", ")})${getStatusNote(record.status)}.\n\n` +
|
|
1525
|
-
(record.result?.trim() || "No output."),
|
|
1526
|
-
details,
|
|
1527
|
-
);
|
|
1528
1507
|
},
|
|
1529
1508
|
}));
|
|
1530
1509
|
|
|
1531
1510
|
// ---- get_subagent_result tool ----
|
|
1532
1511
|
|
|
1533
|
-
|
|
1512
|
+
registerParentTool(defineTool({
|
|
1534
1513
|
name: SUBAGENT_TOOL_NAMES.GET_RESULT,
|
|
1535
1514
|
label: "Get Agent Result",
|
|
1536
1515
|
description:
|
|
1537
|
-
"Check status and retrieve
|
|
1538
|
-
promptSnippet: "Check status and retrieve
|
|
1516
|
+
"Check a subagent's status and retrieve its result. Use the agent ID returned by the Agent tool.",
|
|
1517
|
+
promptSnippet: "Check a subagent's status and retrieve its result",
|
|
1539
1518
|
parameters: Type.Object({
|
|
1540
1519
|
agent_id: Type.String({
|
|
1541
1520
|
description: "The agent ID to check.",
|
|
@@ -1543,201 +1522,124 @@ Terse command-style prompts produce shallow, generic work.
|
|
|
1543
1522
|
}),
|
|
1544
1523
|
execute: async (_toolCallId, params, _signal, _onUpdate, _ctx) => {
|
|
1545
1524
|
const record = manager.getRecord(params.agent_id);
|
|
1546
|
-
|
|
1525
|
+
// Nested records belong to their spawning agent's scoped tools only.
|
|
1526
|
+
if (!record || record.parentAgentId) {
|
|
1547
1527
|
return textResult(`Agent not found: "${params.agent_id}". It may have been cleaned up.`);
|
|
1548
1528
|
}
|
|
1549
1529
|
|
|
1550
|
-
//
|
|
1551
|
-
|
|
1552
|
-
|
|
1553
|
-
|
|
1554
|
-
|
|
1555
|
-
|
|
1556
|
-
const transcriptPath =
|
|
1557
|
-
record.outputFile && existsSync(record.outputFile) ? record.outputFile : undefined;
|
|
1558
|
-
|
|
1559
|
-
if (record.status === "running") {
|
|
1560
|
-
return textResult(renderRunning(record, checkpointsPath, transcriptPath));
|
|
1530
|
+
// Mark result as consumed — suppresses the completion notification. Safe
|
|
1531
|
+
// for every status: only a settled record has a nudge parked, and a
|
|
1532
|
+
// running/queued one has none to cancel.
|
|
1533
|
+
if (record.status !== "running" && record.status !== "queued") {
|
|
1534
|
+
record.resultConsumed = true;
|
|
1535
|
+
cancelNudge(params.agent_id);
|
|
1561
1536
|
}
|
|
1562
1537
|
|
|
1563
|
-
|
|
1564
|
-
// file paths and no result body, mirroring the `running` early return.
|
|
1565
|
-
if (record.status === "queued") {
|
|
1566
|
-
return textResult(renderQueued(record));
|
|
1567
|
-
}
|
|
1568
|
-
|
|
1569
|
-
// Mark result as consumed — suppresses the completion notification.
|
|
1570
|
-
// Queued agents return above; this block only runs for terminal statuses.
|
|
1571
|
-
// Note: for group-batched agents the nudge is keyed `group:<ids>`, so
|
|
1572
|
-
// cancelNudge(agent_id) is a no-op there — the group send closure's
|
|
1573
|
-
// resultConsumed re-check is what filters this agent out instead.
|
|
1574
|
-
record.resultConsumed = true;
|
|
1575
|
-
cancelNudge(params.agent_id);
|
|
1576
|
-
|
|
1577
|
-
// Attach notification details so renderResult can render the report as
|
|
1578
|
-
// markdown, mirroring the completion notification the parent just
|
|
1579
|
-
// suppressed by consuming synchronously.
|
|
1580
|
-
const details = buildNotificationDetails(record, { failurePreviewMaxChars }, agentActivity.get(record.id));
|
|
1581
|
-
|
|
1582
|
-
if (record.status === "error") {
|
|
1583
|
-
return textResult(renderTerminal(record, checkpointsPath, transcriptPath, true), details);
|
|
1584
|
-
}
|
|
1585
|
-
return textResult(renderTerminal(record, checkpointsPath, transcriptPath, false), details);
|
|
1538
|
+
return textResult(renderAgentReport(record));
|
|
1586
1539
|
},
|
|
1587
1540
|
|
|
1588
|
-
renderResult(result,
|
|
1589
|
-
const
|
|
1590
|
-
|
|
1591
|
-
|
|
1592
|
-
|
|
1593
|
-
|
|
1594
|
-
|
|
1595
|
-
|
|
1596
|
-
container.addChild(subagentNotificationRenderHeader(details, theme));
|
|
1597
|
-
container.addChild(subagentNotificationRenderBody(details, effectiveExpanded, resultPreviewMode, theme));
|
|
1598
|
-
return container;
|
|
1541
|
+
renderResult(result, _options, theme) {
|
|
1542
|
+
const report = result.content[0]?.type === "text" ? result.content[0].text : "";
|
|
1543
|
+
// Display copy only: the tool text returned to the parent model keeps its
|
|
1544
|
+
// raw result/error bodies; the builders collapse its single-line metadata.
|
|
1545
|
+
const display = typeof report === "string" ? stripControlChars(report) : "";
|
|
1546
|
+
return display
|
|
1547
|
+
? new Markdown(display, 0, 0, getMarkdownTheme(), { color: (t) => theme.fg("toolOutput", t) })
|
|
1548
|
+
: new Text("", 0, 0);
|
|
1599
1549
|
},
|
|
1600
1550
|
}));
|
|
1601
1551
|
|
|
1602
|
-
/** Queued subagent: no file paths, result body, or footer — nothing produced yet. */
|
|
1603
|
-
function renderQueued(record: AgentRecord): string {
|
|
1604
|
-
const displayName = getDisplayName(record.type);
|
|
1605
|
-
return (
|
|
1606
|
-
`Agent: ${record.id} (queued — not started yet)\n` +
|
|
1607
|
-
`Type: ${displayName} | Description: ${record.description}\n\n` +
|
|
1608
|
-
`This agent is waiting to start. It will begin running when a concurrent-agent slot frees up.`
|
|
1609
|
-
);
|
|
1610
|
-
}
|
|
1611
|
-
|
|
1612
|
-
/** Running subagent: header (turn/elapsed), latest checkpoint, file paths, polling-discouragement footer. */
|
|
1613
|
-
function renderRunning(
|
|
1614
|
-
record: AgentRecord,
|
|
1615
|
-
checkpointsPath: string | undefined,
|
|
1616
|
-
transcriptPath: string | undefined,
|
|
1617
|
-
): string {
|
|
1618
|
-
const displayName = getDisplayName(record.type);
|
|
1619
|
-
const turn = record.turnCount ?? 0;
|
|
1620
|
-
const maxTurns = record.effectiveMaxTurns;
|
|
1621
|
-
const elapsedSeconds = Math.round((Date.now() - record.startedAt) / 1000);
|
|
1622
|
-
const turnLabel = maxTurns != null ? `turn ${turn}/${maxTurns}` : `turn ${turn}`;
|
|
1623
|
-
|
|
1624
|
-
let output =
|
|
1625
|
-
`Agent: ${record.id} (still running — ${turnLabel}, ${elapsedSeconds}s elapsed)\n` +
|
|
1626
|
-
`Type: ${displayName} | Description: ${record.description}\n\n`;
|
|
1627
|
-
|
|
1628
|
-
if (record.lastCheckpoint) {
|
|
1629
|
-
output +=
|
|
1630
|
-
`Latest checkpoint (turn ${record.lastCheckpoint.turn}):\n` +
|
|
1631
|
-
` ${record.lastCheckpoint.summary}\n\n`;
|
|
1632
|
-
output += fileSection(checkpointsPath, transcriptPath, footerForPaths(checkpointsPath, transcriptPath, true));
|
|
1633
|
-
} else {
|
|
1634
|
-
output += `No checkpoint yet — the subagent hasn't called the checkpoint tool.\n\n`;
|
|
1635
|
-
output += fileSection(checkpointsPath, transcriptPath,
|
|
1636
|
-
" grep or read the transcript for detail on what it's doing. Do not poll repeatedly.");
|
|
1637
|
-
}
|
|
1638
|
-
return output;
|
|
1639
|
-
}
|
|
1640
|
-
|
|
1641
|
-
/** Completed/errored subagent: completed header, result or error block, latest checkpoint, file paths. */
|
|
1642
|
-
function renderTerminal(
|
|
1643
|
-
record: AgentRecord,
|
|
1644
|
-
checkpointsPath: string | undefined,
|
|
1645
|
-
transcriptPath: string | undefined,
|
|
1646
|
-
errored: boolean,
|
|
1647
|
-
): string {
|
|
1648
|
-
const header = completedHeader(record);
|
|
1649
|
-
const body = errored
|
|
1650
|
-
? `Error: ${record.error}${partialOutputSuffix(record)}`
|
|
1651
|
-
: record.result?.trim() || "No output.";
|
|
1652
|
-
|
|
1653
|
-
let output = `${header}\n\n${body}`;
|
|
1654
|
-
if (record.lastCheckpoint) {
|
|
1655
|
-
output +=
|
|
1656
|
-
`\n\nLatest checkpoint (turn ${record.lastCheckpoint.turn}):\n` +
|
|
1657
|
-
` ${record.lastCheckpoint.summary}`;
|
|
1658
|
-
}
|
|
1659
|
-
output += "\n\n" + fileSection(checkpointsPath, transcriptPath, footerForPaths(checkpointsPath, transcriptPath, false));
|
|
1660
|
-
return output;
|
|
1661
|
-
}
|
|
1662
|
-
|
|
1663
|
-
/** Shared header for completed/errored shapes (Status + stats + status note). */
|
|
1664
|
-
function completedHeader(record: AgentRecord): string {
|
|
1665
|
-
const displayName = getDisplayName(record.type);
|
|
1666
|
-
const duration = formatDuration(record.startedAt, record.completedAt);
|
|
1667
|
-
const tokens = formatLifetimeTokens(record);
|
|
1668
|
-
const contextPercent = getSessionContextPercent(record.session);
|
|
1669
|
-
const statsParts = [`Tool uses: ${record.toolUses}`];
|
|
1670
|
-
if (tokens) statsParts.push(tokens);
|
|
1671
|
-
if (contextPercent !== null) statsParts.push(`Context: ${Math.round(contextPercent)}%`);
|
|
1672
|
-
if (record.compactionCount) statsParts.push(`Compactions: ${record.compactionCount}`);
|
|
1673
|
-
statsParts.push(`Duration: ${duration}`);
|
|
1674
|
-
return (
|
|
1675
|
-
`Agent: ${record.id}\n` +
|
|
1676
|
-
`Type: ${displayName} | Status: ${record.status}${getStatusNote(record.status)} | ${statsParts.join(" | ")}\n` +
|
|
1677
|
-
`Description: ${record.description}`
|
|
1678
|
-
);
|
|
1679
|
-
}
|
|
1680
|
-
|
|
1681
|
-
/** The checkpoint-history / full-transcript path block, aligned to a shared column, plus the footer. */
|
|
1682
|
-
function fileSection(
|
|
1683
|
-
checkpointsPath: string | undefined,
|
|
1684
|
-
transcriptPath: string | undefined,
|
|
1685
|
-
footer: string,
|
|
1686
|
-
): string {
|
|
1687
|
-
let s = "";
|
|
1688
|
-
if (checkpointsPath) s += `Checkpoint history: ${checkpointsPath}\n`;
|
|
1689
|
-
if (transcriptPath) s += `Full transcript: ${transcriptPath}\n`;
|
|
1690
|
-
s += footer;
|
|
1691
|
-
return s;
|
|
1692
|
-
}
|
|
1693
|
-
|
|
1694
|
-
/** Footer matching the paths actually rendered (existsSync can suppress either independently). */
|
|
1695
|
-
function footerForPaths(
|
|
1696
|
-
checkpointsPath: string | undefined,
|
|
1697
|
-
transcriptPath: string | undefined,
|
|
1698
|
-
running: boolean,
|
|
1699
|
-
): string {
|
|
1700
|
-
const suffix = running ? " Do not poll repeatedly." : "";
|
|
1701
|
-
if (checkpointsPath && transcriptPath) {
|
|
1702
|
-
return ` grep or read the checkpoints / transcript for more detail.${suffix}`;
|
|
1703
|
-
}
|
|
1704
|
-
if (checkpointsPath) {
|
|
1705
|
-
return ` grep or read the checkpoints file for more detail.${suffix}`;
|
|
1706
|
-
}
|
|
1707
|
-
return ` grep or read the transcript for more detail.${suffix}`;
|
|
1708
|
-
}
|
|
1709
|
-
|
|
1710
1552
|
// ---- steer_subagent tool ----
|
|
1711
1553
|
|
|
1712
|
-
|
|
1554
|
+
type SteerOutcome = "sent" | "queued" | "not-found" | "not-running" | "failed";
|
|
1555
|
+
const steerResult = (msg: string, steerOutcome: SteerOutcome) => textResult(msg, { steerOutcome });
|
|
1556
|
+
|
|
1557
|
+
registerParentTool(defineTool({
|
|
1713
1558
|
name: SUBAGENT_TOOL_NAMES.STEER,
|
|
1714
1559
|
label: "Steer Agent",
|
|
1715
1560
|
description:
|
|
1716
|
-
"Send a steering message to a
|
|
1717
|
-
"and be injected into its conversation, allowing you to redirect its work mid-run.
|
|
1718
|
-
promptSnippet: "Send a steering message to redirect a
|
|
1561
|
+
"Send a steering message to a subagent. The message will interrupt the agent after its current tool execution " +
|
|
1562
|
+
"and be injected into its conversation, allowing you to redirect its work mid-run.",
|
|
1563
|
+
promptSnippet: "Send a steering message to redirect a subagent",
|
|
1719
1564
|
parameters: Type.Object({
|
|
1720
1565
|
agent_id: Type.String({
|
|
1721
|
-
description: "The agent ID to steer (must be
|
|
1566
|
+
description: "The agent ID to steer (must be running or queued).",
|
|
1722
1567
|
}),
|
|
1723
1568
|
message: Type.String({
|
|
1724
1569
|
description: "The steering message to send. This will appear as a user message in the agent's conversation.",
|
|
1725
1570
|
}),
|
|
1726
1571
|
}),
|
|
1572
|
+
|
|
1573
|
+
renderCall(args, theme, context) {
|
|
1574
|
+
const a = (args ?? {}) as { agent_id?: unknown; message?: unknown };
|
|
1575
|
+
const agentId = typeof a.agent_id === "string" ? a.agent_id : "";
|
|
1576
|
+
const raw = typeof a.message === "string" ? a.message : "";
|
|
1577
|
+
const state = context.state as { steerDesc?: string };
|
|
1578
|
+
|
|
1579
|
+
// Freeze the first resolved description so record eviction cannot flip the row.
|
|
1580
|
+
if (!state.steerDesc && agentId) {
|
|
1581
|
+
const desc = toSingleLine(manager.getRecord(agentId)?.description);
|
|
1582
|
+
if (desc) state.steerDesc = desc;
|
|
1583
|
+
}
|
|
1584
|
+
// Both header parts are display copies: sanitize + cap.
|
|
1585
|
+
const target = headerPreview(state.steerDesc ?? "") || headerPreview(toSingleLine(agentId));
|
|
1586
|
+
|
|
1587
|
+
const marker = context.expanded ? "▾" : "▸";
|
|
1588
|
+
let head = marker + " " + theme.fg("toolTitle", theme.bold("Steer"));
|
|
1589
|
+
if (target) head += " " + theme.fg("muted", target);
|
|
1590
|
+
|
|
1591
|
+
// Expanded: the result renderer owns the body once a result exists; while the
|
|
1592
|
+
// call is still pending (no result applied, e.g. replay after an abort) the
|
|
1593
|
+
// call shows it so the instruction is never invisible. The body is
|
|
1594
|
+
// built only when it can be shown, so collapsed renders stay O(preview window).
|
|
1595
|
+
if (context.expanded) {
|
|
1596
|
+
const body = context.isPartial ? instructionBody(raw, theme) : "";
|
|
1597
|
+
return new Text(body ? head + "\n" + body : head, 0, 0);
|
|
1598
|
+
}
|
|
1599
|
+
|
|
1600
|
+
const plain = toSingleLine(safeTruncate(raw, INSTRUCTION_PREVIEW_SCAN_CHARS));
|
|
1601
|
+
const shown = truncateToWidth(plain, INSTRUCTION_PREVIEW_COLS, "…");
|
|
1602
|
+
// A blind 512-unit cut hides content without changing `plain`.
|
|
1603
|
+
const hidden = shown !== plain || raw.length > INSTRUCTION_PREVIEW_SCAN_CHARS || /[\n\r\t]/.test(raw);
|
|
1604
|
+
if (!shown) return new Text(head + (hidden ? instructionHint(theme) : ""), 0, 0);
|
|
1605
|
+
const hint = hidden ? instructionHint(theme) : "";
|
|
1606
|
+
return new Text(head + " " + theme.fg("muted", shown) + hint, 0, 0);
|
|
1607
|
+
},
|
|
1608
|
+
|
|
1609
|
+
renderResult(result, { expanded }, theme, context) {
|
|
1610
|
+
const outcome = (result.details as { steerOutcome?: SteerOutcome } | undefined)?.steerOutcome;
|
|
1611
|
+
const failure = outcome === "not-found" || outcome === "not-running" || outcome === "failed";
|
|
1612
|
+
// A synthetic abort/error result carries no details.
|
|
1613
|
+
const color = context.isError || failure ? "error" : "dim";
|
|
1614
|
+
const text = result.content[0]?.type === "text" ? result.content[0].text : "";
|
|
1615
|
+
const cleaned = typeof text === "string" ? stripControlChars(text).trimEnd() : "";
|
|
1616
|
+
const status = cleaned.trim()
|
|
1617
|
+
? cleaned.split("\n").map((l) => theme.fg(color, ` ⎿ ${l}`)).join("\n")
|
|
1618
|
+
: "";
|
|
1619
|
+
const raw = typeof (context.args as { message?: unknown })?.message === "string"
|
|
1620
|
+
? (context.args as { message: string }).message
|
|
1621
|
+
: "";
|
|
1622
|
+
const body = instructionBody(raw, theme);
|
|
1623
|
+
if (expanded && body) {
|
|
1624
|
+
return new Text(body + (status ? "\n" + status : ""), 0, 0);
|
|
1625
|
+
}
|
|
1626
|
+
return new Text(status, 0, 0);
|
|
1627
|
+
},
|
|
1628
|
+
|
|
1727
1629
|
execute: async (_toolCallId, params, _signal, _onUpdate, _ctx) => {
|
|
1728
1630
|
const record = manager.getRecord(params.agent_id);
|
|
1729
|
-
if (!record) {
|
|
1730
|
-
return
|
|
1631
|
+
if (!record || record.parentAgentId) {
|
|
1632
|
+
return steerResult(`Agent not found: "${params.agent_id}". It may have been cleaned up.`, "not-found");
|
|
1731
1633
|
}
|
|
1732
|
-
if (record.status !== "running") {
|
|
1733
|
-
return
|
|
1634
|
+
if (record.status !== "running" && record.status !== "queued") {
|
|
1635
|
+
return steerResult(`Agent "${params.agent_id}" is not running (status: ${record.status}). Cannot steer a non-running agent.`, "not-running");
|
|
1734
1636
|
}
|
|
1735
1637
|
if (!record.session) {
|
|
1736
1638
|
// Session not ready yet — queue the steer for delivery once initialized
|
|
1737
1639
|
if (!record.pendingSteers) record.pendingSteers = [];
|
|
1738
1640
|
record.pendingSteers.push(params.message);
|
|
1739
1641
|
pi.events.emit("subagents:steered", { id: record.id, message: params.message });
|
|
1740
|
-
return
|
|
1642
|
+
return steerResult(`Steering message queued for agent ${record.id}. It will be delivered once the session initializes.`, "queued");
|
|
1741
1643
|
}
|
|
1742
1644
|
|
|
1743
1645
|
try {
|
|
@@ -1750,12 +1652,13 @@ Terse command-style prompts produce shallow, generic work.
|
|
|
1750
1652
|
stateParts.push(`${record.toolUses} tool ${record.toolUses === 1 ? "use" : "uses"}`);
|
|
1751
1653
|
if (contextPercent !== null) stateParts.push(`context ${Math.round(contextPercent)}% full`);
|
|
1752
1654
|
if (record.compactionCount) stateParts.push(`${record.compactionCount} compaction${record.compactionCount === 1 ? "" : "s"}`);
|
|
1753
|
-
return
|
|
1655
|
+
return steerResult(
|
|
1754
1656
|
`Steering message sent to agent ${record.id}. The agent will process it after its current tool execution.\n` +
|
|
1755
1657
|
`Current state: ${stateParts.join(" · ")}`,
|
|
1658
|
+
"sent",
|
|
1756
1659
|
);
|
|
1757
1660
|
} catch (err) {
|
|
1758
|
-
return
|
|
1661
|
+
return steerResult(`Failed to steer agent: ${err instanceof Error ? err.message : String(err)}`, "failed");
|
|
1759
1662
|
}
|
|
1760
1663
|
},
|
|
1761
1664
|
}));
|
|
@@ -1788,31 +1691,7 @@ Terse command-style prompts produce shallow, generic work.
|
|
|
1788
1691
|
if (!record) {
|
|
1789
1692
|
return textResult("Checkpoint failed: agent record not found.");
|
|
1790
1693
|
}
|
|
1791
|
-
|
|
1792
|
-
const turn = record.turnCount ?? 0;
|
|
1793
|
-
const maxTurns = record.effectiveMaxTurns;
|
|
1794
|
-
const elapsedSeconds = Math.round((Date.now() - record.startedAt) / 1000);
|
|
1795
|
-
record.lastCheckpoint = { turn, summary: params.summary };
|
|
1796
|
-
|
|
1797
|
-
// Best-effort: a write failure (disk full, missing parent dir) returns a soft
|
|
1798
|
-
// warning instead of crashing the call. checkpointsFileOk is latching-true —
|
|
1799
|
-
// see AgentRecord.checkpointsFileOk for the suppression contract.
|
|
1800
|
-
const checkpointsPath = checkpointsFilePath(record.outputFile);
|
|
1801
|
-
const maxTurnsLabel = maxTurns != null ? `/${maxTurns}` : "";
|
|
1802
|
-
if (checkpointsPath) {
|
|
1803
|
-
try {
|
|
1804
|
-
appendFileSync(
|
|
1805
|
-
checkpointsPath,
|
|
1806
|
-
`## Turn ${turn}${maxTurnsLabel} — ${elapsedSeconds}s elapsed\n${params.summary}\n\n`,
|
|
1807
|
-
"utf-8",
|
|
1808
|
-
);
|
|
1809
|
-
record.checkpointsFileOk = true;
|
|
1810
|
-
} catch (err) {
|
|
1811
|
-
return textResult(`Checkpoint saved in memory, but file write failed: ${err instanceof Error ? err.message : String(err)}.`);
|
|
1812
|
-
}
|
|
1813
|
-
}
|
|
1814
|
-
|
|
1815
|
-
return textResult(`Checkpoint saved (turn ${turn}${maxTurnsLabel}, ${elapsedSeconds}s).`);
|
|
1694
|
+
return textResult(saveCheckpoint(record, params.summary));
|
|
1816
1695
|
},
|
|
1817
1696
|
}));
|
|
1818
1697
|
}
|
|
@@ -1860,7 +1739,7 @@ Terse command-style prompts produce shallow, generic work.
|
|
|
1860
1739
|
const options: string[] = [];
|
|
1861
1740
|
|
|
1862
1741
|
// Running agents entry (only if there are active agents)
|
|
1863
|
-
const agents = manager.listAgents();
|
|
1742
|
+
const agents = manager.listAgents().filter(a => !a.parentAgentId);
|
|
1864
1743
|
if (agents.length > 0) {
|
|
1865
1744
|
const running = agents.filter(a => a.status === "running" || a.status === "queued").length;
|
|
1866
1745
|
const done = agents.filter(a => a.status === "completed" || a.status === "steered").length;
|
|
@@ -1936,14 +1815,17 @@ Terse command-style prompts produce shallow, generic work.
|
|
|
1936
1815
|
const cfg = getAgentConfig(name);
|
|
1937
1816
|
const disabled = cfg?.enabled === false;
|
|
1938
1817
|
const model = getModelLabel(name, ctx.modelRegistry);
|
|
1818
|
+
const displayName = toSingleLine(name);
|
|
1939
1819
|
return {
|
|
1940
1820
|
id: name,
|
|
1941
|
-
label: `${sourceIndicator(cfg)}${
|
|
1942
|
-
currentValue: model,
|
|
1943
|
-
description: disabled ? "(disabled)" : (cfg?.description ?? name),
|
|
1821
|
+
label: `${sourceIndicator(cfg)}${displayName}`,
|
|
1822
|
+
currentValue: toSingleLine(model),
|
|
1823
|
+
description: disabled ? "(disabled)" : toSingleLine(cfg?.description ?? name),
|
|
1944
1824
|
// Single-value list so Enter "activates" the row (fires onChange with the
|
|
1945
|
-
// agent's id) without offering anything to actually cycle.
|
|
1946
|
-
|
|
1825
|
+
// agent's id) without offering anything to actually cycle. The value is
|
|
1826
|
+
// the sanitized display copy: SettingsList copies values[0] back into
|
|
1827
|
+
// currentValue on activation, so a raw label would reappear.
|
|
1828
|
+
values: [toSingleLine(model)],
|
|
1947
1829
|
};
|
|
1948
1830
|
});
|
|
1949
1831
|
|
|
@@ -1981,16 +1863,16 @@ Terse command-style prompts produce shallow, generic work.
|
|
|
1981
1863
|
}
|
|
1982
1864
|
|
|
1983
1865
|
async function showRunningAgents(ctx: ExtensionCommandContext) {
|
|
1984
|
-
const agents = manager.listAgents();
|
|
1866
|
+
const agents = manager.listAgents().filter(a => !a.parentAgentId);
|
|
1985
1867
|
if (agents.length === 0) {
|
|
1986
1868
|
ctx.ui.notify("No agents.", "info");
|
|
1987
1869
|
return;
|
|
1988
1870
|
}
|
|
1989
1871
|
|
|
1990
1872
|
const options = agents.map(a => {
|
|
1991
|
-
const dn = getDisplayName(a.type);
|
|
1873
|
+
const dn = toSingleLine(getDisplayName(a.type));
|
|
1992
1874
|
const dur = formatDuration(a.startedAt, a.completedAt);
|
|
1993
|
-
return `${dn} (${a.description}) · ${a.toolUses} tools · ${a.status} · ${dur}`;
|
|
1875
|
+
return `${dn} (${toSingleLine(a.description)}) · ${a.toolUses} tools · ${toSingleLine(a.status)} · ${dur}`;
|
|
1994
1876
|
});
|
|
1995
1877
|
|
|
1996
1878
|
const choice = await ctx.ui.select("Running agents", options);
|
|
@@ -2020,9 +1902,9 @@ Terse command-style prompts produce shallow, generic work.
|
|
|
2020
1902
|
(tui, theme, keybindings, done) => {
|
|
2021
1903
|
return new ConversationViewer(tui, session, record, activity, theme, done, () => {
|
|
2022
1904
|
if (manager.abort(record.id)) {
|
|
2023
|
-
ctx.ui.notify(`Stopped "${record.description}".`, "info");
|
|
1905
|
+
ctx.ui.notify(`Stopped "${toSingleLine(record.description)}".`, "info");
|
|
2024
1906
|
}
|
|
2025
|
-
}, keybindings, (message: string) => manager.steer(record.id, message));
|
|
1907
|
+
}, keybindings, (message: string) => manager.steer(record.id, message), showCost);
|
|
2026
1908
|
},
|
|
2027
1909
|
{
|
|
2028
1910
|
overlay: true,
|
|
@@ -2034,7 +1916,7 @@ Terse command-style prompts produce shallow, generic work.
|
|
|
2034
1916
|
async function showAgentDetail(ctx: ExtensionCommandContext, name: string) {
|
|
2035
1917
|
const cfg = getAgentConfig(name);
|
|
2036
1918
|
if (!cfg) {
|
|
2037
|
-
ctx.ui.notify(`Agent config not found for "${name}".`, "warning");
|
|
1919
|
+
ctx.ui.notify(`Agent config not found for "${toSingleLine(name)}".`, "warning");
|
|
2038
1920
|
return;
|
|
2039
1921
|
}
|
|
2040
1922
|
|
|
@@ -2059,33 +1941,33 @@ Terse command-style prompts produce shallow, generic work.
|
|
|
2059
1941
|
menuOptions = ["Edit", "Disable", "Delete", "Back"];
|
|
2060
1942
|
}
|
|
2061
1943
|
|
|
2062
|
-
const choice = await ctx.ui.select(name, menuOptions);
|
|
1944
|
+
const choice = await ctx.ui.select(toSingleLine(name), menuOptions);
|
|
2063
1945
|
if (!choice || choice === "Back") return;
|
|
2064
1946
|
|
|
2065
1947
|
if (choice === "Edit" && file) {
|
|
2066
1948
|
const content = readFileSync(file.path, "utf-8");
|
|
2067
|
-
const edited = await ctx.ui.editor(`Edit ${name}`, content);
|
|
1949
|
+
const edited = await ctx.ui.editor(`Edit ${toSingleLine(name)}`, content);
|
|
2068
1950
|
if (edited !== undefined && edited !== content) {
|
|
2069
1951
|
const { writeFileSync } = await import("node:fs");
|
|
2070
1952
|
writeFileSync(file.path, edited, "utf-8");
|
|
2071
1953
|
reloadCustomAgents();
|
|
2072
|
-
ctx.ui.notify(`Updated ${file.path}`, "info");
|
|
1954
|
+
ctx.ui.notify(`Updated ${toSingleLine(file.path)}`, "info");
|
|
2073
1955
|
}
|
|
2074
1956
|
} else if (choice === "Delete") {
|
|
2075
1957
|
if (file) {
|
|
2076
|
-
const confirmed = await ctx.ui.confirm("Delete agent", `Delete ${name} from ${file.location} (${file.path})?`);
|
|
1958
|
+
const confirmed = await ctx.ui.confirm("Delete agent", `Delete ${toSingleLine(name)} from ${file.location} (${toSingleLine(file.path)})?`);
|
|
2077
1959
|
if (confirmed) {
|
|
2078
1960
|
unlinkSync(file.path);
|
|
2079
1961
|
reloadCustomAgents();
|
|
2080
|
-
ctx.ui.notify(`Deleted ${file.path}`, "info");
|
|
1962
|
+
ctx.ui.notify(`Deleted ${toSingleLine(file.path)}`, "info");
|
|
2081
1963
|
}
|
|
2082
1964
|
}
|
|
2083
1965
|
} else if (choice === "Reset to default" && file) {
|
|
2084
|
-
const confirmed = await ctx.ui.confirm("Reset to default", `Delete override ${file.path} and restore embedded default?`);
|
|
1966
|
+
const confirmed = await ctx.ui.confirm("Reset to default", `Delete override ${toSingleLine(file.path)} and restore embedded default?`);
|
|
2085
1967
|
if (confirmed) {
|
|
2086
1968
|
unlinkSync(file.path);
|
|
2087
1969
|
reloadCustomAgents();
|
|
2088
|
-
ctx.ui.notify(`Restored default ${name}`, "info");
|
|
1970
|
+
ctx.ui.notify(`Restored default ${toSingleLine(name)}`, "info");
|
|
2089
1971
|
}
|
|
2090
1972
|
} else if (choice.startsWith("Eject")) {
|
|
2091
1973
|
await ejectAgent(ctx, name, cfg);
|
|
@@ -2109,7 +1991,7 @@ Terse command-style prompts produce shallow, generic work.
|
|
|
2109
1991
|
|
|
2110
1992
|
const targetPath = join(targetDir, `${name}.md`);
|
|
2111
1993
|
if (existsSync(targetPath)) {
|
|
2112
|
-
const overwrite = await ctx.ui.confirm("Overwrite", `${targetPath} already exists. Overwrite?`);
|
|
1994
|
+
const overwrite = await ctx.ui.confirm("Overwrite", `${toSingleLine(targetPath)} already exists. Overwrite?`);
|
|
2113
1995
|
if (!overwrite) return;
|
|
2114
1996
|
}
|
|
2115
1997
|
|
|
@@ -2129,18 +2011,21 @@ Terse command-style prompts produce shallow, generic work.
|
|
|
2129
2011
|
else if (Array.isArray(cfg.skills)) fmFields.push(`skills: ${cfg.skills.join(", ")}`);
|
|
2130
2012
|
if (cfg.disallowedTools?.length) fmFields.push(`disallowed_tools: ${cfg.disallowedTools.join(", ")}`);
|
|
2131
2013
|
if (cfg.inheritContext) fmFields.push("inherit_context: true");
|
|
2132
|
-
if (cfg.runInBackground) fmFields.push("run_in_background: true");
|
|
2133
2014
|
if (cfg.outputTranscript === false) fmFields.push("output_transcript: false");
|
|
2134
2015
|
if (cfg.isolated) fmFields.push("isolated: true");
|
|
2135
2016
|
if (cfg.memory) fmFields.push(`memory: ${cfg.memory}`);
|
|
2136
2017
|
if (cfg.isolation) fmFields.push(`isolation: ${cfg.isolation}`);
|
|
2018
|
+
if (cfg.allowedSubagents !== undefined) {
|
|
2019
|
+
fmFields.push(`allowed_subagents: ${cfg.allowedSubagents === "all" ? "all" : cfg.allowedSubagents.join(", ")}`);
|
|
2020
|
+
}
|
|
2021
|
+
if (cfg.maxSubagentDepth !== undefined) fmFields.push(`max_subagent_depth: ${cfg.maxSubagentDepth}`);
|
|
2137
2022
|
|
|
2138
2023
|
const content = `---\n${fmFields.join("\n")}\n---\n\n${cfg.systemPrompt}\n`;
|
|
2139
2024
|
|
|
2140
2025
|
const { writeFileSync } = await import("node:fs");
|
|
2141
2026
|
writeFileSync(targetPath, content, "utf-8");
|
|
2142
2027
|
reloadCustomAgents();
|
|
2143
|
-
ctx.ui.notify(`Ejected ${name} to ${targetPath}`, "info");
|
|
2028
|
+
ctx.ui.notify(`Ejected ${toSingleLine(name)} to ${toSingleLine(targetPath)}`, "info");
|
|
2144
2029
|
}
|
|
2145
2030
|
|
|
2146
2031
|
/** Disable an agent: set enabled: false in its .md file, or create a stub for built-in defaults. */
|
|
@@ -2150,14 +2035,14 @@ Terse command-style prompts produce shallow, generic work.
|
|
|
2150
2035
|
// Existing file — set enabled: false in frontmatter (idempotent)
|
|
2151
2036
|
const content = readFileSync(file.path, "utf-8");
|
|
2152
2037
|
if (content.includes("\nenabled: false\n")) {
|
|
2153
|
-
ctx.ui.notify(`${name} is already disabled.`, "info");
|
|
2038
|
+
ctx.ui.notify(`${toSingleLine(name)} is already disabled.`, "info");
|
|
2154
2039
|
return;
|
|
2155
2040
|
}
|
|
2156
2041
|
const updated = content.replace(/^---\n/, "---\nenabled: false\n");
|
|
2157
2042
|
const { writeFileSync } = await import("node:fs");
|
|
2158
2043
|
writeFileSync(file.path, updated, "utf-8");
|
|
2159
2044
|
reloadCustomAgents();
|
|
2160
|
-
ctx.ui.notify(`Disabled ${name} (${file.path})`, "info");
|
|
2045
|
+
ctx.ui.notify(`Disabled ${toSingleLine(name)} (${toSingleLine(file.path)})`, "info");
|
|
2161
2046
|
return;
|
|
2162
2047
|
}
|
|
2163
2048
|
|
|
@@ -2175,7 +2060,7 @@ Terse command-style prompts produce shallow, generic work.
|
|
|
2175
2060
|
const { writeFileSync } = await import("node:fs");
|
|
2176
2061
|
writeFileSync(targetPath, "---\nenabled: false\n---\n", "utf-8");
|
|
2177
2062
|
reloadCustomAgents();
|
|
2178
|
-
ctx.ui.notify(`Disabled ${name} (${targetPath})`, "info");
|
|
2063
|
+
ctx.ui.notify(`Disabled ${toSingleLine(name)} (${toSingleLine(targetPath)})`, "info");
|
|
2179
2064
|
}
|
|
2180
2065
|
|
|
2181
2066
|
/** Enable a disabled agent by removing enabled: false from its frontmatter. */
|
|
@@ -2191,11 +2076,11 @@ Terse command-style prompts produce shallow, generic work.
|
|
|
2191
2076
|
if (updated.trim() === "---\n---" || updated.trim() === "---\n---\n") {
|
|
2192
2077
|
unlinkSync(file.path);
|
|
2193
2078
|
reloadCustomAgents();
|
|
2194
|
-
ctx.ui.notify(`Enabled ${name} (removed ${file.path})`, "info");
|
|
2079
|
+
ctx.ui.notify(`Enabled ${toSingleLine(name)} (removed ${toSingleLine(file.path)})`, "info");
|
|
2195
2080
|
} else {
|
|
2196
2081
|
writeFileSync(file.path, updated, "utf-8");
|
|
2197
2082
|
reloadCustomAgents();
|
|
2198
|
-
ctx.ui.notify(`Enabled ${name} (${file.path})`, "info");
|
|
2083
|
+
ctx.ui.notify(`Enabled ${toSingleLine(name)} (${toSingleLine(file.path)})`, "info");
|
|
2199
2084
|
}
|
|
2200
2085
|
}
|
|
2201
2086
|
|
|
@@ -2232,7 +2117,7 @@ Terse command-style prompts produce shallow, generic work.
|
|
|
2232
2117
|
|
|
2233
2118
|
const targetPath = join(targetDir, `${name}.md`);
|
|
2234
2119
|
if (existsSync(targetPath)) {
|
|
2235
|
-
const overwrite = await ctx.ui.confirm("Overwrite", `${targetPath} already exists. Overwrite?`);
|
|
2120
|
+
const overwrite = await ctx.ui.confirm("Overwrite", `${toSingleLine(targetPath)} already exists. Overwrite?`);
|
|
2236
2121
|
if (!overwrite) return;
|
|
2237
2122
|
}
|
|
2238
2123
|
|
|
@@ -2256,11 +2141,18 @@ extensions: <true (inherit all MCP/extension tools), false (none), or comma-sepa
|
|
|
2256
2141
|
skills: <true (inherit all), false (none), or comma-separated skill names to preload into prompt. Default: true>
|
|
2257
2142
|
disallowed_tools: <comma-separated tool names to block, even if otherwise available. Omit for none>
|
|
2258
2143
|
inherit_context: <true to fork parent conversation into agent so it sees chat history. Default: false>
|
|
2259
|
-
run_in_background: <true to run in background by default. Default: false>
|
|
2260
2144
|
output_transcript: <false to write no transcript file or path for this agent. Independent of persist_session. Default: true>
|
|
2261
2145
|
isolated: <true for no extension/MCP tools, only built-in tools. Default: false>
|
|
2262
|
-
memory: <"user" (global), "project" (per-project), or "local" (gitignored per-project) for persistent memory. Omit for none
|
|
2263
|
-
|
|
2146
|
+
memory: <"user" (global), "project" (per-project), or "local" (gitignored per-project) for persistent memory. Omit for none>${
|
|
2147
|
+
// Offering the field on a project that turned worktrees off would bake a
|
|
2148
|
+
// request that is refused at spawn time into a file that outlives the
|
|
2149
|
+
// session. Built per invocation, so this read is live.
|
|
2150
|
+
isWorktreeIsolationEnabled()
|
|
2151
|
+
? `\nisolation: <"worktree" to run in isolated git worktree; "off" to refuse one even when the caller asks. Omit for normal>`
|
|
2152
|
+
: ""
|
|
2153
|
+
}
|
|
2154
|
+
allowed_subagents: <"all", a comma-separated list of agent types this agent may delegate to, or omit for no nested delegation>
|
|
2155
|
+
max_subagent_depth: <optional per-agent nesting cap; can only lower the project's maxSubagentDepth>
|
|
2264
2156
|
---
|
|
2265
2157
|
|
|
2266
2158
|
<system prompt body — instructions for the agent>
|
|
@@ -2273,25 +2165,49 @@ Guidelines for choosing settings:
|
|
|
2273
2165
|
- Use prompt_mode: replace for fully custom agents with their own personality/instructions
|
|
2274
2166
|
- Set inherit_context: true if the agent needs to know what was discussed in the parent conversation
|
|
2275
2167
|
- Set isolated: true if the agent should NOT have access to MCP servers or other extensions
|
|
2276
|
-
- Set output_transcript: false to skip writing this agent's transcript;
|
|
2168
|
+
- Set output_transcript: false to skip writing this agent's transcript; it doesn't keep the run off disk — every run writes a pi session file while it runs (kept after it settles only with persist_session: true), and isolation: worktree commits and memory still write
|
|
2277
2169
|
- Only include frontmatter fields that differ from defaults — omit fields where the default is fine
|
|
2278
2170
|
|
|
2279
2171
|
Write the file using the write tool. Only write the file, nothing else.`;
|
|
2280
2172
|
|
|
2281
|
-
|
|
2282
|
-
|
|
2283
|
-
|
|
2284
|
-
|
|
2285
|
-
|
|
2286
|
-
|
|
2287
|
-
|
|
2173
|
+
let id: string;
|
|
2174
|
+
try {
|
|
2175
|
+
// Deliberately outside `resolveSpawnType`: this is the extension's own
|
|
2176
|
+
// generator, not a caller-supplied spawn, and it wants the all-tools tier.
|
|
2177
|
+
// Routing it through `fallbackSubagent: none` would break the wizard for
|
|
2178
|
+
// users who opted into strict dispatch.
|
|
2179
|
+
id = manager.spawn(pi, ctx, "general-purpose", generatePrompt, {
|
|
2180
|
+
description: `Generate ${name} agent`,
|
|
2181
|
+
maxTurns: WIZARD_MAX_TURNS,
|
|
2182
|
+
});
|
|
2183
|
+
} catch (err) {
|
|
2184
|
+
ctx.ui.notify(`Generation failed: ${err instanceof Error ? err.message : String(err)}`, "warning");
|
|
2288
2185
|
return;
|
|
2289
2186
|
}
|
|
2187
|
+
const record = manager.getRecord(id)!;
|
|
2188
|
+
// Stop polling when the record settles (stopped/error) or leaves the
|
|
2189
|
+
// manager entirely — dispose() clears the map without touching the
|
|
2190
|
+
// status, so the identity check keeps this from spinning forever.
|
|
2191
|
+
while (manager.getRecord(id) === record && record.status === "queued") {
|
|
2192
|
+
await new Promise((r) => setTimeout(r, WIZARD_QUEUE_POLL_MS));
|
|
2193
|
+
}
|
|
2194
|
+
// The record left the manager without settling (a dispose() that clears
|
|
2195
|
+
// the map without aborting first): the run never started, so report the
|
|
2196
|
+
// cancellation instead of toasting a completed-then-missing file. Real
|
|
2197
|
+
// teardown aborts before it disposes — this keeps a dispose-without-abort
|
|
2198
|
+
// eviction from falling through to the created/not-created toast.
|
|
2199
|
+
if (manager.getRecord(id) !== record) {
|
|
2200
|
+
ctx.ui.notify("Generation cancelled.", "info");
|
|
2201
|
+
return;
|
|
2202
|
+
}
|
|
2203
|
+
await record.promise; // undefined when stopped-while-queued → resolves
|
|
2204
|
+
if (record.status === "error") { ctx.ui.notify(`Generation failed: ${toSingleLine(record.error)}`, "warning"); return; }
|
|
2205
|
+
if (record.status === "stopped") { ctx.ui.notify("Generation cancelled.", "info"); return; }
|
|
2290
2206
|
|
|
2291
2207
|
reloadCustomAgents();
|
|
2292
2208
|
|
|
2293
2209
|
if (existsSync(targetPath)) {
|
|
2294
|
-
ctx.ui.notify(`Created ${targetPath}`, "info");
|
|
2210
|
+
ctx.ui.notify(`Created ${toSingleLine(targetPath)}`, "info");
|
|
2295
2211
|
} else {
|
|
2296
2212
|
ctx.ui.notify("Agent generation completed but file was not created. Check the agent output.", "warning");
|
|
2297
2213
|
}
|
|
@@ -2368,14 +2284,14 @@ ${systemPrompt}
|
|
|
2368
2284
|
const targetPath = join(targetDir, `${name}.md`);
|
|
2369
2285
|
|
|
2370
2286
|
if (existsSync(targetPath)) {
|
|
2371
|
-
const overwrite = await ctx.ui.confirm("Overwrite", `${targetPath} already exists. Overwrite?`);
|
|
2287
|
+
const overwrite = await ctx.ui.confirm("Overwrite", `${toSingleLine(targetPath)} already exists. Overwrite?`);
|
|
2372
2288
|
if (!overwrite) return;
|
|
2373
2289
|
}
|
|
2374
2290
|
|
|
2375
2291
|
const { writeFileSync } = await import("node:fs");
|
|
2376
2292
|
writeFileSync(targetPath, content, "utf-8");
|
|
2377
2293
|
reloadCustomAgents();
|
|
2378
|
-
ctx.ui.notify(`Created ${targetPath}`, "info");
|
|
2294
|
+
ctx.ui.notify(`Created ${toSingleLine(targetPath)}`, "info");
|
|
2379
2295
|
}
|
|
2380
2296
|
|
|
2381
2297
|
function snapshotSettings(): SubagentsSettings {
|
|
@@ -2385,27 +2301,51 @@ ${systemPrompt}
|
|
|
2385
2301
|
// normalizeMaxTurns() in agent-runner.ts (which maps 0 → undefined).
|
|
2386
2302
|
defaultMaxTurns: getDefaultMaxTurns() ?? 0,
|
|
2387
2303
|
graceTurns: getGraceTurns(),
|
|
2388
|
-
|
|
2304
|
+
maxSubagentDepth: getMaxSubagentDepth(),
|
|
2389
2305
|
schedulingEnabled: isSchedulingEnabled(),
|
|
2390
2306
|
scopeModels: isScopeModelsEnabled(),
|
|
2391
|
-
|
|
2392
|
-
resultPreviewExpanded,
|
|
2307
|
+
strictAgentFiles,
|
|
2393
2308
|
failurePreviewMaxChars,
|
|
2394
2309
|
disableDefaultAgents: isDefaultsDisabled(),
|
|
2395
2310
|
toolDescriptionMode: getToolDescriptionMode(),
|
|
2396
2311
|
fleetView: isFleetViewEnabled(),
|
|
2397
2312
|
widgetMode: getWidgetMode(),
|
|
2398
2313
|
outputTranscript: getOutputTranscriptDefault(),
|
|
2314
|
+
worktreeIsolation: isWorktreeIsolationEnabled(),
|
|
2315
|
+
// Deliberately NOT `?? "general-purpose"`: every settings change writes the
|
|
2316
|
+
// whole snapshot, and materializing the implicit default would turn it into
|
|
2317
|
+
// explicit configuration — which then fails loudly if general-purpose later
|
|
2318
|
+
// goes away. undefined is dropped by JSON.stringify.
|
|
2319
|
+
fallbackSubagent: getFallbackSubagent(),
|
|
2320
|
+
reportUsage: isReportUsageEnabled(),
|
|
2321
|
+
showCost: isShowCostEnabled(),
|
|
2322
|
+
showModel: isShowModelEnabled(),
|
|
2399
2323
|
};
|
|
2400
2324
|
}
|
|
2401
2325
|
|
|
2402
|
-
const NUMERIC_IDS = new Set(["maxConcurrent", "defaultMaxTurns", "graceTurns", "failurePreviewMaxChars"]);
|
|
2326
|
+
const NUMERIC_IDS = new Set(["maxConcurrent", "defaultMaxTurns", "graceTurns", "maxSubagentDepth", "failurePreviewMaxChars"]);
|
|
2403
2327
|
|
|
2404
2328
|
async function showSettings(ctx: ExtensionCommandContext) {
|
|
2405
2329
|
function buildItems(): SettingItem[] {
|
|
2406
2330
|
const mc = manager.getMaxConcurrent();
|
|
2407
2331
|
const dmt = getDefaultMaxTurns() ?? 0;
|
|
2408
2332
|
const gt = getGraceTurns();
|
|
2333
|
+
const msd = getMaxSubagentDepth();
|
|
2334
|
+
// Label what unset actually does — it targets general-purpose even when
|
|
2335
|
+
// that is unregistered (the permissive hardcoded tier), so showing "none"
|
|
2336
|
+
// there would advertise strict dispatch for the most permissive state.
|
|
2337
|
+
// `values` still offers only resolvable targets, so the user cannot
|
|
2338
|
+
// persist a fallback that would hard-error on every dispatch.
|
|
2339
|
+
// The row must never show a value that is absent from `values`: pi-tui's
|
|
2340
|
+
// cycle handler computes `values.indexOf(currentValue)` and writes
|
|
2341
|
+
// `values[0]` on -1, so a missing current value turns one Enter into a
|
|
2342
|
+
// silent rewrite to whatever happens to be first — a custom agent, once the
|
|
2343
|
+
// defaults are disabled. Including the current value makes Enter mean
|
|
2344
|
+
// "cycle from what is shown", which is what every other row means.
|
|
2345
|
+
const fallback = fallbackRow(getFallbackSubagent(), getAvailableTypes(), NO_FALLBACK, isFallbackResolvable);
|
|
2346
|
+
const fallbackValue = fallback.value;
|
|
2347
|
+
const fallbackValues = fallback.values;
|
|
2348
|
+
const fallbackUnresolvable = fallback.unresolvable;
|
|
2409
2349
|
|
|
2410
2350
|
return [
|
|
2411
2351
|
{
|
|
@@ -2430,11 +2370,11 @@ ${systemPrompt}
|
|
|
2430
2370
|
values: [String(gt)],
|
|
2431
2371
|
},
|
|
2432
2372
|
{
|
|
2433
|
-
id: "
|
|
2434
|
-
label: "
|
|
2435
|
-
description: "
|
|
2436
|
-
currentValue:
|
|
2437
|
-
values: [
|
|
2373
|
+
id: "maxSubagentDepth",
|
|
2374
|
+
label: "Nested depth",
|
|
2375
|
+
description: "Hard cap on nested delegation — main is 0, its subagents 1 (0/1 = nesting off, Enter to type)",
|
|
2376
|
+
currentValue: String(msd),
|
|
2377
|
+
values: [String(msd)],
|
|
2438
2378
|
},
|
|
2439
2379
|
{
|
|
2440
2380
|
id: "schedulingEnabled",
|
|
@@ -2451,17 +2391,10 @@ ${systemPrompt}
|
|
|
2451
2391
|
values: ["on", "off"],
|
|
2452
2392
|
},
|
|
2453
2393
|
{
|
|
2454
|
-
id: "
|
|
2455
|
-
label: "
|
|
2456
|
-
description: "
|
|
2457
|
-
currentValue:
|
|
2458
|
-
values: ["markdown", "plain"],
|
|
2459
|
-
},
|
|
2460
|
-
{
|
|
2461
|
-
id: "resultPreviewExpanded",
|
|
2462
|
-
label: "Result preview expanded by default",
|
|
2463
|
-
description: "Always show expanded result preview, ignoring pi's expanded flag",
|
|
2464
|
-
currentValue: resultPreviewExpanded ? "on" : "off",
|
|
2394
|
+
id: "strictAgentFiles",
|
|
2395
|
+
label: "Strict agent files",
|
|
2396
|
+
description: "Fail startup on an unreadable/unparseable agent .md instead of skipping it with a warning",
|
|
2397
|
+
currentValue: strictAgentFiles ? "on" : "off",
|
|
2465
2398
|
values: ["on", "off"],
|
|
2466
2399
|
},
|
|
2467
2400
|
{
|
|
@@ -2485,6 +2418,49 @@ ${systemPrompt}
|
|
|
2485
2418
|
currentValue: getOutputTranscriptDefault() ? "on" : "off",
|
|
2486
2419
|
values: ["on", "off"],
|
|
2487
2420
|
},
|
|
2421
|
+
{
|
|
2422
|
+
id: "worktreeIsolation",
|
|
2423
|
+
label: "Worktree isolation",
|
|
2424
|
+
description:
|
|
2425
|
+
"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.",
|
|
2426
|
+
currentValue: isWorktreeIsolationEnabled() ? "on" : "off",
|
|
2427
|
+
values: ["on", "off"],
|
|
2428
|
+
},
|
|
2429
|
+
{
|
|
2430
|
+
id: "fallbackSubagent",
|
|
2431
|
+
label: "Fallback agent",
|
|
2432
|
+
description:
|
|
2433
|
+
`Agent used when subagent_type is unknown, disabled, or ambiguous; "${NO_FALLBACK}" rejects the call instead (strict dispatch)` +
|
|
2434
|
+
(fallbackUnresolvable
|
|
2435
|
+
? " — currently unresolvable: that agent is deleted, disabled, or a default with Disable defaults on, so unknown types are refused"
|
|
2436
|
+
: ""),
|
|
2437
|
+
currentValue: fallbackValue,
|
|
2438
|
+
values: fallbackValues,
|
|
2439
|
+
},
|
|
2440
|
+
{
|
|
2441
|
+
id: "reportUsage",
|
|
2442
|
+
label: "Report usage to session",
|
|
2443
|
+
description:
|
|
2444
|
+
"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.",
|
|
2445
|
+
currentValue: isReportUsageEnabled() ? "on" : "off",
|
|
2446
|
+
values: ["on", "off"],
|
|
2447
|
+
},
|
|
2448
|
+
{
|
|
2449
|
+
id: "showCost",
|
|
2450
|
+
label: "Show cost",
|
|
2451
|
+
description:
|
|
2452
|
+
"Show an estimated `~$0.0042` beside subagent token counts in the widget, fleet view, conversation viewer, results and notifications. Priced by pi from the model's rates — omitted entirely for a model it has no rates for.",
|
|
2453
|
+
currentValue: isShowCostEnabled() ? "on" : "off",
|
|
2454
|
+
values: ["on", "off"],
|
|
2455
|
+
},
|
|
2456
|
+
{
|
|
2457
|
+
id: "showModel",
|
|
2458
|
+
label: "Show model",
|
|
2459
|
+
description:
|
|
2460
|
+
"Name the model driving each agent, and the thinking level it is running at, on the widget's running rows. The conversation viewer shows the pair either way — this adds it to the widget, where the row is already dense.",
|
|
2461
|
+
currentValue: isShowModelEnabled() ? "on" : "off",
|
|
2462
|
+
values: ["on", "off"],
|
|
2463
|
+
},
|
|
2488
2464
|
{
|
|
2489
2465
|
id: "fleetView",
|
|
2490
2466
|
label: "Fleet view",
|
|
@@ -2495,9 +2471,9 @@ ${systemPrompt}
|
|
|
2495
2471
|
{
|
|
2496
2472
|
id: "widgetMode",
|
|
2497
2473
|
label: "Widget",
|
|
2498
|
-
description: "Above-editor agent widget: all = every agent;
|
|
2474
|
+
description: "Above-editor agent widget: all = every agent; off = hide the widget.",
|
|
2499
2475
|
currentValue: getWidgetMode(),
|
|
2500
|
-
values: ["all", "
|
|
2476
|
+
values: ["all", "off"],
|
|
2501
2477
|
},
|
|
2502
2478
|
{
|
|
2503
2479
|
id: "toolDescriptionMode",
|
|
@@ -2531,9 +2507,17 @@ ${systemPrompt}
|
|
|
2531
2507
|
setGraceTurns(n);
|
|
2532
2508
|
notifyApplied(ctx, `Grace turns set to ${n}`);
|
|
2533
2509
|
}
|
|
2534
|
-
} else if (id === "
|
|
2535
|
-
|
|
2536
|
-
|
|
2510
|
+
} else if (id === "maxSubagentDepth") {
|
|
2511
|
+
const n = parseInt(value, 10);
|
|
2512
|
+
if (n >= 0) {
|
|
2513
|
+
setMaxSubagentDepth(n);
|
|
2514
|
+
notifyApplied(
|
|
2515
|
+
ctx,
|
|
2516
|
+
n <= 1
|
|
2517
|
+
? "Nested delegation disabled"
|
|
2518
|
+
: `Nested depth set to ${n}. Applies to agents started from now on.`,
|
|
2519
|
+
);
|
|
2520
|
+
}
|
|
2537
2521
|
} else if (id === "schedulingEnabled") {
|
|
2538
2522
|
const enabled = value === "on";
|
|
2539
2523
|
if (enabled === isSchedulingEnabled()) {
|
|
@@ -2550,30 +2534,83 @@ ${systemPrompt}
|
|
|
2550
2534
|
const enabled = value === "on";
|
|
2551
2535
|
setScopeModelsEnabled(enabled);
|
|
2552
2536
|
notifyApplied(ctx, `Scope models ${enabled ? "enabled" : "disabled"}`);
|
|
2553
|
-
} else if (id === "
|
|
2554
|
-
|
|
2555
|
-
|
|
2556
|
-
|
|
2557
|
-
|
|
2558
|
-
|
|
2559
|
-
|
|
2537
|
+
} else if (id === "strictAgentFiles") {
|
|
2538
|
+
const enabled = value === "on";
|
|
2539
|
+
strictAgentFiles = enabled;
|
|
2540
|
+
notifyApplied(ctx, `Strict agent files ${enabled ? "enabled" : "disabled"}. Takes effect on next pi session.`);
|
|
2541
|
+
} else if (id === "worktreeIsolation") {
|
|
2542
|
+
const enabled = value === "on";
|
|
2543
|
+
setWorktreeIsolationEnabled(enabled);
|
|
2544
|
+
// The refusal is live, but the tool schema is built at registration, so
|
|
2545
|
+
// the isolation parameter only appears/disappears next session.
|
|
2546
|
+
notifyApplied(
|
|
2547
|
+
ctx,
|
|
2548
|
+
`Worktree isolation ${enabled ? "enabled" : "disabled"}. Tool parameter updates on next pi session.`,
|
|
2549
|
+
);
|
|
2550
|
+
} else if (id === "fallbackSubagent") {
|
|
2551
|
+
// The row's value list is built when the menu opens, so a toggle in the
|
|
2552
|
+
// same menu (Disable defaults) can leave it stale. Persisting a fallback
|
|
2553
|
+
// that dispatch would then refuse is the one outcome to avoid: keep the
|
|
2554
|
+
// current value and say why, rather than writing a broken one.
|
|
2555
|
+
if (fallbackRow(value, getAvailableTypes(), NO_FALLBACK, isFallbackResolvable).unresolvable) {
|
|
2556
|
+
// pi-tui has already written the refused value into the row, so put the
|
|
2557
|
+
// real one back or the next Enter cycles past a value the user can no
|
|
2558
|
+
// longer select.
|
|
2559
|
+
list?.updateValue("fallbackSubagent", getFallbackSubagent() ?? IMPLICIT_FALLBACK);
|
|
2560
|
+
ctx.ui.notify(`Agent "${value}" is not available — fallback unchanged.`, "warning");
|
|
2561
|
+
return;
|
|
2562
|
+
}
|
|
2563
|
+
setFallbackSubagent(value);
|
|
2564
|
+
notifyApplied(
|
|
2565
|
+
ctx,
|
|
2566
|
+
value === NO_FALLBACK
|
|
2567
|
+
? "Unknown or disabled agent types will now be rejected"
|
|
2568
|
+
: `Unknown agent types will fall back to ${value}`,
|
|
2569
|
+
);
|
|
2560
2570
|
} else if (id === "failurePreviewMaxChars") {
|
|
2561
2571
|
const n = parseInt(value, 10);
|
|
2562
|
-
if (n >= 1 && n <=
|
|
2572
|
+
if (n >= 1 && n <= FAILURE_PREVIEW_MAX_CHARS_CEILING) {
|
|
2563
2573
|
setFailurePreviewMaxChars(n);
|
|
2564
2574
|
notifyApplied(ctx, `Failure preview max chars set to ${n}`);
|
|
2565
2575
|
}
|
|
2566
2576
|
} else if (id === "disableDefaultAgents") {
|
|
2567
2577
|
const enabled = value === "on";
|
|
2578
|
+
// The setter re-registers the registry itself, so the next spawn already
|
|
2579
|
+
// sees the toggle; this applier only has to report what it orphaned.
|
|
2568
2580
|
setDisableDefaultAgents(enabled);
|
|
2569
|
-
|
|
2581
|
+
const orphaned = fallbackRow(getFallbackSubagent(), getAvailableTypes(), NO_FALLBACK, isFallbackResolvable)
|
|
2582
|
+
.unresolvable;
|
|
2583
|
+
notifyApplied(
|
|
2584
|
+
ctx,
|
|
2585
|
+
`Default agents ${enabled ? "disabled" : "enabled"}. Tool spec change takes effect on next pi session.` +
|
|
2586
|
+
(orphaned
|
|
2587
|
+
? ` Fallback agent "${getFallbackSubagent()}" no longer resolves — unknown agent types will be refused until it is changed.`
|
|
2588
|
+
: ""),
|
|
2589
|
+
);
|
|
2570
2590
|
} else if (id === "outputTranscript") {
|
|
2571
2591
|
const enabled = value === "on";
|
|
2572
|
-
|
|
2592
|
+
setOutputTranscriptDefault(enabled);
|
|
2573
2593
|
notifyApplied(ctx, `Output transcript ${enabled ? "enabled" : "disabled"} by default`);
|
|
2574
2594
|
} else if (id === "toolDescriptionMode") {
|
|
2575
2595
|
setToolDescriptionMode(value as ToolDescriptionMode);
|
|
2576
2596
|
notifyApplied(ctx, `Tool description set to ${value}. Takes effect on next pi session.`);
|
|
2597
|
+
} else if (id === "reportUsage") {
|
|
2598
|
+
const enabled = value === "on";
|
|
2599
|
+
setReportUsage(enabled);
|
|
2600
|
+
notifyApplied(
|
|
2601
|
+
ctx,
|
|
2602
|
+
enabled
|
|
2603
|
+
? "Subagent usage now counted in this session's totals"
|
|
2604
|
+
: "Subagent usage no longer counted in this session's totals",
|
|
2605
|
+
);
|
|
2606
|
+
} else if (id === "showCost") {
|
|
2607
|
+
const enabled = value === "on";
|
|
2608
|
+
setShowCost(enabled);
|
|
2609
|
+
notifyApplied(ctx, `Cost display ${enabled ? "enabled" : "disabled"}`);
|
|
2610
|
+
} else if (id === "showModel") {
|
|
2611
|
+
const enabled = value === "on";
|
|
2612
|
+
setShowModel(enabled);
|
|
2613
|
+
notifyApplied(ctx, `Model display ${enabled ? "enabled" : "disabled"}`);
|
|
2577
2614
|
} else if (id === "fleetView") {
|
|
2578
2615
|
const enabled = value === "on";
|
|
2579
2616
|
setFleetViewEnabled(enabled);
|
|
@@ -2584,7 +2621,7 @@ ${systemPrompt}
|
|
|
2584
2621
|
}
|
|
2585
2622
|
}
|
|
2586
2623
|
|
|
2587
|
-
let list: SettingsList;
|
|
2624
|
+
let list: SettingsList | undefined;
|
|
2588
2625
|
// Track current selection index directly (SettingsList doesn't expose it).
|
|
2589
2626
|
// Updated on arrow keys so Enter knows which field is selected immediately.
|
|
2590
2627
|
let currentIndex = 0;
|
|
@@ -2605,7 +2642,7 @@ ${systemPrompt}
|
|
|
2605
2642
|
const container = new Container();
|
|
2606
2643
|
container.addChild(new Text("⚙ Subagent Settings", 0, 0));
|
|
2607
2644
|
container.addChild(new Spacer(1));
|
|
2608
|
-
container.addChild(list);
|
|
2645
|
+
if (list) container.addChild(list);
|
|
2609
2646
|
|
|
2610
2647
|
return {
|
|
2611
2648
|
render: (w: number) => container.render(w),
|
|
@@ -2623,24 +2660,21 @@ ${systemPrompt}
|
|
|
2623
2660
|
done(items[currentIndex].id);
|
|
2624
2661
|
return;
|
|
2625
2662
|
}
|
|
2626
|
-
list
|
|
2663
|
+
list?.handleInput?.(data);
|
|
2627
2664
|
},
|
|
2628
2665
|
};
|
|
2629
2666
|
});
|
|
2630
2667
|
|
|
2631
2668
|
// If a numeric field ID was returned, prompt for typed input
|
|
2632
2669
|
if (result && NUMERIC_IDS.has(result)) {
|
|
2633
|
-
const current
|
|
2634
|
-
|
|
2635
|
-
:
|
|
2636
|
-
|
|
2637
|
-
|
|
2638
|
-
|
|
2639
|
-
|
|
2640
|
-
|
|
2641
|
-
: result === "defaultMaxTurns"
|
|
2642
|
-
? "Default max turns (0 = unlimited)"
|
|
2643
|
-
: "Grace turns (1+)";
|
|
2670
|
+
const numericPrompt: Record<string, { current: string; label: string }> = {
|
|
2671
|
+
maxConcurrent: { current: String(manager.getMaxConcurrent()), label: "Max concurrency (1+)" },
|
|
2672
|
+
defaultMaxTurns: { current: String(getDefaultMaxTurns() ?? 0), label: "Default max turns (0 = unlimited)" },
|
|
2673
|
+
graceTurns: { current: String(getGraceTurns()), label: "Grace turns (1+)" },
|
|
2674
|
+
maxSubagentDepth: { current: String(getMaxSubagentDepth()), label: "Nested depth (0/1 = nesting off)" },
|
|
2675
|
+
failurePreviewMaxChars: { current: String(failurePreviewMaxChars), label: "Failure preview max chars (1+)" },
|
|
2676
|
+
};
|
|
2677
|
+
const { current, label } = numericPrompt[result];
|
|
2644
2678
|
|
|
2645
2679
|
// Loop until user enters a valid integer or cancels (Esc / null).
|
|
2646
2680
|
// Silently trims whitespace; rejects non-numeric input by re-prompting.
|