@nredd/pi-subagents 0.22.0-nredd.7 → 0.22.0-nredd.9
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/README.md +245 -77
- package/package.json +16 -13
- package/src/abortable.ts +1 -1
- package/src/agent-file-toggle.ts +6 -5
- package/src/agent-manager.ts +489 -129
- package/src/agent-runner.ts +244 -93
- package/src/child-context.ts +19 -1
- package/src/completion-outbox.ts +22 -3
- package/src/cross-extension-rpc.ts +21 -42
- package/src/custom-agents.ts +11 -6
- package/src/default-agents.ts +21 -21
- package/src/detached-results.ts +18 -2
- package/src/enabled-models.ts +8 -7
- package/src/enterprise-local.ts +7 -6
- package/src/error-message.ts +11 -0
- package/src/git-path.ts +66 -0
- package/src/group-join.ts +4 -10
- package/src/home-paths.ts +27 -0
- package/src/index.ts +898 -297
- package/src/invocation-config.ts +4 -21
- package/src/memory.ts +9 -9
- package/src/mention-clone.ts +13 -8
- package/src/model-resolver.ts +92 -25
- package/src/model-scope.ts +2 -1
- package/src/model-selection.ts +170 -0
- package/src/model-tiers.ts +176 -0
- package/src/nested-tools.ts +99 -44
- package/src/notification-result.ts +118 -0
- package/src/output-file.ts +23 -1
- package/src/package-resources.ts +25 -10
- package/src/plan-mode-children.ts +176 -0
- package/src/process-budget.ts +6 -3
- package/src/prompts.ts +7 -2
- package/src/quota-admission.ts +35 -13
- package/src/resource-loader.ts +65 -0
- package/src/schedule.ts +94 -30
- package/src/settings.ts +63 -13
- package/src/skill-loader.ts +244 -93
- package/src/stale-ctx.ts +16 -0
- package/src/status-note.ts +28 -5
- package/src/types.ts +79 -2
- package/src/ui/agent-mention.ts +5 -2
- package/src/ui/agent-row.ts +13 -3
- package/src/ui/agent-widget.ts +55 -25
- package/src/ui/conversation-viewer.ts +10 -5
- package/src/ui/fallback-flag.ts +114 -0
- package/src/ui/fleet-list.ts +431 -86
- package/src/ui/fleet-state.ts +154 -0
- package/src/ui/notification-view.ts +22 -3
- package/src/ui/workflow-card.ts +6 -9
- package/src/ui/workflow-dialog.ts +36 -13
- package/src/workflow/host.ts +47 -50
- package/src/workflow/journal.ts +13 -1
- package/src/workflow/meta.ts +7 -4
- package/src/workflow/progress.ts +8 -3
- package/src/workflow/runtime.ts +48 -15
- package/src/workflow/saved.ts +21 -21
- package/src/workflow/task.ts +22 -7
- package/src/workflow/tool-description.ts +2 -2
- package/src/workflow/worker-source.ts +162 -40
- package/src/worktree.ts +309 -19
package/src/index.ts
CHANGED
|
@@ -4,7 +4,8 @@
|
|
|
4
4
|
* Tools:
|
|
5
5
|
* Agent — LLM-callable: spawn a sub-agent
|
|
6
6
|
* get_subagent_result — LLM-callable: check background agent status/result
|
|
7
|
-
* steer_subagent — LLM-callable:
|
|
7
|
+
* steer_subagent — LLM-callable: steer a running agent, or resume a finished one
|
|
8
|
+
* stop_subagent — LLM-callable: stop a running or queued agent, or a workflow run
|
|
8
9
|
*
|
|
9
10
|
* Commands:
|
|
10
11
|
* /agents — Interactive agent management menu
|
|
@@ -12,14 +13,14 @@
|
|
|
12
13
|
|
|
13
14
|
import { existsSync, mkdirSync, readFileSync, unlinkSync, writeFileSync } from "node:fs";
|
|
14
15
|
import { isAbsolute, join } from "node:path";
|
|
15
|
-
import { defineTool, type ExtensionAPI, type ExtensionCommandContext, type ExtensionContext, type ExtensionFactory,
|
|
16
|
+
import { defineTool, type ExtensionAPI, type ExtensionCommandContext, type ExtensionContext, type ExtensionFactory, getSettingsListTheme } from "@earendil-works/pi-coding-agent";
|
|
16
17
|
import { Container, isKeyRelease, Key, matchesKey, type ScrollViewScrollbar, type SettingItem, SettingsList, Spacer, Text } from "@earendil-works/pi-tui";
|
|
17
18
|
import { nanoid } from "nanoid";
|
|
18
19
|
import { Type } from "typebox";
|
|
19
20
|
import { abortable } from "./abortable.js";
|
|
20
21
|
import { hasAgentBadge, renderAgentName } from "./agent-color.js";
|
|
21
|
-
import { buildNewAgentFile, disableInContent, enableInContent, isEmptyStub, locateAgentFile, personalAgentsDir, projectAgentsDir, serializeAgentFile } from "./agent-file-toggle.js";
|
|
22
|
-
import { AgentManager, describeEvicted, isTopLevelAgent, markConsumed } from "./agent-manager.js";
|
|
22
|
+
import { buildNewAgentFile, disableInContent, enableInContent, isEmptyStub, locateAgentFile as locateAgentFileAtHome, personalAgentsDir as personalAgentsDirAtHome, projectAgentsDir, serializeAgentFile } from "./agent-file-toggle.js";
|
|
23
|
+
import { AgentManager, describeEvicted, isTopLevelAgent, markConsumed, type OwnedPending } from "./agent-manager.js";
|
|
23
24
|
import { getAgentConversation, getDefaultMaxTurns, getGraceTurns, getRememberAgents, normalizeMaxTurns, resolveEffectiveMaxTurns, SUBAGENT_TOOL_NAMES, setDefaultMaxTurns, setGraceTurns, setRememberAgents, steerAgent } from "./agent-runner.js";
|
|
24
25
|
import { rowDescription, sourceIndicator, sourceLegend } from "./agent-source-badge.js";
|
|
25
26
|
import { BUILTIN_TOOL_NAMES, getAgentConfig, getAllTypes, getAvailableTypes, getConfig, getFallbackSubagent, isDefaultsDisabled, NO_FALLBACK, registerAgents, resolveSpawnType, resolveType, setDefaultsDisabled, setFallbackSubagent } from "./agent-types.js";
|
|
@@ -31,6 +32,7 @@ import { loadCustomAgents } from "./custom-agents.js";
|
|
|
31
32
|
import {
|
|
32
33
|
type DetachedResult,
|
|
33
34
|
drainDetached,
|
|
35
|
+
listAllRunningDetached,
|
|
34
36
|
listRunningDetached,
|
|
35
37
|
registerRunningDetached,
|
|
36
38
|
spoolDetached,
|
|
@@ -38,24 +40,30 @@ import {
|
|
|
38
40
|
watchDetached,
|
|
39
41
|
} from "./detached-results.js";
|
|
40
42
|
import { readEnabledModels, resolveEnabledModels } from "./enabled-models.js";
|
|
43
|
+
import { errorMessage } from "./error-message.js";
|
|
41
44
|
import { GroupJoinManager } from "./group-join.js";
|
|
42
|
-
import {
|
|
45
|
+
import { resolveAgentDir } from "./home-paths.js";
|
|
46
|
+
import { cwdParam, isolationParam, resolveAgentInvocationConfig, resolveCwdParam } from "./invocation-config.js";
|
|
43
47
|
import { describeMention, handleBase, isReservedHandle, parseMention, resolveHandleToType, stripAgentPrefix } from "./mention.js";
|
|
44
48
|
import { runMentionClone } from "./mention-clone.js";
|
|
45
49
|
import { applyModelCallEvent, type ModelCallEvent } from "./model-call.js";
|
|
46
|
-
import {
|
|
47
|
-
import {
|
|
50
|
+
import { type ModelFallback, type ModelRegistry, setAgentModels, setModelAliases, setModelScopeProvider } from "./model-resolver.js";
|
|
51
|
+
import { isScopeModelsEnabled, setScopeModelsEnabled } from "./model-scope.js";
|
|
52
|
+
import { admitModelSelection, describeSelection, modelIdentityNote, modelRequest, modelSelectionNote, selectModel } from "./model-selection.js";
|
|
53
|
+
import { configuredTierNames, tierDisclosure } from "./model-tiers.js";
|
|
48
54
|
import { getMaxSubagentDepth, setMaxSubagentDepth } from "./nested-tools.js";
|
|
49
|
-
import {
|
|
55
|
+
import { clipNotice, DEFAULT_NOTIFICATION_RESULT_CHARS, fairShares, GROUP_PREVIEW_RESULT_CHARS, NOTIFICATION_RESULT_CHARS_CEILING, type NoticeFrame, type NoticeParts, PREVIEW_RESULT_CHARS, previewResult, renderNotice } from "./notification-result.js";
|
|
56
|
+
import { appendModelFallbackEntry, appendModelTierEntry, createOutputFilePath, ensureOutputFile, getOutputTranscriptDefault, sessionTaskDir, setOutputTranscriptDefault, streamToOutputFile, writeInitialEntry } from "./output-file.js";
|
|
50
57
|
import { getPackageAgentsGate, getPackageWorkflowsGate, invalidatePackageCache, isProjectTrusted, packageNameForPath, setPackageAgentsGate, setPackageWorkflowsGate, setProjectTrusted } from "./package-resources.js";
|
|
58
|
+
import { answerPlanGateQueries, PLAN_PARK_REFUSAL, PLAN_SCHEDULE_REFUSAL, planningNow, planSteerBlocked, planSteerRefusal } from "./plan-mode-children.js";
|
|
51
59
|
import { getBudgetPolicy, setBudgetPolicy } from "./process-budget.js";
|
|
52
60
|
import { checkAdmission, describeBlock, mayPark, modelRef, QUOTA_RESTART_GRACE_MS } from "./quota-admission.js";
|
|
53
61
|
import { SubagentScheduler } from "./schedule.js";
|
|
54
62
|
import { resolveStorePath, ScheduleStore } from "./schedule-store.js";
|
|
55
63
|
import { applyAndEmitLoaded, loadSettings, type SubagentsSettings, saveAndEmitChanged, type ToolDescriptionMode } from "./settings.js";
|
|
56
64
|
import { hostStartupErrorClass } from "./startup-error.js";
|
|
57
|
-
import { getForegroundOutcomeNote, getStatusNote, partialOutputSuffix } from "./status-note.js";
|
|
58
|
-
import { type AgentConfig, type AgentInvocation, type AgentMentionMode, type AgentRecord, type JoinMode, type NotificationDetails, type SubagentType, type ViewerMarkdownMode, type WidgetMode } from "./types.js";
|
|
65
|
+
import { getForegroundOutcomeNote, getStatusNote, partialOutputSuffix, resumeHint } from "./status-note.js";
|
|
66
|
+
import { type AgentConfig, type AgentInvocation, type AgentMentionMode, type AgentRecord, type AgentTombstone, type JoinMode, type NotificationDetails, type SubagentType, type ViewerMarkdownMode, type WidgetMode } from "./types.js";
|
|
59
67
|
import { createMentionProvider, mentionRoster, type TypeInfo } from "./ui/agent-mention.js";
|
|
60
68
|
import { AgentCallView, AgentResultView, type AgentRowLive, keepRowTicking, keepTicking } from "./ui/agent-row.js";
|
|
61
69
|
import {
|
|
@@ -75,6 +83,7 @@ import {
|
|
|
75
83
|
} from "./ui/agent-widget.js";
|
|
76
84
|
import { ConversationViewer, getConversationOverlayOptions, type ViewerBackgroundAction } from "./ui/conversation-viewer.js";
|
|
77
85
|
import { FleetList, type FleetUICtx, type FleetWorkflow } from "./ui/fleet-list.js";
|
|
86
|
+
import { FLEET_STATE_ENTRY, FLEET_STATE_VERSION } from "./ui/fleet-state.js";
|
|
78
87
|
import { renderNotification } from "./ui/notification-view.js";
|
|
79
88
|
import { showSchedulesMenu } from "./ui/schedule-menu.js";
|
|
80
89
|
import { selectItem } from "./ui/select-item.js";
|
|
@@ -111,7 +120,7 @@ function formatLifetimeTokens(o: { lifetimeUsage: LifetimeUsage }): string {
|
|
|
111
120
|
* Create an AgentActivity state and spawn callbacks for tracking tool usage.
|
|
112
121
|
* Used by both foreground and background paths to avoid duplication.
|
|
113
122
|
*/
|
|
114
|
-
function createActivityTracker(maxTurns
|
|
123
|
+
function createActivityTracker(maxTurns: number | undefined, onStreamUpdate: (() => void) | undefined, clock: Clock) {
|
|
115
124
|
const state: AgentActivity = {
|
|
116
125
|
activeTools: new Map(),
|
|
117
126
|
toolUses: 0,
|
|
@@ -167,6 +176,11 @@ function createActivityTracker(maxTurns?: number, onStreamUpdate?: () => void, c
|
|
|
167
176
|
*/
|
|
168
177
|
const THINKING_LEVELS = ["off", "minimal", "low", "medium", "high", "xhigh", "max"] as const;
|
|
169
178
|
|
|
179
|
+
/** Run time of an agent: frozen at `completedAt` once settled, else measured to `now`. */
|
|
180
|
+
function recordElapsedMs(record: { startedAt: number; completedAt?: number }, now: number): number {
|
|
181
|
+
return (record.completedAt ?? now) - record.startedAt;
|
|
182
|
+
}
|
|
183
|
+
|
|
170
184
|
/** Human-readable status label for agent completion. */
|
|
171
185
|
function getStatusLabel(status: string, error?: string): string {
|
|
172
186
|
switch (status) {
|
|
@@ -178,10 +192,13 @@ function getStatusLabel(status: string, error?: string): string {
|
|
|
178
192
|
}
|
|
179
193
|
}
|
|
180
194
|
|
|
181
|
-
/**
|
|
182
|
-
|
|
195
|
+
/**
|
|
196
|
+
* Format a structured task notification matching Claude Code's <task-notification> XML.
|
|
197
|
+
* Everything but the `<result>` is read now; `renderNotice` fills that in.
|
|
198
|
+
*/
|
|
199
|
+
function formatTaskNotification(record: AgentRecord, showCost: boolean): NoticeFrame {
|
|
183
200
|
const status = getStatusLabel(record.status, record.error);
|
|
184
|
-
const durationMs = record
|
|
201
|
+
const durationMs = recordElapsedMs(record, Date.now());
|
|
185
202
|
const totalTokens = getLifetimeTotal(record.lifetimeUsage);
|
|
186
203
|
const contextPercent = getSessionContextPercent(record.session);
|
|
187
204
|
const ctxXml = contextPercent !== null ? `<context_percent>${Math.round(contextPercent)}</context_percent>` : "";
|
|
@@ -191,28 +208,34 @@ function formatTaskNotification(record: AgentRecord, resultMaxLen: number, showC
|
|
|
191
208
|
const cost = showCost ? getLifetimeCost(record.lifetimeUsage) : 0;
|
|
192
209
|
const costXml = cost > 0 ? `<estimated_cost_usd>${cost.toFixed(4)}</estimated_cost_usd>` : "";
|
|
193
210
|
|
|
194
|
-
const
|
|
195
|
-
|
|
196
|
-
? record.result.slice(0, resultMaxLen) + "\n...(truncated, use get_subagent_result for full output)"
|
|
197
|
-
: record.result
|
|
198
|
-
: "No output.";
|
|
211
|
+
const modelNote = [modelSelectionNote(record), modelIdentityNote(record)].filter(Boolean).join("\n");
|
|
212
|
+
const hint = resumeHint(record);
|
|
199
213
|
|
|
200
|
-
|
|
214
|
+
const head = [
|
|
201
215
|
`<task-notification>`,
|
|
202
216
|
`<task-id>${record.id}</task-id>`,
|
|
203
217
|
record.toolCallId ? `<tool-use-id>${escapeXml(record.toolCallId)}</tool-use-id>` : null,
|
|
204
218
|
record.outputFile ? `<output-file>${escapeXml(record.outputFile)}</output-file>` : null,
|
|
205
219
|
`<status>${escapeXml(status)}</status>`,
|
|
206
|
-
`<summary>Agent "${escapeXml(record.description)}" ${record.status}${getStatusNote(record.status)}</summary>`,
|
|
207
|
-
`<
|
|
220
|
+
`<summary>Agent "${escapeXml(record.description)}" ${record.status}${getStatusNote(record.status, record.stoppedBy)}${hint ? ` (${hint})` : ""}</summary>`,
|
|
221
|
+
modelNote ? `<model-note>${escapeXml(modelNote)}</model-note>` : null,
|
|
222
|
+
].filter(Boolean).join('\n');
|
|
223
|
+
const tail = [
|
|
208
224
|
`<usage><total_tokens>${totalTokens}</total_tokens><tool_uses>${record.toolUses}</tool_uses>${ctxXml}${compactXml}${costXml}<duration_ms>${durationMs}</duration_ms></usage>`,
|
|
209
225
|
`</task-notification>`,
|
|
210
|
-
].
|
|
226
|
+
].join('\n');
|
|
227
|
+
return { head, tail };
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
/** What a full-result notice carries: the same text `get_subagent_result` returns for a finished agent. */
|
|
231
|
+
function noticeBody(record: AgentRecord): string {
|
|
232
|
+
if (record.status === "error") return `Error: ${record.error ?? "unknown"}${partialOutputSuffix(record)}`;
|
|
233
|
+
return record.result?.trim() || "No output.";
|
|
211
234
|
}
|
|
212
235
|
|
|
213
236
|
/** Build AgentDetails from a base + record-specific fields. */
|
|
214
237
|
function buildDetails(
|
|
215
|
-
base: Pick<AgentDetails, "displayName" | "description" | "subagentType" | "modelName" | "tags">,
|
|
238
|
+
base: Pick<AgentDetails, "displayName" | "description" | "subagentType" | "modelName" | "modelNote" | "modelFallback" | "modelTier" | "tags">,
|
|
216
239
|
record: { toolUses: number; startedAt: number; completedAt?: number; status: string; error?: string; id?: string; session?: any; outputFile?: string; lifetimeUsage: LifetimeUsage },
|
|
217
240
|
activity?: AgentActivity,
|
|
218
241
|
overrides?: Partial<AgentDetails>,
|
|
@@ -227,7 +250,7 @@ function buildDetails(
|
|
|
227
250
|
cost: getLifetimeCost(record.lifetimeUsage),
|
|
228
251
|
turnCount: activity?.turnCount,
|
|
229
252
|
maxTurns: activity?.maxTurns,
|
|
230
|
-
durationMs: (record
|
|
253
|
+
durationMs: recordElapsedMs(record, Date.now()),
|
|
231
254
|
status: record.status as AgentDetails["status"],
|
|
232
255
|
agentId: record.id,
|
|
233
256
|
error: record.error,
|
|
@@ -237,6 +260,31 @@ function buildDetails(
|
|
|
237
260
|
}
|
|
238
261
|
|
|
239
262
|
/** Build notification details for the custom message renderer. */
|
|
263
|
+
|
|
264
|
+
/**
|
|
265
|
+
* The settle report for owned agents still pending, or `undefined` when none
|
|
266
|
+
* are. Every package's settle notice shares one shape,
|
|
267
|
+
* `Run ended with N <work>, armed: <what wakes the parent>`, and names only
|
|
268
|
+
* its own work, so one package saying nothing of its own is armed cannot read
|
|
269
|
+
* as "nothing at all is armed" beside another's armed monitor.
|
|
270
|
+
*
|
|
271
|
+
* A background agent's completion notice starts a parent turn in every join
|
|
272
|
+
* mode. Two edges qualify that: after a session switch the notice is spooled
|
|
273
|
+
* for the session it belongs to and lands when that session is live again, and
|
|
274
|
+
* a held send that outlives a tearing-down `pi -p` is dropped. Foreground
|
|
275
|
+
* agents and RPC spawns without `isBackground` are marked consumed at settle
|
|
276
|
+
* and notify nobody, so they are named as not armed.
|
|
277
|
+
*/
|
|
278
|
+
function formatOwnedPendingNotice({ notifying, silent }: OwnedPending): string | undefined {
|
|
279
|
+
const total = notifying + silent;
|
|
280
|
+
if (total === 0) return undefined;
|
|
281
|
+
const plural = (n: number) => (n === 1 ? "" : "s");
|
|
282
|
+
const armed = notifying > 0
|
|
283
|
+
? `${notifying} agent completion notice${plural(notifying)} ${notifying === 1 ? "wakes" : "wake"} the parent`
|
|
284
|
+
: "no agent completion notice wakes the parent";
|
|
285
|
+
const unarmed = silent > 0 ? `; ${silent} foreground or RPC-spawned agent${plural(silent)} will not` : "";
|
|
286
|
+
return `Run ended with ${total} agent${plural(total)} running or queued, armed: ${armed}${unarmed}`;
|
|
287
|
+
}
|
|
240
288
|
function buildNotificationDetails(record: AgentRecord, resultMaxLen: number, activity?: AgentActivity): NotificationDetails {
|
|
241
289
|
const totalTokens = getLifetimeTotal(record.lifetimeUsage);
|
|
242
290
|
|
|
@@ -252,9 +300,12 @@ function buildNotificationDetails(record: AgentRecord, resultMaxLen: number, act
|
|
|
252
300
|
// data, and a notification rendered before a mid-session toggle should not
|
|
253
301
|
// be stuck with the old answer.
|
|
254
302
|
totalCost: getLifetimeCost(record.lifetimeUsage),
|
|
255
|
-
durationMs: record
|
|
303
|
+
durationMs: recordElapsedMs(record, Date.now()),
|
|
256
304
|
outputFile: record.outputFile,
|
|
257
305
|
error: record.error,
|
|
306
|
+
resumeHint: resumeHint(record) || undefined,
|
|
307
|
+
modelNote: modelSelectionNote(record) || undefined,
|
|
308
|
+
modelTier: record.modelTier,
|
|
258
309
|
resultPreview: record.result
|
|
259
310
|
? record.result.length > resultMaxLen
|
|
260
311
|
? record.result.slice(0, resultMaxLen) + "…"
|
|
@@ -287,12 +338,18 @@ function buildNotificationDetails(record: AgentRecord, resultMaxLen: number, act
|
|
|
287
338
|
* agents and those with `extensions: false`.
|
|
288
339
|
*/
|
|
289
340
|
export function formatToolsSuffix(cfg: AgentConfig | undefined): string {
|
|
290
|
-
const
|
|
341
|
+
const denied = cfg?.disallowedTools ?? [];
|
|
342
|
+
const tools = denied.length > 0
|
|
343
|
+
? (cfg?.builtinToolNames ?? BUILTIN_TOOL_NAMES).filter((tool) => !denied.includes(tool))
|
|
344
|
+
: cfg?.builtinToolNames;
|
|
345
|
+
// Only claim what is known at registration: extensions may supply command
|
|
346
|
+
// tools (including ssh_exec), so missing bash is not proof of no remote access.
|
|
347
|
+
const shellNote = tools && !tools.includes("bash") ? "; no built-in shell: cannot run bash commands" : "";
|
|
291
348
|
// `isolated` overrides extensions to false in the runner, so both mean the
|
|
292
349
|
// agent has no extension tools.
|
|
293
350
|
const noExtensionTools = cfg?.isolated === true || cfg?.extensions === false;
|
|
294
351
|
if (tools && tools.length === 0) {
|
|
295
|
-
return noExtensionTools ? "none" : "no built-ins, extension tools only";
|
|
352
|
+
return (noExtensionTools ? "none" : "no built-ins, extension tools only") + shellNote;
|
|
296
353
|
}
|
|
297
354
|
const isFullSet = !tools
|
|
298
355
|
|| (tools.length === BUILTIN_TOOL_NAMES.length && BUILTIN_TOOL_NAMES.every((t) => tools.includes(t)));
|
|
@@ -300,7 +357,7 @@ export function formatToolsSuffix(cfg: AgentConfig | undefined): string {
|
|
|
300
357
|
// Extension tools resolve at run time, so they cannot be enumerated here, but
|
|
301
358
|
// omitting the fact reads as "built-ins only": an agent that can search the
|
|
302
359
|
// web through an extension then looks web-less, and the work gets routed away.
|
|
303
|
-
return noExtensionTools ? builtins : `${builtins} + extension tools
|
|
360
|
+
return (noExtensionTools ? builtins : `${builtins} + extension tools`) + shellNote;
|
|
304
361
|
}
|
|
305
362
|
|
|
306
363
|
/** CLI flag that runs a workflow script at session start. */
|
|
@@ -320,6 +377,8 @@ export type { Clock, TimerHandle } from "./clock.js";
|
|
|
320
377
|
|
|
321
378
|
/** Optional host dependencies, shared by the scheduler and completion delivery. */
|
|
322
379
|
export interface SubagentsExtensionOptions {
|
|
380
|
+
/** Home for every user-scoped read and child run. Omitted uses host defaults at call time. */
|
|
381
|
+
homeDir?: string;
|
|
323
382
|
clock?: Clock;
|
|
324
383
|
}
|
|
325
384
|
|
|
@@ -332,6 +391,10 @@ export default createSubagentsExtension();
|
|
|
332
391
|
|
|
333
392
|
function activateSubagents(pi: ExtensionAPI, options: SubagentsExtensionOptions) {
|
|
334
393
|
const clock = options.clock ?? realClock;
|
|
394
|
+
const getAgentDir = () => resolveAgentDir(options.homeDir);
|
|
395
|
+
const personalAgentsDir = () => personalAgentsDirAtHome(options.homeDir);
|
|
396
|
+
const locateAgentFile: typeof locateAgentFileAtHome = (name, path, cwd, source) =>
|
|
397
|
+
locateAgentFileAtHome(name, path, cwd, source, options.homeDir);
|
|
335
398
|
// Child AgentSessions load normal extensions. Re-entering this extension there
|
|
336
399
|
// would create another manager and leak handlers. Nested orchestration is
|
|
337
400
|
// injected as scoped custom tools by the existing manager instead.
|
|
@@ -345,7 +408,7 @@ function activateSubagents(pi: ExtensionAPI, options: SubagentsExtensionOptions)
|
|
|
345
408
|
try {
|
|
346
409
|
getBudgetPolicy();
|
|
347
410
|
} catch (error) {
|
|
348
|
-
const message =
|
|
411
|
+
const message = errorMessage(error);
|
|
349
412
|
if (!StartupError) throw new Error(message + " This host also lacks ExtensionStartupError; upgrade pi before retrying.", { cause: error });
|
|
350
413
|
pi.on("session_start", async () => { throw new StartupError(message, { cause: error }); });
|
|
351
414
|
return;
|
|
@@ -383,11 +446,15 @@ function activateSubagents(pi: ExtensionAPI, options: SubagentsExtensionOptions)
|
|
|
383
446
|
});
|
|
384
447
|
|
|
385
448
|
// Fuzzy model names ("opus") prefer what the user enabled in pi's settings.
|
|
386
|
-
setModelScopeProvider(registry
|
|
449
|
+
setModelScopeProvider((registry, context) => {
|
|
450
|
+
const cwd = context?.cwd ?? process.cwd();
|
|
451
|
+
const homeDir = context?.homeDir ?? options.homeDir;
|
|
452
|
+
return resolveEnabledModels(readEnabledModels(cwd, homeDir), registry, cwd, homeDir);
|
|
453
|
+
});
|
|
387
454
|
|
|
388
455
|
// Read directly rather than waiting for applyAndEmitLoaded below: these decide
|
|
389
456
|
// the initial load, which happens hundreds of lines before settings are applied.
|
|
390
|
-
const bootSettings = loadSettings(process.cwd());
|
|
457
|
+
const bootSettings = loadSettings(process.cwd(), options.homeDir);
|
|
391
458
|
let strictAgentFiles = bootSettings.strictAgentFiles === true;
|
|
392
459
|
// Strict agent files fail a bind by throwing the host's `ExtensionStartupError`
|
|
393
460
|
// from `session_start`. A host without that class would silently degrade to
|
|
@@ -412,7 +479,7 @@ function activateSubagents(pi: ExtensionAPI, options: SubagentsExtensionOptions)
|
|
|
412
479
|
|
|
413
480
|
/** Reload agents from package/global/project agent dirs and merge with defaults (called on init and each Agent invocation). */
|
|
414
481
|
const reloadCustomAgents = (strict = false) => {
|
|
415
|
-
const userAgents = loadCustomAgents(process.cwd(), strict);
|
|
482
|
+
const userAgents = loadCustomAgents(process.cwd(), strict, { homeDir: options.homeDir });
|
|
416
483
|
registerAgents(userAgents);
|
|
417
484
|
};
|
|
418
485
|
|
|
@@ -464,6 +531,8 @@ function activateSubagents(pi: ExtensionAPI, options: SubagentsExtensionOptions)
|
|
|
464
531
|
persistSettings(ctx, `Viewer markdown set to ${mode}`);
|
|
465
532
|
}
|
|
466
533
|
const pendingUsage = new PendingUsagePool();
|
|
534
|
+
/** Result characters a completion notice carries per agent; `0` keeps the preview (see notification-result.ts). */
|
|
535
|
+
let notificationResultChars = DEFAULT_NOTIFICATION_RESULT_CHARS;
|
|
467
536
|
|
|
468
537
|
// ---- Completion delivery ----
|
|
469
538
|
// Every completion notification goes through the outbox, which sends what is
|
|
@@ -471,15 +540,62 @@ function activateSubagents(pi: ExtensionAPI, options: SubagentsExtensionOptions)
|
|
|
471
540
|
// completion-outbox.ts). Each is built when the agent finishes, and reading
|
|
472
541
|
// the result (get_subagent_result, rpc consume) cancels it before it is sent.
|
|
473
542
|
const outbox = new CompletionOutbox(
|
|
474
|
-
(message) =>
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
543
|
+
(message) => {
|
|
544
|
+
pi.sendMessage<NotificationDetails>({
|
|
545
|
+
customType: "subagent-notification",
|
|
546
|
+
content: message.content,
|
|
547
|
+
display: true,
|
|
548
|
+
details: message.details,
|
|
549
|
+
}, { deliverAs: "steer", triggerTurn: true });
|
|
550
|
+
for (const id of message.noticed ?? []) {
|
|
551
|
+
const record = manager.getRecord(id);
|
|
552
|
+
// A resume started since: that run's notice says nothing about this one.
|
|
553
|
+
if (record && record.status !== "running" && record.status !== "queued") record.noticeDelivered = true;
|
|
554
|
+
}
|
|
555
|
+
// The whole result reached the parent, as get_subagent_result would hand it over.
|
|
556
|
+
for (const id of message.delivered ?? []) {
|
|
557
|
+
const record = manager.getRecord(id);
|
|
558
|
+
// A resume started since: that run's result is still unread.
|
|
559
|
+
if (!record || record.status === "running" || record.status === "queued") continue;
|
|
560
|
+
markConsumed(record, clock.now());
|
|
561
|
+
}
|
|
562
|
+
},
|
|
480
563
|
() => currentCtx?.signal !== undefined,
|
|
481
564
|
options.clock,
|
|
565
|
+
(chars) => {
|
|
566
|
+
if (notificationResultChars === 0) return {};
|
|
567
|
+
const ids = Object.keys(chars);
|
|
568
|
+
const shares = fairShares(ids.map(id => chars[id]), notificationResultChars);
|
|
569
|
+
return Object.fromEntries(ids.map((id, i) => [id, shares[i]]));
|
|
570
|
+
},
|
|
482
571
|
);
|
|
572
|
+
|
|
573
|
+
/**
|
|
574
|
+
* `record`'s notice, read now so a resume before the send cannot rewrite it.
|
|
575
|
+
* `render` fills in the result clipped to the share the batch allots it, or
|
|
576
|
+
* the `notificationResultChars: 0` preview of `previewChars`.
|
|
577
|
+
*/
|
|
578
|
+
function snapshotNotice(record: AgentRecord, previewChars: number) {
|
|
579
|
+
const frame = formatTaskNotification(record, showCost);
|
|
580
|
+
const details = buildNotificationDetails(record, previewChars, agentActivity.get(record.id));
|
|
581
|
+
if (notificationResultChars === 0) {
|
|
582
|
+
// A preview never counts as the whole result, even a short one: reap notices stay on.
|
|
583
|
+
const text = renderNotice(frame, record.result ? previewResult(record.result, previewChars) : "No output.");
|
|
584
|
+
return { id: record.id, details, chars: undefined, parts: undefined, render: (_share?: number) => ({ text, delivered: false, complete: false }) };
|
|
585
|
+
}
|
|
586
|
+
const parts: NoticeParts = {
|
|
587
|
+
...frame, body: noticeBody(record), cap: notificationResultChars, outputFile: record.outputFile,
|
|
588
|
+
// A failure stays unread: delivering its notice must not acknowledge it in FleetView (README, FleetView).
|
|
589
|
+
failed: record.status === "error" || record.status === "aborted",
|
|
590
|
+
};
|
|
591
|
+
return { id: record.id, details, chars: parts.body.length, parts, render: (share?: number) => clipNotice(parts, share) };
|
|
592
|
+
}
|
|
593
|
+
|
|
594
|
+
/** Full result lengths of the notices that have one, for the batch budget. */
|
|
595
|
+
function resultCharsOf(notices: readonly ReturnType<typeof snapshotNotice>[]): Record<string, number> | undefined {
|
|
596
|
+
const withChars = notices.filter(n => n.chars !== undefined);
|
|
597
|
+
return withChars.length > 0 ? Object.fromEntries(withChars.map(n => [n.id, n.chars])) : undefined;
|
|
598
|
+
}
|
|
483
599
|
// A queued result wait must observe completion before its held notification
|
|
484
600
|
// can fire, so successful waits can still suppress that redundant nudge.
|
|
485
601
|
const QUEUE_WAIT_POLL_MS = Math.floor(NOTIFICATION_HOLD_MS / 4);
|
|
@@ -492,11 +608,19 @@ function activateSubagents(pi: ExtensionAPI, options: SubagentsExtensionOptions)
|
|
|
492
608
|
// cannot be formatted is skipped, as a failed send always was.
|
|
493
609
|
try {
|
|
494
610
|
const footer = record.outputFile ? `\nFull transcript available at: ${record.outputFile}` : '';
|
|
495
|
-
const
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
611
|
+
const notice = snapshotNotice(record, PREVIEW_RESULT_CHARS);
|
|
612
|
+
outbox.add({
|
|
613
|
+
ids: [record.id],
|
|
614
|
+
resultChars: resultCharsOf([notice]),
|
|
615
|
+
build: (_ids, caps) => {
|
|
616
|
+
const { text, delivered, complete } = notice.render(caps[notice.id]);
|
|
617
|
+
return {
|
|
618
|
+
content: text + footer, details: notice.details,
|
|
619
|
+
...(delivered ? { delivered: [notice.id] } : {}),
|
|
620
|
+
...(complete ? { noticed: [notice.id] } : {}),
|
|
621
|
+
};
|
|
622
|
+
},
|
|
623
|
+
});
|
|
500
624
|
} catch { /* ignore stale completion side-effect errors */ }
|
|
501
625
|
widget.update();
|
|
502
626
|
}
|
|
@@ -508,15 +632,21 @@ function activateSubagents(pi: ExtensionAPI, options: SubagentsExtensionOptions)
|
|
|
508
632
|
|
|
509
633
|
const members = records.filter(r => !r.resultConsumed).flatMap(r => {
|
|
510
634
|
try {
|
|
511
|
-
return [
|
|
635
|
+
return [snapshotNotice(r, GROUP_PREVIEW_RESULT_CHARS)];
|
|
512
636
|
} catch { return []; /* as in sendIndividualNudge */ }
|
|
513
637
|
});
|
|
638
|
+
// Only the preview needs the pointer; a clipped full result carries its own.
|
|
639
|
+
const trailer = notificationResultChars === 0 ? "\n\nUse get_subagent_result for full output." : "";
|
|
514
640
|
if (members.length > 0) {
|
|
515
641
|
outbox.add({
|
|
516
642
|
ids: members.map(m => m.id),
|
|
517
|
-
|
|
643
|
+
resultChars: resultCharsOf(members),
|
|
644
|
+
build: (ids, caps) => {
|
|
518
645
|
const unread = members.filter(m => ids.includes(m.id));
|
|
519
|
-
const
|
|
646
|
+
const rendered = unread.map(m => ({ id: m.id, ...m.render(caps[m.id]) }));
|
|
647
|
+
const notifications = rendered.map(m => m.text).join('\n\n');
|
|
648
|
+
const delivered = rendered.filter(m => m.delivered).map(m => m.id);
|
|
649
|
+
const noticed = rendered.filter(m => m.complete).map(m => m.id);
|
|
520
650
|
const label = partial
|
|
521
651
|
? `${unread.length} agent(s) finished (partial — others still running)`
|
|
522
652
|
: `${unread.length} agent(s) finished`;
|
|
@@ -525,8 +655,10 @@ function activateSubagents(pi: ExtensionAPI, options: SubagentsExtensionOptions)
|
|
|
525
655
|
const details = first.details;
|
|
526
656
|
if (rest.length > 0) details.others = rest.map(m => m.details);
|
|
527
657
|
return {
|
|
528
|
-
content: `Background agent group completed: ${label}\n\n${notifications}
|
|
658
|
+
content: `Background agent group completed: ${label}\n\n${notifications}${trailer}`,
|
|
529
659
|
details,
|
|
660
|
+
...(delivered.length > 0 ? { delivered } : {}),
|
|
661
|
+
...(noticed.length > 0 ? { noticed } : {}),
|
|
530
662
|
};
|
|
531
663
|
},
|
|
532
664
|
});
|
|
@@ -538,7 +670,7 @@ function activateSubagents(pi: ExtensionAPI, options: SubagentsExtensionOptions)
|
|
|
538
670
|
|
|
539
671
|
/** Helper: build event data for lifecycle events from an AgentRecord. */
|
|
540
672
|
function buildEventData(record: AgentRecord) {
|
|
541
|
-
const durationMs = record
|
|
673
|
+
const durationMs = recordElapsedMs(record, Date.now());
|
|
542
674
|
// All three fields are lifetime-accumulated (Σ over every assistant message_end),
|
|
543
675
|
// so they survive compaction together — input + output ≤ total always.
|
|
544
676
|
// tokens is omitted when nothing was ever produced (e.g. agent errored before
|
|
@@ -564,6 +696,7 @@ function activateSubagents(pi: ExtensionAPI, options: SubagentsExtensionOptions)
|
|
|
564
696
|
id: record.id,
|
|
565
697
|
type: record.type,
|
|
566
698
|
description: record.description,
|
|
699
|
+
...eventFields(record),
|
|
567
700
|
result: record.result,
|
|
568
701
|
error: record.error,
|
|
569
702
|
status: record.status,
|
|
@@ -571,6 +704,8 @@ function activateSubagents(pi: ExtensionAPI, options: SubagentsExtensionOptions)
|
|
|
571
704
|
durationMs,
|
|
572
705
|
tokens,
|
|
573
706
|
usage,
|
|
707
|
+
// Stamped by the only caller, the settle path, just before this runs.
|
|
708
|
+
settleSeq: record.settleSeq,
|
|
574
709
|
};
|
|
575
710
|
}
|
|
576
711
|
|
|
@@ -580,19 +715,84 @@ function activateSubagents(pi: ExtensionAPI, options: SubagentsExtensionOptions)
|
|
|
580
715
|
*/
|
|
581
716
|
let detachedSessionFile: string | undefined;
|
|
582
717
|
|
|
718
|
+
/**
|
|
719
|
+
* Every lifecycle event's identity fields, then `fallbackFields`. `handle` (`explore-2`)
|
|
720
|
+
* and `spawnSeq` are always set: every caller announces a top-level agent, which the
|
|
721
|
+
* manager gives a handle, and the manager stamps `spawnSeq` on every record it creates.
|
|
722
|
+
* `name` (the handle drawn from the spawner's `name`, `auth-audit-2`), `nameBase` (that
|
|
723
|
+
* name before collision numbering, `auth-audit`) and `previousId` (the evicted id a
|
|
724
|
+
* reopen continues) are each omitted when absent, so a listener such as pi-todo can
|
|
725
|
+
* match an agent by what the parent typed, not only its id. `spawnSeq` orders the spawn
|
|
726
|
+
* against other spawns and `settleSeq`s, queued time included.
|
|
727
|
+
*/
|
|
728
|
+
const lifecycleRuns = new WeakMap<AgentRecord, number>();
|
|
729
|
+
function eventFields(record: AgentRecord | undefined): Record<string, unknown> {
|
|
730
|
+
return {
|
|
731
|
+
handle: record?.handle,
|
|
732
|
+
...(record?.alias ? { name: record.alias } : {}),
|
|
733
|
+
...(record?.nameBase ? { nameBase: record.nameBase } : {}),
|
|
734
|
+
...(record?.previousId ? { previousId: record.previousId } : {}),
|
|
735
|
+
spawnSeq: record?.spawnSeq,
|
|
736
|
+
...(record && lifecycleRuns.has(record) ? { runSeq: lifecycleRuns.get(record) } : {}),
|
|
737
|
+
wakesParent: record?.isBackground === true,
|
|
738
|
+
...fallbackFields(record),
|
|
739
|
+
};
|
|
740
|
+
}
|
|
741
|
+
|
|
742
|
+
/** `modelFallback` + `modelNote` event fields, or nothing. Only what is KNOWN when the event fires. */
|
|
743
|
+
function fallbackFields(record: AgentRecord | undefined): Record<string, unknown> {
|
|
744
|
+
return {
|
|
745
|
+
...(record?.modelTier ? { modelTier: record.modelTier } : {}),
|
|
746
|
+
...(record?.modelFallback ? { modelFallback: record.modelFallback } : {}),
|
|
747
|
+
...(modelSelectionNote(record) ? { modelNote: modelSelectionNote(record) } : {}),
|
|
748
|
+
};
|
|
749
|
+
}
|
|
750
|
+
|
|
751
|
+
/** The `subagents:record` history entry; the ONE builder, shared by live completion and the detached spool. */
|
|
752
|
+
function historyRecord(record: AgentRecord): Record<string, unknown> {
|
|
753
|
+
return {
|
|
754
|
+
id: record.id, type: record.type, description: record.description,
|
|
755
|
+
status: record.status, result: record.result, error: record.error,
|
|
756
|
+
startedAt: record.startedAt, completedAt: record.completedAt,
|
|
757
|
+
...(record.modelFallback ? { modelFallback: record.modelFallback } : {}),
|
|
758
|
+
...(record.modelTier ? { modelTier: record.modelTier } : {}),
|
|
759
|
+
};
|
|
760
|
+
}
|
|
761
|
+
|
|
762
|
+
/**
|
|
763
|
+
* A survivor's lifecycle payload for a session that never heard its spawn: the
|
|
764
|
+
* orphaned manager's own `spawnSeq`/`runSeq`/`settleSeq` count a different sequence
|
|
765
|
+
* than this one, so they are dropped rather than compared. Only background runs
|
|
766
|
+
* survive a switch (`AgentManager.detach`), so `wakesParent` is already true.
|
|
767
|
+
*/
|
|
768
|
+
function survivorEventData(data: Record<string, unknown>): Record<string, unknown> {
|
|
769
|
+
const { spawnSeq: _spawn, runSeq: _run, settleSeq: _settle, ...rest } = data;
|
|
770
|
+
return rest;
|
|
771
|
+
}
|
|
772
|
+
|
|
773
|
+
/** The terminal lifecycle channel for a settled record; shared by the live and the detached path. */
|
|
774
|
+
function lifecycleChannel(record: AgentRecord): "subagents:failed" | "subagents:completed" {
|
|
775
|
+
return record.status === "error" || record.status === "stopped" || record.status === "aborted"
|
|
776
|
+
? "subagents:failed" : "subagents:completed";
|
|
777
|
+
}
|
|
778
|
+
|
|
583
779
|
function spoolDetachedResult(sessionFile: string, record: AgentRecord) {
|
|
584
780
|
if (record.resultConsumed) return;
|
|
585
781
|
const footer = record.outputFile ? `\nFull transcript available at: ${record.outputFile}` : "";
|
|
586
782
|
try {
|
|
783
|
+
const notice = snapshotNotice(record, PREVIEW_RESULT_CHARS);
|
|
587
784
|
spoolDetached(sessionFile, {
|
|
588
785
|
id: record.id,
|
|
589
|
-
content:
|
|
590
|
-
details:
|
|
591
|
-
record:
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
786
|
+
content: notice.render().text + footer,
|
|
787
|
+
details: notice.details,
|
|
788
|
+
record: historyRecord(record),
|
|
789
|
+
evicted: manager.evictionNotes([record.id]),
|
|
790
|
+
tombstones: manager.evictionTombstones([record.id]),
|
|
791
|
+
lifecycle: {
|
|
792
|
+
channel: lifecycleChannel(record),
|
|
793
|
+
data: survivorEventData(buildEventData(record)),
|
|
595
794
|
},
|
|
795
|
+
...(notice.parts ? { notice: { ...notice.parts, footer } } : {}),
|
|
596
796
|
});
|
|
597
797
|
} catch { /* spool unwritable: the transcript file still has the result */ }
|
|
598
798
|
}
|
|
@@ -602,18 +802,62 @@ function activateSubagents(pi: ExtensionAPI, options: SubagentsExtensionOptions)
|
|
|
602
802
|
for (const result of drainDetached(sessionFile)) deliverDetachedResult(result);
|
|
603
803
|
}
|
|
604
804
|
|
|
805
|
+
/**
|
|
806
|
+
* Announce every agent still running in an orphaned manager, which this session never
|
|
807
|
+
* heard spawn, so lifecycle listeners (e.g. pi-todo) see their owners alive instead of lost.
|
|
808
|
+
* Runs at every `session_start` and on `subagents:announce_survivors`, for a listener that
|
|
809
|
+
* bound after it. Survivors registered under the current session file are announced as
|
|
810
|
+
* `subagents:started` (armed: their results spool here and wake this session). All others,
|
|
811
|
+
* whatever switch left them (`/fork`, `/resume`, `/new`, `/reload`), are announced as
|
|
812
|
+
* `subagents:other_session_survivor` with the `sessionFile` they wake instead, and arm nothing here.
|
|
813
|
+
*/
|
|
814
|
+
function announceSurvivors() {
|
|
815
|
+
const current = currentCtx?.sessionManager?.getSessionFile?.();
|
|
816
|
+
for (const { sessionFile, agent } of listAllRunningDetached()) {
|
|
817
|
+
const { record } = agent;
|
|
818
|
+
const own = sessionFile === current;
|
|
819
|
+
const { wakesParent: _wakes, ...fields } = eventFields(record);
|
|
820
|
+
pi.events.emit(own ? "subagents:started" : "subagents:other_session_survivor", survivorEventData({
|
|
821
|
+
id: record.id,
|
|
822
|
+
type: record.type,
|
|
823
|
+
description: record.description,
|
|
824
|
+
...fields,
|
|
825
|
+
...(own ? { wakesParent: true } : { sessionFile }),
|
|
826
|
+
}));
|
|
827
|
+
}
|
|
828
|
+
}
|
|
829
|
+
|
|
605
830
|
function deliverDetachedResult(result: DetachedResult) {
|
|
606
831
|
try {
|
|
607
|
-
|
|
832
|
+
// First: the notice names this id, and a read of it must not say "not found".
|
|
833
|
+
for (const note of result.evicted ?? []) manager.rememberEvicted(note);
|
|
834
|
+
for (const entry of result.tombstones ?? []) manager.rememberTombstone(entry);
|
|
835
|
+
// Before the notice: a listener must see the owner settle, not stay "running".
|
|
836
|
+
if (result.lifecycle) pi.events.emit(result.lifecycle.channel, result.lifecycle.data);
|
|
837
|
+
if (result.record) {
|
|
838
|
+
pi.appendEntry("subagents:record", result.record);
|
|
839
|
+
fleet.ingestFailure(result.record);
|
|
840
|
+
fleet.update();
|
|
841
|
+
}
|
|
608
842
|
const details = result.details as NotificationDetails;
|
|
843
|
+
// Re-clipped here, so it shares the budget of whatever lands with it. Its
|
|
844
|
+
// record lives in the orphaned manager, so nothing is marked read.
|
|
845
|
+
const notice = typeof result.notice?.body === "string" ? result.notice : undefined;
|
|
609
846
|
outbox.add({
|
|
610
847
|
ids: [details.id, ...(details.others?.map(other => other.id) ?? [])],
|
|
611
|
-
|
|
848
|
+
...(notice ? { resultChars: { [details.id]: notice.body.length } } : {}),
|
|
849
|
+
build: (_ids, caps) => notice
|
|
850
|
+
? { content: clipNotice(notice, caps[details.id]).text + notice.footer, details }
|
|
851
|
+
: { content: result.content, details },
|
|
612
852
|
});
|
|
613
853
|
} catch { /* session already gone again; the transcript file still has the result */ }
|
|
614
854
|
}
|
|
615
855
|
|
|
616
856
|
let unwatchDetached: (() => void) | undefined;
|
|
857
|
+
let unwatchAnnounce: (() => void) | undefined;
|
|
858
|
+
|
|
859
|
+
// At load, before pi-plan-mode's session_start ask: Plan admits `Agent` only with this gate (#437).
|
|
860
|
+
answerPlanGateQueries(pi.events);
|
|
617
861
|
|
|
618
862
|
// Background completion: route through group join or send individual nudge
|
|
619
863
|
const manager = new AgentManager((record) => {
|
|
@@ -631,21 +875,13 @@ function activateSubagents(pi: ExtensionAPI, options: SubagentsExtensionOptions)
|
|
|
631
875
|
return;
|
|
632
876
|
}
|
|
633
877
|
|
|
878
|
+
if (!record.resultConsumed) fleet.ingestFailure(record);
|
|
634
879
|
// Emit lifecycle event based on terminal status
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
if (isError) {
|
|
638
|
-
pi.events.emit("subagents:failed", eventData);
|
|
639
|
-
} else {
|
|
640
|
-
pi.events.emit("subagents:completed", eventData);
|
|
641
|
-
}
|
|
880
|
+
record.settleSeq = manager.nextSequence();
|
|
881
|
+
pi.events.emit(lifecycleChannel(record), buildEventData(record));
|
|
642
882
|
|
|
643
883
|
// Persist final record for cross-extension history reconstruction
|
|
644
|
-
pi.appendEntry("subagents:record",
|
|
645
|
-
id: record.id, type: record.type, description: record.description,
|
|
646
|
-
status: record.status, result: record.result, error: record.error,
|
|
647
|
-
startedAt: record.startedAt, completedAt: record.completedAt,
|
|
648
|
-
});
|
|
884
|
+
pi.appendEntry("subagents:record", historyRecord(record));
|
|
649
885
|
|
|
650
886
|
// Skip notification if result was already consumed via get_subagent_result
|
|
651
887
|
if (record.resultConsumed) {
|
|
@@ -680,11 +916,14 @@ function activateSubagents(pi: ExtensionAPI, options: SubagentsExtensionOptions)
|
|
|
680
916
|
fleet.ensureTimer();
|
|
681
917
|
fleet.update();
|
|
682
918
|
}
|
|
919
|
+
// Bind later handoff announcements to this exact run.
|
|
920
|
+
lifecycleRuns.set(record, manager.nextSequence());
|
|
683
921
|
// Emit started event when agent transitions to running (including from queue)
|
|
684
922
|
pi.events.emit("subagents:started", {
|
|
685
923
|
id: record.id,
|
|
686
924
|
type: record.type,
|
|
687
925
|
description: record.description,
|
|
926
|
+
...eventFields(record),
|
|
688
927
|
});
|
|
689
928
|
}, (record, info) => {
|
|
690
929
|
if (!isTopLevelAgent(record)) return;
|
|
@@ -696,6 +935,7 @@ function activateSubagents(pi: ExtensionAPI, options: SubagentsExtensionOptions)
|
|
|
696
935
|
reason: info.reason,
|
|
697
936
|
tokensBefore: info.tokensBefore,
|
|
698
937
|
compactionCount: record.compactionCount,
|
|
938
|
+
...eventFields(record),
|
|
699
939
|
});
|
|
700
940
|
}, (record, usage) => {
|
|
701
941
|
// Every assistant message from every agent — nested included, exactly once.
|
|
@@ -731,7 +971,8 @@ function activateSubagents(pi: ExtensionAPI, options: SubagentsExtensionOptions)
|
|
|
731
971
|
// clamped it to the model, or an agent file outranked it), so a ledger
|
|
732
972
|
// can show `low (asked minimal)` instead of silently relabelling.
|
|
733
973
|
...(invocation?.requestedThinking !== undefined ? { requestedThinking: invocation.requestedThinking } : {}),
|
|
734
|
-
|
|
974
|
+
...fallbackFields(record),
|
|
975
|
+
depth: record.depth,
|
|
735
976
|
...(record.parentAgentId !== undefined ? { parentAgentId: record.parentAgentId } : {}),
|
|
736
977
|
...(record.workflowId !== undefined ? { workflowId: record.workflowId } : {}),
|
|
737
978
|
usage: reported,
|
|
@@ -739,7 +980,26 @@ function activateSubagents(pi: ExtensionAPI, options: SubagentsExtensionOptions)
|
|
|
739
980
|
}, (record, message) => {
|
|
740
981
|
if (!isTopLevelAgent(record)) return;
|
|
741
982
|
pi.events.emit("subagents:steered", { id: record.id, message });
|
|
742
|
-
}, clock)
|
|
983
|
+
}, clock, options.homeDir, (record) => {
|
|
984
|
+
// Stopped before it ever started: no result and no notice, but a listener that heard
|
|
985
|
+
// `subagents:created` must still see it end. Same payload as a settled stop.
|
|
986
|
+
if (!isTopLevelAgent(record) || detachedSessionFile !== undefined) return;
|
|
987
|
+
record.settleSeq = manager.nextSequence();
|
|
988
|
+
pi.events.emit("subagents:failed", buildEventData(record));
|
|
989
|
+
}, (note) => {
|
|
990
|
+
// The retention sweep dropped a result nobody read: say so now, where the parent sees it on
|
|
991
|
+
// its next turn, instead of leaving `Agent not found` to be discovered by a later lookup (#330).
|
|
992
|
+
pi.sendMessage({
|
|
993
|
+
customType: "subagent-reaped",
|
|
994
|
+
content: `${describeEvicted(note)}. Its result was never read and its record is gone; reopen it with steer_subagent or Agent resume if its session is still saved.`,
|
|
995
|
+
display: true,
|
|
996
|
+
}, { deliverAs: "nextTurn" });
|
|
997
|
+
});
|
|
998
|
+
|
|
999
|
+
// A live survivor of an orphaned manager keeps its handle: reusing it would make a lifecycle listener
|
|
1000
|
+
// that keys by handle read this manager's newer agent and the survivor as one.
|
|
1001
|
+
manager.reservedHandles = () =>
|
|
1002
|
+
listAllRunningDetached().flatMap(({ agent }) => [agent.record.handle, agent.record.alias].filter((h): h is string => !!h));
|
|
743
1003
|
|
|
744
1004
|
// Expose manager via Symbol.for() global registry for cross-package access.
|
|
745
1005
|
// Standard Node.js pattern for cross-package singletons (used by OpenTelemetry, etc.).
|
|
@@ -810,10 +1070,19 @@ function activateSubagents(pi: ExtensionAPI, options: SubagentsExtensionOptions)
|
|
|
810
1070
|
// Bypasses handle allocation, so a forged value would duplicate a live
|
|
811
1071
|
// agent's name and make `@handle` ambiguous. Same rule: dispatcher only.
|
|
812
1072
|
delete safeOptions.reclaim;
|
|
1073
|
+
// Would make the new agent claim an evicted agent's id on lifecycle events.
|
|
1074
|
+
delete safeOptions.previousId;
|
|
813
1075
|
// Every spawn through here is DETACHED — the caller gets an id back and
|
|
814
1076
|
// awaits nothing. A forged `blocking` would charge it to the foreground
|
|
815
1077
|
// pool and could defer it behind a queue whose gate nobody is holding.
|
|
816
1078
|
delete safeOptions.blocking;
|
|
1079
|
+
// The spawn path's own disclosure; a caller-supplied one could hide a
|
|
1080
|
+
// fallback or invent one. The runner reports the real outcome itself.
|
|
1081
|
+
delete safeOptions.modelFallback;
|
|
1082
|
+
delete safeOptions.modelSelection;
|
|
1083
|
+
delete safeOptions.modelTier;
|
|
1084
|
+
delete safeOptions.previousModelTier;
|
|
1085
|
+
delete safeOptions.previousModelFallback;
|
|
817
1086
|
return spawnResolved(piRef, ctxRef, type, prompt, safeOptions);
|
|
818
1087
|
};
|
|
819
1088
|
|
|
@@ -828,7 +1097,12 @@ function activateSubagents(pi: ExtensionAPI, options: SubagentsExtensionOptions)
|
|
|
828
1097
|
const byId = manager.getRecord(ref);
|
|
829
1098
|
if (byId) return byId;
|
|
830
1099
|
const resolved = manager.resolveMention(ref);
|
|
831
|
-
|
|
1100
|
+
if (resolved?.kind === "live") return resolved.record;
|
|
1101
|
+
// Survivors of a /reload, /new or /resume live in the orphaned manager, so
|
|
1102
|
+
// this fresh one has never heard of them (#330). Same live records, so
|
|
1103
|
+
// reading and steering them work unchanged.
|
|
1104
|
+
const survivors = listRunningDetached(currentCtx?.sessionManager?.getSessionFile?.()).map(d => d.record);
|
|
1105
|
+
return survivors.find(r => r.id === ref) ?? survivors.find(r => r.handle === ref || r.alias === ref);
|
|
832
1106
|
};
|
|
833
1107
|
|
|
834
1108
|
const registryEntry = {
|
|
@@ -847,6 +1121,7 @@ function activateSubagents(pi: ExtensionAPI, options: SubagentsExtensionOptions)
|
|
|
847
1121
|
|
|
848
1122
|
// --- Cross-extension RPC via pi.events ---
|
|
849
1123
|
let currentCtx: ExtensionContext | undefined;
|
|
1124
|
+
let unwatchFleetSchedules: (() => void) | undefined;
|
|
850
1125
|
// RPC handlers + the `subagents:ready` broadcast are wired on `session_start`
|
|
851
1126
|
// (a bound lifecycle event), not at factory time. pi runs every extension
|
|
852
1127
|
// factory before the `extensions:` filter and only fires lifecycle events for
|
|
@@ -862,7 +1137,7 @@ function activateSubagents(pi: ExtensionAPI, options: SubagentsExtensionOptions)
|
|
|
862
1137
|
// Session-scoped: store is constructed inside session_start once sessionId
|
|
863
1138
|
// is available. Mirrors pi-chonky-tasks's session-scoped task store —
|
|
864
1139
|
// schedules reset on /new, restore on /resume.
|
|
865
|
-
const scheduler = new SubagentScheduler({ clock: options.clock });
|
|
1140
|
+
const scheduler = new SubagentScheduler({ clock: options.clock, homeDir: options.homeDir });
|
|
866
1141
|
|
|
867
1142
|
function startScheduler(ctx: ExtensionContext) {
|
|
868
1143
|
try {
|
|
@@ -884,6 +1159,7 @@ function activateSubagents(pi: ExtensionAPI, options: SubagentsExtensionOptions)
|
|
|
884
1159
|
// bound session_start, so a filtered-out activation never advertises (#142).
|
|
885
1160
|
pi.on("session_start", async (_event, ctx) => {
|
|
886
1161
|
currentCtx = ctx;
|
|
1162
|
+
restoreFleetDisclosure(ctx);
|
|
887
1163
|
// Pi resolves project trust before the first session starts, so this is the
|
|
888
1164
|
// earliest honest answer. The activation-time load defaulted to untrusted,
|
|
889
1165
|
// so a trusted project's `packages[]` only becomes visible now.
|
|
@@ -899,7 +1175,7 @@ function activateSubagents(pi: ExtensionAPI, options: SubagentsExtensionOptions)
|
|
|
899
1175
|
reloadCustomAgents(strictLoad);
|
|
900
1176
|
} catch (err) {
|
|
901
1177
|
if (strictLoad && StartupError) {
|
|
902
|
-
throw new StartupError(
|
|
1178
|
+
throw new StartupError(errorMessage(err), { cause: err });
|
|
903
1179
|
}
|
|
904
1180
|
throw err;
|
|
905
1181
|
}
|
|
@@ -911,6 +1187,7 @@ function activateSubagents(pi: ExtensionAPI, options: SubagentsExtensionOptions)
|
|
|
911
1187
|
widget.setUICtx(ctx.ui);
|
|
912
1188
|
fleet.setUICtx(ctx.ui as any);
|
|
913
1189
|
watchHandoffKey(ctx.ui);
|
|
1190
|
+
unwatchFleetSchedules ??= pi.events.on("subagents:scheduled", () => fleet.update());
|
|
914
1191
|
}
|
|
915
1192
|
manager.clearCompleted(true);
|
|
916
1193
|
const sessionFile = ctx.sessionManager?.getSessionFile?.();
|
|
@@ -919,12 +1196,15 @@ function activateSubagents(pi: ExtensionAPI, options: SubagentsExtensionOptions)
|
|
|
919
1196
|
unwatchDetached = watchDetached(sessionFile, () => deliverDetached(sessionFile));
|
|
920
1197
|
deliverDetached(sessionFile);
|
|
921
1198
|
}
|
|
1199
|
+
announceSurvivors();
|
|
1200
|
+
unwatchAnnounce ??= pi.events.on("subagents:announce_survivors", announceSurvivors);
|
|
922
1201
|
// Orphans from the previous instance are already registered; show them now.
|
|
923
1202
|
fleet.update();
|
|
924
1203
|
// Guard mirrors the `!scheduler.isActive()` pattern below: session_start
|
|
925
1204
|
// fires once per activation, but a double-bind must not leak listeners.
|
|
926
1205
|
if (!rpcHandle) {
|
|
927
1206
|
rpcHandle = registerRpcHandlers({
|
|
1207
|
+
homeDir: options.homeDir,
|
|
928
1208
|
events: pi.events,
|
|
929
1209
|
pi,
|
|
930
1210
|
getCtx: () => currentCtx,
|
|
@@ -944,6 +1224,7 @@ function activateSubagents(pi: ExtensionAPI, options: SubagentsExtensionOptions)
|
|
|
944
1224
|
if (!record || record.parentAgentId) return false;
|
|
945
1225
|
if (record.status === "running" || record.status === "queued") return false;
|
|
946
1226
|
markConsumed(record, clock.now());
|
|
1227
|
+
fleet.acknowledgeFailure(record.id);
|
|
947
1228
|
outbox.cancel(record.id);
|
|
948
1229
|
return true;
|
|
949
1230
|
},
|
|
@@ -956,6 +1237,7 @@ function activateSubagents(pi: ExtensionAPI, options: SubagentsExtensionOptions)
|
|
|
956
1237
|
pi.events.emit("subagents:ready", {});
|
|
957
1238
|
}
|
|
958
1239
|
if (isSchedulingEnabled() && !scheduler.isActive()) startScheduler(ctx);
|
|
1240
|
+
fleet.update(); // restored quota-parked jobs have no agent record
|
|
959
1241
|
// Stack `@handle` suggestions on pi's built-in autocomplete. Registered at
|
|
960
1242
|
// most once per activation: pi appends wrappers to a list it never prunes,
|
|
961
1243
|
// so a second call would layer a duplicate provider on the first. TUI only
|
|
@@ -981,7 +1263,7 @@ function activateSubagents(pi: ExtensionAPI, options: SubagentsExtensionOptions)
|
|
|
981
1263
|
|
|
982
1264
|
/** Agent types `@` can start, in the shape the roster wants. */
|
|
983
1265
|
const mentionTypes = (): TypeInfo[] =>
|
|
984
|
-
getAvailableTypes().map(name => ({ name, description: getAgentConfig(name)
|
|
1266
|
+
getAvailableTypes().map(name => ({ name, description: getAgentConfig(name)!.description }));
|
|
985
1267
|
|
|
986
1268
|
/**
|
|
987
1269
|
* `@handle message` typed at the prompt addresses that agent instead of the
|
|
@@ -1080,62 +1362,9 @@ function activateSubagents(pi: ExtensionAPI, options: SubagentsExtensionOptions)
|
|
|
1080
1362
|
if (resolved?.kind === "tombstone") {
|
|
1081
1363
|
const entry = resolved.entry;
|
|
1082
1364
|
const target = `@${entry.alias ?? entry.handle}`;
|
|
1083
|
-
|
|
1084
|
-
|
|
1085
|
-
|
|
1086
|
-
// the catch below. A `/new` in another pi window or a manual delete makes
|
|
1087
|
-
// the conversation unrecoverable (Claude Code's `not_reachable`), so drop
|
|
1088
|
-
// the entry — a row that can only ever fail is worse than none — and say
|
|
1089
|
-
// so rather than quietly sending this message to an unrelated agent.
|
|
1090
|
-
if (!existsSync(entry.sessionFile)) {
|
|
1091
|
-
manager.dropTombstone(entry.handle);
|
|
1092
|
-
ctx.ui.notify(`Could not resume ${target} — its session is gone.`, "warning");
|
|
1093
|
-
return { action: "handled" };
|
|
1094
|
-
}
|
|
1095
|
-
|
|
1096
|
-
// The Agent tool deliberately falls back to general-purpose for a type it
|
|
1097
|
-
// cannot resolve (#183), which covers a deleted file AND a merely
|
|
1098
|
-
// disabled one. A resume must not inherit that: reopening this
|
|
1099
|
-
// conversation under a different agent's prompt and tools is not
|
|
1100
|
-
// continuing it, and the new record would re-tombstone under the
|
|
1101
|
-
// substitute, so the handle would never find its way back.
|
|
1102
|
-
reloadCustomAgents();
|
|
1103
|
-
const dispatch = resolveSpawnType(entry.type);
|
|
1104
|
-
if (!dispatch.ok || dispatch.fellBackFrom !== undefined) {
|
|
1105
|
-
// The tombstone stays: re-enabling the agent makes the handle work
|
|
1106
|
-
// again, which a drop would foreclose.
|
|
1107
|
-
ctx.ui.notify(`Could not resume ${target} — the ${entry.type} agent is no longer available.`, "warning");
|
|
1108
|
-
return { action: "handled" };
|
|
1109
|
-
}
|
|
1110
|
-
|
|
1111
|
-
try {
|
|
1112
|
-
// spawnResolved, not spawnTopLevel: the latter strips
|
|
1113
|
-
// `resumeSessionFile` and `reclaim` as untrusted. This path is the
|
|
1114
|
-
// exception — both come from a tombstone this extension wrote.
|
|
1115
|
-
const id = spawnResolved(pi, ctx, dispatch.type, mention.message, {
|
|
1116
|
-
description: entry.description,
|
|
1117
|
-
reclaim: { handle: entry.handle, alias: entry.alias },
|
|
1118
|
-
resumeSessionFile: entry.sessionFile,
|
|
1119
|
-
isBackground: true,
|
|
1120
|
-
});
|
|
1121
|
-
// The agent may still be starting — wait, so a startup failure lands in
|
|
1122
|
-
// the catch below instead of being announced as a resume.
|
|
1123
|
-
await manager.awaitStartup(id);
|
|
1124
|
-
// The tombstone deliberately stays. `resolveMention` prefers the live
|
|
1125
|
-
// record holding these same names, so it cannot shadow the resume — and
|
|
1126
|
-
// if this run dies before establishing its own session, the original
|
|
1127
|
-
// transcript is still the right thing for the next mention to reopen.
|
|
1128
|
-
// Once the resumed record is evicted it overwrites this entry in place,
|
|
1129
|
-
// keyed by the same handle, so nothing accumulates.
|
|
1130
|
-
ctx.ui.notify(`Resuming ${target}`, "info");
|
|
1131
|
-
} catch (err) {
|
|
1132
|
-
// The type is already settled above, so what is left is a spawn-time
|
|
1133
|
-
// failure: a strict worktree-isolation error, an unusable cwd.
|
|
1134
|
-
ctx.ui.notify(
|
|
1135
|
-
`Could not resume ${target}: ${err instanceof Error ? err.message : String(err)}`,
|
|
1136
|
-
"warning",
|
|
1137
|
-
);
|
|
1138
|
-
}
|
|
1365
|
+
const reopened = await reopenEvicted(ctx, entry, mention.message);
|
|
1366
|
+
if (reopened.ok) ctx.ui.notify(`Resuming ${target}`, "info");
|
|
1367
|
+
else ctx.ui.notify(couldNotResume(target, reopened.failure), "warning");
|
|
1139
1368
|
return { action: "handled" };
|
|
1140
1369
|
}
|
|
1141
1370
|
|
|
@@ -1169,7 +1398,7 @@ function activateSubagents(pi: ExtensionAPI, options: SubagentsExtensionOptions)
|
|
|
1169
1398
|
// Not awaited: the clone runs a full model turn, and prompt() is blocked
|
|
1170
1399
|
// until this hook returns. The user gets their prompt back immediately
|
|
1171
1400
|
// and the agent appears in the widget when it starts.
|
|
1172
|
-
void runMentionClone({ ctx, type, message: mention.message, agentTool: registeredAgentTool })
|
|
1401
|
+
void runMentionClone({ ctx, type, message: mention.message, agentTool: registeredAgentTool, homeDir: options.homeDir })
|
|
1173
1402
|
.then(async (result) => {
|
|
1174
1403
|
if (result.spawned) return;
|
|
1175
1404
|
// A clone that could not run must not swallow the mention: start the
|
|
@@ -1186,7 +1415,7 @@ function activateSubagents(pi: ExtensionAPI, options: SubagentsExtensionOptions)
|
|
|
1186
1415
|
ctx.ui.notify(`Started ${label} directly — ${result.error}`, "warning");
|
|
1187
1416
|
} catch (err) {
|
|
1188
1417
|
ctx.ui.notify(
|
|
1189
|
-
`Could not start ${label}: ${
|
|
1418
|
+
`Could not start ${label}: ${errorMessage(err)}`,
|
|
1190
1419
|
"error",
|
|
1191
1420
|
);
|
|
1192
1421
|
}
|
|
@@ -1195,7 +1424,7 @@ function activateSubagents(pi: ExtensionAPI, options: SubagentsExtensionOptions)
|
|
|
1195
1424
|
}
|
|
1196
1425
|
|
|
1197
1426
|
try {
|
|
1198
|
-
// Nothing else to pass:
|
|
1427
|
+
// Nothing else to pass: the manager resolves model; runAgent resolves thinking, max turns and
|
|
1199
1428
|
// `isolated` from the agent's own config when the spawn omits them, and the
|
|
1200
1429
|
// manager's onStart/onComplete callbacks own the widget, the fleet list
|
|
1201
1430
|
// and the completion notification — the same contract the scheduler and
|
|
@@ -1210,7 +1439,7 @@ function activateSubagents(pi: ExtensionAPI, options: SubagentsExtensionOptions)
|
|
|
1210
1439
|
await manager.awaitStartup(id);
|
|
1211
1440
|
ctx.ui.notify(`Started @${handleBase(type)}`, "info");
|
|
1212
1441
|
} catch (err) {
|
|
1213
|
-
ctx.ui.notify(`Could not start @${handleBase(type)}: ${
|
|
1442
|
+
ctx.ui.notify(`Could not start @${handleBase(type)}: ${errorMessage(err)}`, "error");
|
|
1214
1443
|
}
|
|
1215
1444
|
return { action: "handled" };
|
|
1216
1445
|
});
|
|
@@ -1227,9 +1456,12 @@ function activateSubagents(pi: ExtensionAPI, options: SubagentsExtensionOptions)
|
|
|
1227
1456
|
// to this session (see detached-results.ts). Only agents that cannot outlive the
|
|
1228
1457
|
// session are aborted.
|
|
1229
1458
|
pi.on("session_shutdown", async (event: { reason?: string }) => {
|
|
1459
|
+
fleet.flushState(); // appendEntry must run before this activation loses its context
|
|
1230
1460
|
const sessionFile = currentCtx?.sessionManager?.getSessionFile?.();
|
|
1231
1461
|
unwatchDetached?.();
|
|
1232
1462
|
unwatchDetached = undefined;
|
|
1463
|
+
unwatchAnnounce?.();
|
|
1464
|
+
unwatchAnnounce = undefined;
|
|
1233
1465
|
rpcHandle?.unsubSpawn();
|
|
1234
1466
|
rpcHandle?.unsubStop();
|
|
1235
1467
|
rpcHandle?.unsubPing();
|
|
@@ -1260,7 +1492,12 @@ function activateSubagents(pi: ExtensionAPI, options: SubagentsExtensionOptions)
|
|
|
1260
1492
|
}
|
|
1261
1493
|
const pending = outbox.takePending();
|
|
1262
1494
|
if (pending) {
|
|
1263
|
-
|
|
1495
|
+
// Not sent here: the session that reads the spool is the one that sees it.
|
|
1496
|
+
const { delivered: _unsent, noticed: _unseen, ...spooled } = pending;
|
|
1497
|
+
const heldIds = [pending.details.id, ...(pending.details.others?.map(other => other.id) ?? [])];
|
|
1498
|
+
const evicted = manager.evictionNotes(heldIds);
|
|
1499
|
+
const tombstones = manager.evictionTombstones(heldIds);
|
|
1500
|
+
try { spoolDetached(sessionFile, { id: nanoid(8), ...spooled, evicted, tombstones }); }
|
|
1264
1501
|
catch { console.error("[pi-subagents] Could not spool held completion notices; results remain in their transcripts."); }
|
|
1265
1502
|
}
|
|
1266
1503
|
}
|
|
@@ -1276,6 +1513,8 @@ function activateSubagents(pi: ExtensionAPI, options: SubagentsExtensionOptions)
|
|
|
1276
1513
|
widget.dispose();
|
|
1277
1514
|
outbox.dispose();
|
|
1278
1515
|
fleet.dispose();
|
|
1516
|
+
unwatchFleetSchedules?.();
|
|
1517
|
+
unwatchFleetSchedules = undefined;
|
|
1279
1518
|
unsubHandoffKey?.();
|
|
1280
1519
|
unsubHandoffKey = undefined;
|
|
1281
1520
|
handoffKeyUi = undefined;
|
|
@@ -1290,7 +1529,7 @@ function activateSubagents(pi: ExtensionAPI, options: SubagentsExtensionOptions)
|
|
|
1290
1529
|
registerRunningDetached(sessionFile, {
|
|
1291
1530
|
record,
|
|
1292
1531
|
activity: agentActivity.get(record.id),
|
|
1293
|
-
abort: () => manager.abort(record.id),
|
|
1532
|
+
abort: (by) => manager.abort(record.id, by),
|
|
1294
1533
|
steer: (message) => manager.steer(record.id, message),
|
|
1295
1534
|
});
|
|
1296
1535
|
}
|
|
@@ -1340,16 +1579,17 @@ function activateSubagents(pi: ExtensionAPI, options: SubagentsExtensionOptions)
|
|
|
1340
1579
|
wake();
|
|
1341
1580
|
sent++;
|
|
1342
1581
|
// It is a background agent from here on, so announce it as one — as a
|
|
1343
|
-
// detached resume re-announces an existing id.
|
|
1344
|
-
|
|
1345
|
-
|
|
1346
|
-
|
|
1347
|
-
|
|
1348
|
-
|
|
1349
|
-
|
|
1350
|
-
|
|
1351
|
-
|
|
1352
|
-
|
|
1582
|
+
// detached resume re-announces an existing id. `sendToBackground` only
|
|
1583
|
+
// hands off a record it holds.
|
|
1584
|
+
const record = manager.getRecord(id)!;
|
|
1585
|
+
pi.events.emit("subagents:created", {
|
|
1586
|
+
id,
|
|
1587
|
+
type: record.type,
|
|
1588
|
+
description: record.description,
|
|
1589
|
+
isBackground: true,
|
|
1590
|
+
backgroundHandoff: true,
|
|
1591
|
+
...eventFields(record),
|
|
1592
|
+
});
|
|
1353
1593
|
}
|
|
1354
1594
|
if (sent > 0) {
|
|
1355
1595
|
widget.ensureTimer();
|
|
@@ -1412,6 +1652,18 @@ function activateSubagents(pi: ExtensionAPI, options: SubagentsExtensionOptions)
|
|
|
1412
1652
|
getFullscreenScrollbar, backgroundActionFor, isShowModelEnabled,
|
|
1413
1653
|
// Agents the previous instance of this session left running (/reload, /new, ...).
|
|
1414
1654
|
() => listRunningDetached(currentCtx?.sessionManager?.getSessionFile?.()), clock);
|
|
1655
|
+
fleet.setParkedSource(() => scheduler.list().filter(job => job.enabled && job.quotaParked && job.lastStatus !== "running").length);
|
|
1656
|
+
fleet.setStopAll(stopAllRunning);
|
|
1657
|
+
/** Replay UI-only state from the active branch, like workflow/goal custom snapshots. */
|
|
1658
|
+
function restoreFleetDisclosure(ctx: ExtensionContext): void {
|
|
1659
|
+
fleet.restoreSession(() => ctx.sessionManager?.getBranch?.() ?? [], state => {
|
|
1660
|
+
pi.appendEntry(FLEET_STATE_ENTRY, { ...state, version: FLEET_STATE_VERSION });
|
|
1661
|
+
});
|
|
1662
|
+
}
|
|
1663
|
+
pi.on("session_tree", (_event, ctx) => {
|
|
1664
|
+
restoreFleetDisclosure(ctx);
|
|
1665
|
+
fleet.update();
|
|
1666
|
+
});
|
|
1415
1667
|
/** What the Agent tool row reads at render time, so its summary stays live. */
|
|
1416
1668
|
const agentRowLive: AgentRowLive = {
|
|
1417
1669
|
getRecord: (id) => manager.getRecord(id),
|
|
@@ -1552,6 +1804,100 @@ function activateSubagents(pi: ExtensionAPI, options: SubagentsExtensionOptions)
|
|
|
1552
1804
|
return getAgentConfig(record.type)?.maxTurns ?? explicit ?? record.invocation?.maxTurns ?? getDefaultMaxTurns();
|
|
1553
1805
|
}
|
|
1554
1806
|
|
|
1807
|
+
/** Why `reopenEvicted` did not reopen; `type-unavailable` keeps the tombstone, `session-gone` drops it. */
|
|
1808
|
+
type ReopenFailure =
|
|
1809
|
+
| { reason: "session-gone" }
|
|
1810
|
+
| { reason: "type-unavailable"; type: string }
|
|
1811
|
+
| { reason: "spawn-failed"; message: string };
|
|
1812
|
+
|
|
1813
|
+
/** The one wording both reopen callers use, `target` being `@handle` or `agent "<id>"`. */
|
|
1814
|
+
function couldNotResume(target: string, failure: ReopenFailure): string {
|
|
1815
|
+
switch (failure.reason) {
|
|
1816
|
+
case "session-gone": return `Could not resume ${target} — its session is gone.`;
|
|
1817
|
+
case "type-unavailable": return `Could not resume ${target} — the ${failure.type} agent is no longer available.`;
|
|
1818
|
+
case "spawn-failed": return `Could not resume ${target}: ${failure.message}`;
|
|
1819
|
+
}
|
|
1820
|
+
}
|
|
1821
|
+
|
|
1822
|
+
/**
|
|
1823
|
+
* Reopen an evicted agent's conversation from its session file. An ordinary
|
|
1824
|
+
* spawn carrying that file, so the new record picks up the widget, fleet row,
|
|
1825
|
+
* transcript and completion notification unchanged, and `reclaim` hands it
|
|
1826
|
+
* back the names the tombstone was holding. Always detached.
|
|
1827
|
+
*
|
|
1828
|
+
* Shared by `@handle message` and the Agent tool's `resume` of an evicted id
|
|
1829
|
+
* (#330); they differ only in how they report the outcome, see
|
|
1830
|
+
* `couldNotResume`. The evicted id is linked to the new one before the first
|
|
1831
|
+
* await, so a second resume of the old id in the same turn finds the live
|
|
1832
|
+
* record instead of reopening the file twice.
|
|
1833
|
+
*/
|
|
1834
|
+
async function reopenEvicted(
|
|
1835
|
+
ctx: ExtensionContext,
|
|
1836
|
+
entry: AgentTombstone,
|
|
1837
|
+
prompt: string,
|
|
1838
|
+
opts: { maxTurns?: number } = {},
|
|
1839
|
+
): Promise<{ ok: true; id: string } | { ok: false; failure: ReopenFailure }> {
|
|
1840
|
+
// Checked here rather than left to SessionManager.open: that runs inside
|
|
1841
|
+
// runAgent, whose rejection lands on the record as an agent error, not in
|
|
1842
|
+
// the catch below. A `/new` in another pi window or a manual delete makes
|
|
1843
|
+
// the conversation unrecoverable (Claude Code's `not_reachable`), so drop
|
|
1844
|
+
// the entry — a row that can only ever fail is worse than none — and say
|
|
1845
|
+
// so rather than quietly sending this message to an unrelated agent.
|
|
1846
|
+
if (!existsSync(entry.sessionFile)) {
|
|
1847
|
+
manager.dropTombstone(entry.handle);
|
|
1848
|
+
return { ok: false, failure: { reason: "session-gone" } };
|
|
1849
|
+
}
|
|
1850
|
+
|
|
1851
|
+
// The Agent tool deliberately falls back to general-purpose for a type it
|
|
1852
|
+
// cannot resolve (#183), which covers a deleted file AND a merely
|
|
1853
|
+
// disabled one. A resume must not inherit that: reopening this
|
|
1854
|
+
// conversation under a different agent's prompt and tools is not
|
|
1855
|
+
// continuing it, and the new record would re-tombstone under the
|
|
1856
|
+
// substitute, so the handle would never find its way back.
|
|
1857
|
+
reloadCustomAgents();
|
|
1858
|
+
const dispatch = resolveSpawnType(entry.type);
|
|
1859
|
+
if (!dispatch.ok || dispatch.fellBackFrom !== undefined) {
|
|
1860
|
+
// The tombstone stays: re-enabling the agent makes the handle work
|
|
1861
|
+
// again, which a drop would foreclose.
|
|
1862
|
+
return { ok: false, failure: { reason: "type-unavailable", type: entry.type } };
|
|
1863
|
+
}
|
|
1864
|
+
|
|
1865
|
+
let id: string | undefined;
|
|
1866
|
+
try {
|
|
1867
|
+
// spawnResolved, not spawnTopLevel: the latter strips
|
|
1868
|
+
// `resumeSessionFile` and `reclaim` as untrusted. This path is the
|
|
1869
|
+
// exception — both come from a tombstone this extension wrote.
|
|
1870
|
+
id = spawnResolved(pi, ctx, dispatch.type, prompt, {
|
|
1871
|
+
description: entry.description,
|
|
1872
|
+
maxTurns: opts.maxTurns,
|
|
1873
|
+
reclaim: { handle: entry.handle, alias: entry.alias },
|
|
1874
|
+
previousId: entry.id,
|
|
1875
|
+
resumeSessionFile: entry.sessionFile,
|
|
1876
|
+
// Historical disclosures never replace the continuation's current selection.
|
|
1877
|
+
...(entry.modelFallback ? { previousModelFallback: entry.modelFallback } : {}),
|
|
1878
|
+
...(entry.modelTier ? { previousModelTier: entry.modelTier } : {}),
|
|
1879
|
+
isBackground: true,
|
|
1880
|
+
});
|
|
1881
|
+
manager.linkSuccessor(entry.id, id);
|
|
1882
|
+
// The agent may still be starting — wait, so a startup failure lands in
|
|
1883
|
+
// the catch below instead of being announced as a resume.
|
|
1884
|
+
await manager.awaitStartup(id);
|
|
1885
|
+
// The tombstone deliberately stays. `resolveMention` prefers the live
|
|
1886
|
+
// record holding these same names, so it cannot shadow the resume — and
|
|
1887
|
+
// if this run dies before establishing its own session, the original
|
|
1888
|
+
// transcript is still the right thing for the next mention to reopen.
|
|
1889
|
+
// Once the resumed record is evicted it overwrites this entry in place,
|
|
1890
|
+
// keyed by the same handle, so nothing accumulates.
|
|
1891
|
+
return { ok: true, id };
|
|
1892
|
+
} catch (err) {
|
|
1893
|
+
// The type is already settled above, so what is left is a spawn-time
|
|
1894
|
+
// failure: a strict worktree-isolation error, an unusable cwd.
|
|
1895
|
+
// A run that never started is no successor: the old id reopens again next time.
|
|
1896
|
+
if (id !== undefined) manager.unlinkSuccessor(entry.id);
|
|
1897
|
+
return { ok: false, failure: { reason: "spawn-failed", message: errorMessage(err) } };
|
|
1898
|
+
}
|
|
1899
|
+
}
|
|
1900
|
+
|
|
1555
1901
|
/**
|
|
1556
1902
|
* Launch a detached resume of an existing agent and wire everything a
|
|
1557
1903
|
* re-running agent needs: transcript anchoring, activity tracking, join-mode
|
|
@@ -1571,24 +1917,31 @@ function activateSubagents(pi: ExtensionAPI, options: SubagentsExtensionOptions)
|
|
|
1571
1917
|
opts: { outputTranscript: boolean; maxTurns?: number; toolCallId?: string },
|
|
1572
1918
|
): Promise<AgentRecord | undefined> {
|
|
1573
1919
|
const id = existing.id;
|
|
1574
|
-
const
|
|
1920
|
+
const priorSettleSeq = existing.settleSeq;
|
|
1921
|
+
const resumeSeq = manager.nextSequence();
|
|
1922
|
+
// A resume always runs detached, and a detached run joins by the default mode.
|
|
1923
|
+
const joinMode = defaultJoinMode;
|
|
1575
1924
|
// Assigned unconditionally: the completion notification carries this as
|
|
1576
1925
|
// `<tool-use-id>`, so a mention-resume (which passes none) has to CLEAR the
|
|
1577
1926
|
// id left by the spawn that created the record. Keeping it would point the
|
|
1578
1927
|
// orchestrator's new result at a tool call that was answered runs ago.
|
|
1579
1928
|
existing.toolCallId = opts.toolCallId;
|
|
1580
|
-
|
|
1929
|
+
existing.joinMode = joinMode;
|
|
1581
1930
|
// Reuse the agent's transcript rather than starting a fresh one: the
|
|
1582
1931
|
// path is deterministic per agent+session, so writing an initial entry
|
|
1583
1932
|
// would truncate the previous run's turns (see ensureOutputFile).
|
|
1584
1933
|
if (opts.outputTranscript) {
|
|
1585
1934
|
existing.outputFile = createOutputFilePath(ctx.cwd, id, ctx.sessionManager.getSessionId());
|
|
1586
1935
|
ensureOutputFile(existing.outputFile);
|
|
1936
|
+
// A fresh transcript for a later resume must still say what the agent ran on.
|
|
1937
|
+
if (existing.modelFallback) appendModelFallbackEntry(existing.outputFile, id, existing.modelFallback);
|
|
1938
|
+
if (existing.modelTier) appendModelTierEntry(existing.outputFile, id, existing.modelTier);
|
|
1587
1939
|
}
|
|
1588
1940
|
// Anchor streaming past the turns already on disk, captured BEFORE the
|
|
1589
1941
|
// run starts. The resumed prompt lands as an ordinary user message at
|
|
1590
1942
|
// this index, so it is written exactly once.
|
|
1591
|
-
|
|
1943
|
+
// Both callers resume only a record that still holds its session.
|
|
1944
|
+
const transcriptAnchor = existing.session!.messages.length;
|
|
1592
1945
|
|
|
1593
1946
|
const { state: bgState, callbacks: bgCallbacks } = createActivityTracker(normalizeMaxTurns(opts.maxTurns), undefined, clock);
|
|
1594
1947
|
// resumeAgent has no onSessionCreated — the session predates this run —
|
|
@@ -1642,6 +1995,8 @@ function activateSubagents(pi: ExtensionAPI, options: SubagentsExtensionOptions)
|
|
|
1642
1995
|
type: existing.type,
|
|
1643
1996
|
description: existing.description,
|
|
1644
1997
|
isBackground: true,
|
|
1998
|
+
...(record.status === "queued" && priorSettleSeq !== undefined ? { queuedAfterSettleSeq: priorSettleSeq, queuedResumeSeq: resumeSeq } : {}),
|
|
1999
|
+
...eventFields(existing),
|
|
1645
2000
|
});
|
|
1646
2001
|
|
|
1647
2002
|
return record;
|
|
@@ -1662,17 +2017,28 @@ function activateSubagents(pi: ExtensionAPI, options: SubagentsExtensionOptions)
|
|
|
1662
2017
|
outbox.onTurnEnd(event.message.role === "assistant" ? event.message.stopReason : undefined);
|
|
1663
2018
|
});
|
|
1664
2019
|
pi.on("agent_end", () => outbox.onAgentEnd());
|
|
1665
|
-
pi.on("agent_settled", () =>
|
|
2020
|
+
pi.on("agent_settled", () => {
|
|
2021
|
+
outbox.onAgentEnd();
|
|
2022
|
+
// The run is over but owned agents are not: say so, and say what will
|
|
2023
|
+
// wake the parent, instead of leaving a quiet prompt that looks like
|
|
2024
|
+
// finished work. `currentCtx` is set at `session_start` and cleared only at
|
|
2025
|
+
// `session_shutdown`, and pi settles a run only inside a live session, so
|
|
2026
|
+
// it is defined here.
|
|
2027
|
+
const pending = manager.ownedPending();
|
|
2028
|
+
const message = formatOwnedPendingNotice(pending);
|
|
2029
|
+
if (message) currentCtx?.ui.notify(message, pending.notifying > 0 ? "info" : "warning");
|
|
2030
|
+
});
|
|
1666
2031
|
|
|
1667
2032
|
/** Build the full type list text dynamically from available agents only. */
|
|
1668
2033
|
const buildTypeListText = () => {
|
|
1669
2034
|
const available = getAvailableTypes();
|
|
1670
2035
|
|
|
1671
2036
|
return available.map((name) => {
|
|
1672
|
-
|
|
1673
|
-
const
|
|
2037
|
+
// Every available type is registered, so its config exists.
|
|
2038
|
+
const cfg = getAgentConfig(name)!;
|
|
2039
|
+
const modelSuffix = cfg.model ? ` (${getModelLabelFromConfig(cfg.model)})` : "";
|
|
1674
2040
|
const toolsSuffix = ` (Tools: ${formatToolsSuffix(cfg)})`;
|
|
1675
|
-
return `- ${name}: ${cfg
|
|
2041
|
+
return `- ${name}: ${cfg.description}${modelSuffix}${toolsSuffix}`;
|
|
1676
2042
|
}).join("\n");
|
|
1677
2043
|
};
|
|
1678
2044
|
|
|
@@ -1685,8 +2051,8 @@ function activateSubagents(pi: ExtensionAPI, options: SubagentsExtensionOptions)
|
|
|
1685
2051
|
/** Compact type list: one line per agent, first sentence only. */
|
|
1686
2052
|
const buildCompactTypeListText = () =>
|
|
1687
2053
|
getAvailableTypes().map((name) => {
|
|
1688
|
-
const cfg = getAgentConfig(name)
|
|
1689
|
-
return `- ${name}: ${firstSentence(cfg
|
|
2054
|
+
const cfg = getAgentConfig(name)!;
|
|
2055
|
+
return `- ${name}: ${firstSentence(cfg.description)} (Tools: ${formatToolsSuffix(cfg)})`;
|
|
1690
2056
|
}).join("\n");
|
|
1691
2057
|
|
|
1692
2058
|
/** Derive a short model label from a model string. */
|
|
@@ -1714,6 +2080,7 @@ function activateSubagents(pi: ExtensionAPI, options: SubagentsExtensionOptions)
|
|
|
1714
2080
|
setBudgetPolicy,
|
|
1715
2081
|
setDefaultMaxTurns,
|
|
1716
2082
|
setGraceTurns,
|
|
2083
|
+
setNotificationResultChars: (n) => { notificationResultChars = n; },
|
|
1717
2084
|
setDefaultJoinMode,
|
|
1718
2085
|
setBackgroundByDefault,
|
|
1719
2086
|
setSchedulingEnabled,
|
|
@@ -1740,6 +2107,8 @@ function activateSubagents(pi: ExtensionAPI, options: SubagentsExtensionOptions)
|
|
|
1740
2107
|
setPackageWorkflows: setPackageWorkflowsGate,
|
|
1741
2108
|
},
|
|
1742
2109
|
(event, payload) => pi.events.emit(event, payload),
|
|
2110
|
+
process.cwd(),
|
|
2111
|
+
options.homeDir,
|
|
1743
2112
|
);
|
|
1744
2113
|
|
|
1745
2114
|
// ---- Agent tool ----
|
|
@@ -1774,13 +2143,35 @@ function activateSubagents(pi: ExtensionAPI, options: SubagentsExtensionOptions)
|
|
|
1774
2143
|
// With no per-result note by design, the model would have every reason to go
|
|
1775
2144
|
// on reporting a `pi-agent-*` branch that was never created.
|
|
1776
2145
|
const isolationGuideline = isWorktreeIsolationEnabled()
|
|
1777
|
-
? `\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. The worktree is made from the repo containing \`cwd\` (default: the session cwd); when the target repo is not the session cwd, pass its path as \`cwd\`.`
|
|
2146
|
+
? `\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 unless cleanup fails (the result then names the retained path); if it made changes, they are committed to a branch and the branch is named in the result. The worktree is made from the repo containing \`cwd\` (default: the session cwd); when the target repo is not the session cwd, pass its path as \`cwd\`.`
|
|
1778
2147
|
: "";
|
|
1779
2148
|
|
|
1780
2149
|
const isolationCompactGuideline = isWorktreeIsolationEnabled()
|
|
1781
|
-
? `\n- isolation: "worktree" gives the agent its own git worktree (removed
|
|
2150
|
+
? `\n- isolation: "worktree" gives the agent its own git worktree (removed unless cleanup fails); changes land on a branch named in the result. Pass \`cwd\` to isolate a repo other than the session cwd.`
|
|
1782
2151
|
: "";
|
|
1783
2152
|
|
|
2153
|
+
// Tier keys only: members are profile config (router or enterprise manifest),
|
|
2154
|
+
// and teaching fuzzy names here is how model ids leak into calls, where an
|
|
2155
|
+
// explicit `model` beats `agentModels` and bypasses the router.
|
|
2156
|
+
const tierGuidance = (): string | undefined => {
|
|
2157
|
+
const tiers = configuredTierNames();
|
|
2158
|
+
return tiers.length === 0
|
|
2159
|
+
? undefined
|
|
2160
|
+
: `Configured tiers: ${tiers.map((tier) => `@${tier}`).join(", ")}. Prefer a tier over a model id.`;
|
|
2161
|
+
};
|
|
2162
|
+
const modelGuideline = (): string => {
|
|
2163
|
+
const tiers = tierGuidance();
|
|
2164
|
+
return tiers
|
|
2165
|
+
? `Use model to select a tier. ${tiers}`
|
|
2166
|
+
: 'Use model to select a configured tier (e.g. "@cheap"), an exact "provider/modelId", or a fuzzy tool-call input (e.g. "haiku").';
|
|
2167
|
+
};
|
|
2168
|
+
const modelParamDescription = (): string => {
|
|
2169
|
+
const tiers = tierGuidance();
|
|
2170
|
+
return tiers
|
|
2171
|
+
? `Optional model override. ${tiers} "provider/modelId" is also accepted. Explicit values win over agentModels and the agent definition.`
|
|
2172
|
+
: 'Optional model override. Accepts a configured tier (e.g. "@cheap"), "provider/modelId", or fuzzy name (e.g. "haiku"). Explicit values win over agentModels and the agent definition.';
|
|
2173
|
+
};
|
|
2174
|
+
|
|
1784
2175
|
// Compact Agent tool description (#91, `toolDescriptionMode: "compact"`) —
|
|
1785
2176
|
// the same load-bearing facts as the full version at ~75% fewer tokens, for
|
|
1786
2177
|
// small/local models. Per-option details live in the param descriptions.
|
|
@@ -1794,7 +2185,7 @@ Notes:
|
|
|
1794
2185
|
- Parallel work: one message, multiple Agent calls — they run concurrently.
|
|
1795
2186
|
- Subagents run in the background by default; you'll be notified when one completes. Pass run_in_background: false only when your very next action depends on the result and nothing else could usefully happen while it runs. Never fabricate or predict a pending agent's results — if the user asks before the notification arrives, say it's still running.
|
|
1796
2187
|
- The result is not shown to the user — summarize it for them. Verify an agent's claimed code changes before reporting work done.
|
|
1797
|
-
- resume continues a previous agent by ID; steer_subagent messages
|
|
2188
|
+
- resume continues a previous agent by ID; steer_subagent messages one, steering it mid-run or resuming it once finished.${isolationCompactGuideline}`;
|
|
1798
2189
|
|
|
1799
2190
|
const fullAgentToolDescription = () => `Launch a new agent to handle complex, multi-step tasks autonomously. Each agent type has specific capabilities and tools available to it.
|
|
1800
2191
|
|
|
@@ -1819,10 +2210,11 @@ If the target is already known, use a direct tool — \`read\` for a known path,
|
|
|
1819
2210
|
- **Foreground vs background**: Pass \`run_in_background: false\` only when your very next action depends on the agent's result and nothing else could usefully happen while it runs — e.g., a research agent whose finding gates the edit you're about to make. Otherwise let it run in the background (the default) — this includes fire-and-forget work, independent investigations, and anything where the user might hand you something else in the meantime. Wanting the result "next" is not enough on its own.
|
|
1820
2211
|
- **Don't race**: after launching a background agent, you know nothing about its results. Never fabricate or predict them in any format — not as prose, summary, or structured output. The completion notification arrives in a later turn; it is never something you write yourself. If the user asks before it lands, say the agent is still running — give status, not a guess.
|
|
1821
2212
|
- 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.
|
|
1822
|
-
- Use steer_subagent to
|
|
2213
|
+
- Use steer_subagent to message an agent: it steers a running agent mid-run and resumes a finished one in the background, like resume.
|
|
2214
|
+
- Use stop_subagent to end an agent or workflow you no longer need, e.g. once a sibling has answered. A steer asking it to stop is read only after its current tool call and can be ignored.
|
|
1823
2215
|
- 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.
|
|
1824
2216
|
- If an agent's description says it should be used proactively, try to use it without the user having to ask for it first.
|
|
1825
|
-
-
|
|
2217
|
+
- ${modelGuideline()}
|
|
1826
2218
|
- Use thinking to control extended thinking level.
|
|
1827
2219
|
- Use inherit_context if the agent needs the parent conversation history.${isolationGuideline}${scheduleGuideline}
|
|
1828
2220
|
|
|
@@ -1871,7 +2263,7 @@ Terse command-style prompts produce shallow, generic work.
|
|
|
1871
2263
|
if (text) return renderToolDescriptionTemplate(text);
|
|
1872
2264
|
console.warn(`[pi-subagents] ${path} is empty — ignoring`);
|
|
1873
2265
|
} catch (err) {
|
|
1874
|
-
console.warn(`[pi-subagents] failed to read ${path}: ${
|
|
2266
|
+
console.warn(`[pi-subagents] failed to read ${path}: ${errorMessage(err)}`);
|
|
1875
2267
|
}
|
|
1876
2268
|
}
|
|
1877
2269
|
return undefined;
|
|
@@ -1923,8 +2315,7 @@ Terse command-style prompts produce shallow, generic work.
|
|
|
1923
2315
|
}),
|
|
1924
2316
|
model: Type.Optional(
|
|
1925
2317
|
Type.String({
|
|
1926
|
-
description:
|
|
1927
|
-
'Optional model override. Accepts "provider/modelId" or fuzzy name (e.g. "haiku", "sonnet"). Omit to use the agent type\'s default.',
|
|
2318
|
+
description: modelParamDescription(),
|
|
1928
2319
|
}),
|
|
1929
2320
|
),
|
|
1930
2321
|
thinking: Type.Optional(
|
|
@@ -1945,7 +2336,7 @@ Terse command-style prompts produce shallow, generic work.
|
|
|
1945
2336
|
),
|
|
1946
2337
|
resume: Type.Optional(
|
|
1947
2338
|
Type.String({
|
|
1948
|
-
description: "Optional agent ID to resume from. Continues from previous context. Resumes detached like any other spawn; pass run_in_background: false to block and get the result inline. The resumed run gets a fresh max_turns budget. An agent can only be resumed once its current run has finished — use steer_subagent to reach one mid-run.",
|
|
2339
|
+
description: "Optional agent ID to resume from. Continues from previous context. Resumes detached like any other spawn; pass run_in_background: false to block and get the result inline. The resumed run gets a fresh max_turns budget. An agent can only be resumed once its current run has finished — use steer_subagent to reach one mid-run. An evicted agent's conversation is reopened from disk, background only, under a NEW Agent ID: use that id from then on (the old one also resolves to it).",
|
|
1949
2340
|
}),
|
|
1950
2341
|
),
|
|
1951
2342
|
isolated: Type.Optional(
|
|
@@ -2040,7 +2431,7 @@ Terse command-style prompts produce shallow, generic work.
|
|
|
2040
2431
|
// foreground path. Resume deliberately doesn't: it replays the stored
|
|
2041
2432
|
// session and ignores `subagent_type` entirely, so a note about type
|
|
2042
2433
|
// substitution would be describing something that didn't happen.
|
|
2043
|
-
const
|
|
2434
|
+
const typeNote = dispatch.ok && dispatch.fellBackFrom !== undefined
|
|
2044
2435
|
? `Note: Unknown agent type "${dispatch.fellBackFrom}" — using ${resolveType(subagentType) ? subagentType : "the fallback agent config"}.\n\n`
|
|
2045
2436
|
: "";
|
|
2046
2437
|
|
|
@@ -2050,36 +2441,22 @@ Terse command-style prompts produce shallow, generic work.
|
|
|
2050
2441
|
const customConfig = getAgentConfig(subagentType);
|
|
2051
2442
|
|
|
2052
2443
|
const resolvedConfig = resolveAgentInvocationConfig(customConfig, params, {
|
|
2053
|
-
agentType: subagentType,
|
|
2054
2444
|
worktreeAllowed: isWorktreeIsolationEnabled(),
|
|
2055
2445
|
defaultRunInBackground: getBackgroundByDefault(),
|
|
2056
2446
|
});
|
|
2057
2447
|
|
|
2058
|
-
//
|
|
2059
|
-
|
|
2060
|
-
|
|
2061
|
-
|
|
2062
|
-
|
|
2063
|
-
if (resolvedConfig.modelFromParams) return textResult(resolved);
|
|
2064
|
-
// config-specified: silent fallback to parent
|
|
2065
|
-
} else {
|
|
2066
|
-
model = resolved;
|
|
2067
|
-
}
|
|
2068
|
-
}
|
|
2069
|
-
|
|
2070
|
-
// Scope validation: the effective resolved model is checked against the
|
|
2071
|
-
// user's enabledModels list. Policy (hard error vs warn-and-proceed) lives
|
|
2072
|
-
// in model-scope.ts so the nested delegation tools apply the same rule.
|
|
2073
|
-
const scopeVerdict = checkModelScope({
|
|
2074
|
-
model,
|
|
2075
|
-
cwd: ctx.cwd,
|
|
2076
|
-
modelRegistry: ctx.modelRegistry,
|
|
2077
|
-
callerSupplied: resolvedConfig.modelFromParams,
|
|
2078
|
-
agentLabel: customConfig?.displayName ?? subagentType,
|
|
2079
|
-
modelInput: resolvedConfig.modelInput,
|
|
2448
|
+
// A live resume keeps its record's selection, ignoring new model inputs.
|
|
2449
|
+
const selection = params.resume ? undefined : selectModel({
|
|
2450
|
+
homeDir: options.homeDir,
|
|
2451
|
+
agentType: subagentType, definitionModel: customConfig?.model, paramModel: params.model,
|
|
2452
|
+
parentModel: ctx.model, registry: ctx.modelRegistry, cwd: ctx.cwd, agentLabel: customConfig?.displayName,
|
|
2080
2453
|
});
|
|
2081
|
-
if (
|
|
2082
|
-
|
|
2454
|
+
if (typeof selection === "string") return { ...textResult(selection), isError: true };
|
|
2455
|
+
const tierBlock = selection?.tierBinding && !params.schedule ? await admitModelSelection(selection, ctx.modelRegistry, pi.events) : undefined;
|
|
2456
|
+
const model = selection?.model;
|
|
2457
|
+
const modelFallback = selection?.fallback;
|
|
2458
|
+
|
|
2459
|
+
if (selection?.scopeWarning) ctx.ui.notify(selection.scopeWarning, "warning");
|
|
2083
2460
|
|
|
2084
2461
|
const thinking = resolvedConfig.thinking;
|
|
2085
2462
|
const inheritContext = resolvedConfig.inheritContext;
|
|
@@ -2088,7 +2465,7 @@ Terse command-style prompts produce shallow, generic work.
|
|
|
2088
2465
|
const isolation = resolvedConfig.isolation;
|
|
2089
2466
|
// Undefined = the session cwd. The manager validates it (absolute,
|
|
2090
2467
|
// existing directory) at spawn and again at start.
|
|
2091
|
-
const cwd = resolveCwdParam(params.cwd, ctx.cwd);
|
|
2468
|
+
const cwd = resolveCwdParam(params.cwd, ctx.cwd, options.homeDir);
|
|
2092
2469
|
// Whether this spawn writes its .output transcript. Per-agent
|
|
2093
2470
|
// frontmatter (`output_transcript`) wins; otherwise the project/global
|
|
2094
2471
|
// default applies. `attachTranscript` below is the SOLE gate — every
|
|
@@ -2106,9 +2483,7 @@ Terse command-style prompts produce shallow, generic work.
|
|
|
2106
2483
|
// parent's model used to show the level with nothing to attach it to.
|
|
2107
2484
|
// This is the pre-session snapshot — agent-manager overwrites it with the
|
|
2108
2485
|
// effective values the moment a session reports them.
|
|
2109
|
-
const { modelName, modelId } =
|
|
2110
|
-
? isVirtualModel(model) ? describeVirtualModel(model) : describeModel(model)
|
|
2111
|
-
: { modelName: undefined, modelId: undefined };
|
|
2486
|
+
const { modelName, modelId } = describeSelection(selection, ctx.modelRegistry);
|
|
2112
2487
|
const effectiveMaxTurns = normalizeMaxTurns(resolvedConfig.maxTurns ?? getDefaultMaxTurns());
|
|
2113
2488
|
const agentInvocation: AgentInvocation = {
|
|
2114
2489
|
modelName,
|
|
@@ -2126,11 +2501,34 @@ Terse command-style prompts produce shallow, generic work.
|
|
|
2126
2501
|
const modeLabel = getPromptModeLabel(subagentType);
|
|
2127
2502
|
const { tags: invocationTags } = buildInvocationTags(agentInvocation);
|
|
2128
2503
|
const agentTags = modeLabel ? [modeLabel, ...invocationTags] : invocationTags;
|
|
2504
|
+
/**
|
|
2505
|
+
* The lead-in for a result: the type note, then the model note. The model
|
|
2506
|
+
* note is read off the record the shared spawn path created. Before a
|
|
2507
|
+
* record exists (scheduling) it is this call's own resolution, the only
|
|
2508
|
+
* one there is. Never recomputed for a resume, which runs on its old session.
|
|
2509
|
+
*/
|
|
2510
|
+
const modelNoteOf = (rec: AgentRecord | undefined): string | undefined =>
|
|
2511
|
+
modelSelectionNote(rec ?? { modelFallback, modelTier: selection?.tier }) || undefined;
|
|
2512
|
+
const modelFallbackOf = (rec: AgentRecord | undefined): ModelFallback | undefined =>
|
|
2513
|
+
rec ? rec.modelFallback : modelFallback;
|
|
2514
|
+
/** A resume's lead-in: the record's note only (the type note describes a spawn, not a resume). */
|
|
2515
|
+
const resumeNoteFor = (rec: AgentRecord): string => {
|
|
2516
|
+
const note = [modelNoteOf(rec), modelIdentityNote(rec)].filter(Boolean).join("\n");
|
|
2517
|
+
return note ? `${note}\n\n` : "";
|
|
2518
|
+
};
|
|
2519
|
+
const fallbackNoteFor = (rec: AgentRecord | undefined): string => {
|
|
2520
|
+
// No record means a schedule or quota park, reselected at fire time: no identity claim.
|
|
2521
|
+
const modelNote = [modelNoteOf(rec), modelIdentityNote(rec)].filter(Boolean).join("\n");
|
|
2522
|
+
return typeNote + (modelNote ? `${modelNote}\n\n` : "");
|
|
2523
|
+
};
|
|
2129
2524
|
const detailBase = {
|
|
2130
2525
|
displayName,
|
|
2131
2526
|
description: params.description,
|
|
2132
2527
|
subagentType,
|
|
2133
2528
|
modelName,
|
|
2529
|
+
modelNote: modelNoteOf(undefined),
|
|
2530
|
+
modelFallback: modelFallbackOf(undefined),
|
|
2531
|
+
modelTier: selection?.tier ? tierDisclosure(selection.tier) : undefined,
|
|
2134
2532
|
tags: agentTags.length > 0 ? agentTags : undefined,
|
|
2135
2533
|
};
|
|
2136
2534
|
|
|
@@ -2148,7 +2546,10 @@ Terse command-style prompts produce shallow, generic work.
|
|
|
2148
2546
|
* buildInvocationTags would silently drop `twin`.
|
|
2149
2547
|
*/
|
|
2150
2548
|
const detailBaseFor = (rec: AgentRecord | undefined): typeof detailBase => {
|
|
2151
|
-
|
|
2549
|
+
// A record's disclosure outranks this call's, even before its session
|
|
2550
|
+
// reports an invocation: resume reuses the old session, so this call's
|
|
2551
|
+
// config says nothing about what ran.
|
|
2552
|
+
if (!rec?.invocation) return rec ? { ...detailBase, modelNote: modelNoteOf(rec), modelFallback: modelFallbackOf(rec), modelTier: rec.modelTier } : detailBase;
|
|
2152
2553
|
const type = rec.type;
|
|
2153
2554
|
const { modelName: recModelName, tags } = buildInvocationTags(rec.invocation);
|
|
2154
2555
|
const recModeLabel = getPromptModeLabel(type);
|
|
@@ -2158,6 +2559,9 @@ Terse command-style prompts produce shallow, generic work.
|
|
|
2158
2559
|
description: rec.description,
|
|
2159
2560
|
subagentType: type,
|
|
2160
2561
|
modelName: recModelName,
|
|
2562
|
+
modelNote: modelNoteOf(rec),
|
|
2563
|
+
modelFallback: modelFallbackOf(rec),
|
|
2564
|
+
modelTier: rec.modelTier,
|
|
2161
2565
|
tags: recTags.length > 0 ? recTags : undefined,
|
|
2162
2566
|
};
|
|
2163
2567
|
};
|
|
@@ -2182,6 +2586,7 @@ Terse command-style prompts produce shallow, generic work.
|
|
|
2182
2586
|
if (!scheduler.isActive()) {
|
|
2183
2587
|
return textResult("Scheduler is not active in this session yet. Try again after the session has fully started.");
|
|
2184
2588
|
}
|
|
2589
|
+
if (planningNow(pi.events)) return textResult(PLAN_SCHEDULE_REFUSAL);
|
|
2185
2590
|
try {
|
|
2186
2591
|
const job = scheduler.addJob({
|
|
2187
2592
|
name: params.description as string,
|
|
@@ -2191,7 +2596,7 @@ Terse command-style prompts produce shallow, generic work.
|
|
|
2191
2596
|
// at fire time, and the original is what a user edits.
|
|
2192
2597
|
subagent_type: requestedType,
|
|
2193
2598
|
prompt: params.prompt as string,
|
|
2194
|
-
model: params.model as string | undefined,
|
|
2599
|
+
model: selection?.requested?.startsWith("@") ? selection.requested : params.model as string | undefined,
|
|
2195
2600
|
thinking: thinking,
|
|
2196
2601
|
max_turns: effectiveMaxTurns,
|
|
2197
2602
|
isolated: isolated,
|
|
@@ -2199,23 +2604,62 @@ Terse command-style prompts produce shallow, generic work.
|
|
|
2199
2604
|
});
|
|
2200
2605
|
const next = scheduler.getNextRun(job.id);
|
|
2201
2606
|
return textResult(
|
|
2202
|
-
`${
|
|
2607
|
+
`${fallbackNoteFor(undefined)}Scheduled "${job.name}" (id: ${job.id}, type: ${job.scheduleType}). ` +
|
|
2203
2608
|
`Next run: ${next ?? "(unknown)"}. ` +
|
|
2204
2609
|
`Manage via /agents → Scheduled jobs.`,
|
|
2205
2610
|
);
|
|
2206
2611
|
} catch (err) {
|
|
2207
|
-
return textResult(
|
|
2612
|
+
return textResult(errorMessage(err));
|
|
2208
2613
|
}
|
|
2209
2614
|
}
|
|
2210
2615
|
|
|
2211
2616
|
// Resume existing agent
|
|
2212
2617
|
if (params.resume) {
|
|
2213
|
-
|
|
2214
|
-
|
|
2618
|
+
// An evicted id that was reopened resolves to the newest record of the
|
|
2619
|
+
// same conversation, so it never reopens the session file twice (#330).
|
|
2620
|
+
const latest = manager.latestId(params.resume);
|
|
2621
|
+
const continued = latest === params.resume ? "" : `Agent ${params.resume} continues as ${latest}; use that id from now on.\n`;
|
|
2622
|
+
const existing = manager.getRecord(latest);
|
|
2623
|
+
if (!existing) {
|
|
2624
|
+
// Evicted by the retention sweep, but a persisted conversation is
|
|
2625
|
+
// still on disk: reopen it, as `@handle` does.
|
|
2626
|
+
const entry = manager.listTombstones().find(t => t.id === latest);
|
|
2627
|
+
if (!entry) {
|
|
2628
|
+
// Evicted with no session file left to reopen: `rememberAgents` off, or the tombstone was dropped.
|
|
2629
|
+
const note = manager.getEvictedNote(latest);
|
|
2630
|
+
const gone = note?.nameTaken
|
|
2631
|
+
? `its name "${note.nameTaken}" now belongs to another agent, so its saved session was not kept reopenable.`
|
|
2632
|
+
: "no saved session is left to reopen.";
|
|
2633
|
+
const why = note ? `Cannot resume agent "${latest}": ${gone}\n${describeEvicted(note)}` : agentNotFound(latest);
|
|
2634
|
+
return textResult(`${continued}${why}`);
|
|
2635
|
+
}
|
|
2636
|
+
// The reopen is a fresh detached spawn: nothing can block on it here.
|
|
2637
|
+
if (!runInBackground) {
|
|
2638
|
+
return textResult(
|
|
2639
|
+
`${continued}Agent "${latest}" was evicted; its conversation reopens only in the background, under a new Agent ID. ` +
|
|
2640
|
+
"Call Agent again with this resume and run_in_background: true.",
|
|
2641
|
+
);
|
|
2642
|
+
}
|
|
2643
|
+
const reopened = await reopenEvicted(ctx, entry, params.prompt, {
|
|
2644
|
+
maxTurns: getAgentConfig(entry.type)?.maxTurns ?? params.max_turns,
|
|
2645
|
+
});
|
|
2646
|
+
if (!reopened.ok) return textResult(`${continued}${couldNotResume(`agent "${latest}"`, reopened.failure)}`);
|
|
2647
|
+
// No `.output` line: like every non-tool spawn it writes none, and
|
|
2648
|
+
// the session file it reopened is the full transcript.
|
|
2649
|
+
return textResult(
|
|
2650
|
+
`${continued}Agent resumed in background, reopening evicted agent ${latest}.\n` +
|
|
2651
|
+
`Agent ID: ${reopened.id} (new; ${params.resume} also resolves to it)\n` +
|
|
2652
|
+
`Type: ${entry.type}\n` +
|
|
2653
|
+
`Session file: ${entry.sessionFile}\n` +
|
|
2654
|
+
`\nYou will be notified when this agent completes.\n` +
|
|
2655
|
+
`Use get_subagent_result to retrieve full results, or steer_subagent to send it messages.`,
|
|
2656
|
+
);
|
|
2657
|
+
}
|
|
2658
|
+
if (!isTopLevelAgent(existing)) {
|
|
2215
2659
|
return textResult(`Agent not found: "${params.resume}". It may have been cleaned up.`);
|
|
2216
2660
|
}
|
|
2217
2661
|
if (!existing.session) {
|
|
2218
|
-
return textResult(
|
|
2662
|
+
return textResult(`${continued}Agent "${existing.id}" has no active session to resume.`);
|
|
2219
2663
|
}
|
|
2220
2664
|
// Not `effectiveMaxTurns`: that was resolved for `subagent_type`, which
|
|
2221
2665
|
// a resume ignores, so it may describe another agent.
|
|
@@ -2233,7 +2677,7 @@ Terse command-style prompts produce shallow, generic work.
|
|
|
2233
2677
|
// instead of letting it read as a generic failure.
|
|
2234
2678
|
if (existing.status === "running" || existing.status === "queued") {
|
|
2235
2679
|
return textResult(
|
|
2236
|
-
|
|
2680
|
+
`${continued}Agent "${existing.id}" is still ${existing.status} — it can only be resumed once its current run finishes.\n` +
|
|
2237
2681
|
`Use steer_subagent to send it a message mid-run, or get_subagent_result to wait for it.`,
|
|
2238
2682
|
);
|
|
2239
2683
|
}
|
|
@@ -2252,7 +2696,7 @@ Terse command-style prompts produce shallow, generic work.
|
|
|
2252
2696
|
|
|
2253
2697
|
const isQueued = record.status === "queued";
|
|
2254
2698
|
return textResult(
|
|
2255
|
-
|
|
2699
|
+
`${continued}${resumeNoteFor(record)}Agent ${isQueued ? "queued" : "resumed"} in background.\n` +
|
|
2256
2700
|
`Agent ID: ${id}\n` +
|
|
2257
2701
|
`Type: ${existing.type}\n` +
|
|
2258
2702
|
(record.outputFile ? `Output file: ${record.outputFile}\n` : "") +
|
|
@@ -2275,7 +2719,7 @@ Terse command-style prompts produce shallow, generic work.
|
|
|
2275
2719
|
fleet.update();
|
|
2276
2720
|
let record: AgentRecord | undefined;
|
|
2277
2721
|
try {
|
|
2278
|
-
record = await manager.resume(
|
|
2722
|
+
record = await manager.resume(existing.id, params.prompt, signal, {
|
|
2279
2723
|
maxTurns: resumeMaxTurns,
|
|
2280
2724
|
onTurnEnd: resumeCallbacks.onTurnEnd,
|
|
2281
2725
|
onTextDelta: resumeCallbacks.onTextDelta,
|
|
@@ -2294,10 +2738,10 @@ Terse command-style prompts produce shallow, generic work.
|
|
|
2294
2738
|
// A failed resume surfaces the error, plus any partial output THIS
|
|
2295
2739
|
// resume produced (never the previous turn's answer, #144).
|
|
2296
2740
|
if (record.status === "error") {
|
|
2297
|
-
return textResult(
|
|
2741
|
+
return textResult(`${resumeNoteFor(record)}Agent failed: ${record.error}${partialOutputSuffix(record)}`, buildDetails(detailBaseFor(record), record, resumeState));
|
|
2298
2742
|
}
|
|
2299
2743
|
return textResult(
|
|
2300
|
-
record.result?.trim() || "No output."
|
|
2744
|
+
`${continued}${resumeNoteFor(record)}${record.result?.trim() || "No output."}`,
|
|
2301
2745
|
buildDetails(detailBaseFor(record), record, resumeState),
|
|
2302
2746
|
);
|
|
2303
2747
|
}
|
|
@@ -2308,7 +2752,7 @@ Terse command-style prompts produce shallow, generic work.
|
|
|
2308
2752
|
const backgroundResult = (id: string, record: AgentRecord | undefined) => {
|
|
2309
2753
|
const isQueued = record?.status === "queued";
|
|
2310
2754
|
return textResult(
|
|
2311
|
-
`${
|
|
2755
|
+
`${fallbackNoteFor(record)}Agent ${isQueued ? "queued" : "started"} in background.\n` +
|
|
2312
2756
|
`Agent ID: ${id}\n` +
|
|
2313
2757
|
`Type: ${displayName}\n` +
|
|
2314
2758
|
`Description: ${params.description}\n` +
|
|
@@ -2327,21 +2771,25 @@ Terse command-style prompts produce shallow, generic work.
|
|
|
2327
2771
|
// exhaustion policy says fail now (`wait: false`). Anything else fails
|
|
2328
2772
|
// now, with the reset, rather than starting on an exhausted provider.
|
|
2329
2773
|
const dispatchModel = model ?? ctx.model;
|
|
2330
|
-
const quotaBlock =
|
|
2774
|
+
const quotaBlock = selection?.tierBinding ? tierBlock
|
|
2775
|
+
: dispatchModel ? await checkAdmission(pi.events, modelRef(dispatchModel), undefined, clock) : undefined;
|
|
2331
2776
|
if (quotaBlock) {
|
|
2332
2777
|
// A parked job is a scheduled job, which has no `cwd` to carry.
|
|
2333
2778
|
const canPark = runInBackground && !inheritContext && cwd === undefined && mayPark(quotaBlock)
|
|
2334
2779
|
&& isSchedulingEnabled() && scheduler.isActive();
|
|
2335
2780
|
if (!canPark || !mayPark(quotaBlock)) {
|
|
2336
|
-
throw new Error(`${describeBlock(quotaBlock)} The agent was not started.`);
|
|
2781
|
+
throw new Error(`${selection?.tier ? `${modelNoteOf(undefined)}\n` : ""}${describeBlock(quotaBlock)} The agent was not started.`);
|
|
2337
2782
|
}
|
|
2783
|
+
if (planningNow(pi.events)) throw new Error(`${describeBlock(quotaBlock)} ${PLAN_PARK_REFUSAL}`);
|
|
2338
2784
|
const job = scheduler.addJob({
|
|
2339
2785
|
name: `${params.description} (quota ${nanoid(4)})`,
|
|
2340
2786
|
description: params.description as string,
|
|
2341
2787
|
schedule: new Date(quotaBlock.resetAt + QUOTA_RESTART_GRACE_MS).toISOString(),
|
|
2342
2788
|
subagent_type: requestedType,
|
|
2343
2789
|
prompt: params.prompt as string,
|
|
2344
|
-
|
|
2790
|
+
// Keep the request, not today's configured/parent pick. Fire-time
|
|
2791
|
+
// selection must still apply agentModels and aliases.
|
|
2792
|
+
model: selection?.requested?.startsWith("@") ? selection.requested : params.model,
|
|
2345
2793
|
thinking,
|
|
2346
2794
|
max_turns: effectiveMaxTurns,
|
|
2347
2795
|
isolated,
|
|
@@ -2349,8 +2797,8 @@ Terse command-style prompts produce shallow, generic work.
|
|
|
2349
2797
|
quotaParked: true,
|
|
2350
2798
|
});
|
|
2351
2799
|
return textResult(
|
|
2352
|
-
`${
|
|
2353
|
-
`${
|
|
2800
|
+
`${fallbackNoteFor(undefined)}${describeBlock(quotaBlock)} Queued "${job.name}" (id: ${job.id}) to start at ` +
|
|
2801
|
+
`${job.schedule}. Manage via /agents → Scheduled jobs.`,
|
|
2354
2802
|
);
|
|
2355
2803
|
}
|
|
2356
2804
|
|
|
@@ -2359,8 +2807,7 @@ Terse command-style prompts produce shallow, generic work.
|
|
|
2359
2807
|
const { state: bgState, callbacks: bgCallbacks } = createActivityTracker(effectiveMaxTurns, undefined, clock);
|
|
2360
2808
|
|
|
2361
2809
|
// Wrap onSessionCreated to wire output file streaming.
|
|
2362
|
-
// The callback
|
|
2363
|
-
// rather than closing over a value that doesn't exist yet.
|
|
2810
|
+
// The callback reads record.outputFile prepared before launch.
|
|
2364
2811
|
let id: string;
|
|
2365
2812
|
const origBgOnSession = bgCallbacks.onSessionCreated;
|
|
2366
2813
|
bgCallbacks.onSessionCreated = (session: any) => {
|
|
@@ -2375,6 +2822,7 @@ Terse command-style prompts produce shallow, generic work.
|
|
|
2375
2822
|
// tool call failed, whereas a returned message reads to the model as a
|
|
2376
2823
|
// subagent that ran and reported this (#179).
|
|
2377
2824
|
id = manager.spawn(pi, ctx, subagentType, params.prompt, {
|
|
2825
|
+
modelSelection: selection,
|
|
2378
2826
|
description: params.description,
|
|
2379
2827
|
name: params.name as string | undefined,
|
|
2380
2828
|
model,
|
|
@@ -2387,18 +2835,17 @@ Terse command-style prompts produce shallow, generic work.
|
|
|
2387
2835
|
cwd,
|
|
2388
2836
|
invocation: agentInvocation,
|
|
2389
2837
|
rootSessionId: ctx.sessionManager.getSessionId(),
|
|
2838
|
+
onPrepare: (agentId) => attachTranscript(manager.getRecord(agentId), agentId),
|
|
2390
2839
|
...bgCallbacks,
|
|
2391
2840
|
});
|
|
2392
2841
|
|
|
2393
|
-
//
|
|
2394
|
-
//
|
|
2395
|
-
const joinMode =
|
|
2396
|
-
|
|
2397
|
-
|
|
2398
|
-
|
|
2399
|
-
|
|
2400
|
-
attachTranscript(record, id);
|
|
2401
|
-
}
|
|
2842
|
+
// `spawn` has not yielded yet, so the join mode and tool-call id are set before the
|
|
2843
|
+
// record's session starts.
|
|
2844
|
+
const joinMode = defaultJoinMode;
|
|
2845
|
+
// `spawn` registered the record before returning its id.
|
|
2846
|
+
const record = manager.getRecord(id)!;
|
|
2847
|
+
record.joinMode = joinMode;
|
|
2848
|
+
record.toolCallId = toolCallId;
|
|
2402
2849
|
|
|
2403
2850
|
// With isolation: "worktree" the agent isn't running yet — the repo
|
|
2404
2851
|
// copy is an awaited git call. Wait for it here, after the synchronous
|
|
@@ -2420,6 +2867,7 @@ Terse command-style prompts produce shallow, generic work.
|
|
|
2420
2867
|
type: subagentType,
|
|
2421
2868
|
description: params.description,
|
|
2422
2869
|
isBackground: true,
|
|
2870
|
+
...eventFields(manager.getRecord(id)),
|
|
2423
2871
|
});
|
|
2424
2872
|
|
|
2425
2873
|
return backgroundResult(id, record);
|
|
@@ -2478,8 +2926,7 @@ Terse command-style prompts produce shallow, generic work.
|
|
|
2478
2926
|
const { state: fgState, callbacks: fgCallbacks } = createActivityTracker(effectiveMaxTurns, streamUpdate, clock);
|
|
2479
2927
|
|
|
2480
2928
|
// Wire session creation: register in widget + stream to output file.
|
|
2481
|
-
// The output file path is
|
|
2482
|
-
// before onSessionCreated fires — same pattern as background agents.
|
|
2929
|
+
// The output file path is prepared before launch, as for background agents.
|
|
2483
2930
|
const origOnSession = fgCallbacks.onSessionCreated;
|
|
2484
2931
|
fgCallbacks.onSessionCreated = (session: any) => {
|
|
2485
2932
|
origOnSession(session);
|
|
@@ -2497,16 +2944,11 @@ Terse command-style prompts produce shallow, generic work.
|
|
|
2497
2944
|
widget.ensureTimer();
|
|
2498
2945
|
fleet.ensureTimer();
|
|
2499
2946
|
fleet.update();
|
|
2947
|
+
// Stream conversation to output file (foreground agent logging)
|
|
2948
|
+
if (a.outputFile) a.outputCleanup = streamToOutputFile(session, a.outputFile, a.id, ctx.cwd);
|
|
2500
2949
|
break;
|
|
2501
2950
|
}
|
|
2502
2951
|
}
|
|
2503
|
-
// Stream conversation to output file (foreground agent logging)
|
|
2504
|
-
if (fgId) {
|
|
2505
|
-
const rec = manager.getRecord(fgId);
|
|
2506
|
-
if (rec?.outputFile) {
|
|
2507
|
-
rec.outputCleanup = streamToOutputFile(session, rec.outputFile, fgId, ctx.cwd);
|
|
2508
|
-
}
|
|
2509
|
-
}
|
|
2510
2952
|
};
|
|
2511
2953
|
|
|
2512
2954
|
// Animate spinner at ~80ms (smooth rotation through 10 braille frames)
|
|
@@ -2528,6 +2970,7 @@ Terse command-style prompts produce shallow, generic work.
|
|
|
2528
2970
|
let fgResult: { record: AgentRecord } | undefined;
|
|
2529
2971
|
try {
|
|
2530
2972
|
fgResult = await Promise.race([manager.spawnAndWait(pi, ctx, subagentType, params.prompt, {
|
|
2973
|
+
modelSelection: selection,
|
|
2531
2974
|
description: params.description,
|
|
2532
2975
|
name: params.name as string | undefined,
|
|
2533
2976
|
model,
|
|
@@ -2544,12 +2987,12 @@ Terse command-style prompts produce shallow, generic work.
|
|
|
2544
2987
|
// widget and the `finally` cleanup below, none of which should see an
|
|
2545
2988
|
// agent that has no session and may never get one.
|
|
2546
2989
|
onQueued: (_id, ahead) => { queuedAhead = ahead; streamUpdate(); },
|
|
2990
|
+
onPrepare: (agentId) => attachTranscript(manager.getRecord(agentId), agentId),
|
|
2547
2991
|
...fgCallbacks,
|
|
2548
2992
|
}, (fgAgentId) => {
|
|
2549
|
-
// onSpawned
|
|
2550
|
-
//
|
|
2551
|
-
const fgRec = manager.getRecord(fgAgentId)
|
|
2552
|
-
attachTranscript(fgRec, fgAgentId);
|
|
2993
|
+
// onSpawned fires after the run promise exists, before onSessionCreated.
|
|
2994
|
+
// Running, so the manager still holds it: only settled records are removed.
|
|
2995
|
+
const fgRec = manager.getRecord(fgAgentId)!;
|
|
2553
2996
|
// Fires once the run promise exists, i.e. the agent is running. From
|
|
2554
2997
|
// here until this call returns, the run can be handed off.
|
|
2555
2998
|
handoffId = fgAgentId;
|
|
@@ -2557,10 +3000,8 @@ Terse command-style prompts produce shallow, generic work.
|
|
|
2557
3000
|
// What a background spawn sets up front: the notification names
|
|
2558
3001
|
// this tool call, and it reports alone — its spawn batch is long
|
|
2559
3002
|
// closed.
|
|
2560
|
-
|
|
2561
|
-
|
|
2562
|
-
fgRec.joinMode = "async";
|
|
2563
|
-
}
|
|
3003
|
+
fgRec.toolCallId = toolCallId;
|
|
3004
|
+
fgRec.joinMode = "async";
|
|
2564
3005
|
backgrounded = true;
|
|
2565
3006
|
wakeBackgrounded();
|
|
2566
3007
|
});
|
|
@@ -2591,10 +3032,10 @@ Terse command-style prompts produce shallow, generic work.
|
|
|
2591
3032
|
|
|
2592
3033
|
if (record.status === "error") {
|
|
2593
3034
|
// Error headline + any partial output the run produced before failing.
|
|
2594
|
-
return textResult(`${
|
|
3035
|
+
return textResult(`${fallbackNoteFor(record)}Agent failed: ${record.error}${partialOutputSuffix(record)}`, details);
|
|
2595
3036
|
}
|
|
2596
3037
|
|
|
2597
|
-
const durationMs = (record
|
|
3038
|
+
const durationMs = recordElapsedMs(record, Date.now());
|
|
2598
3039
|
const statsParts = [`${record.toolUses} tool uses`];
|
|
2599
3040
|
if (tokenText) statsParts.push(tokenText);
|
|
2600
3041
|
if (showCost) {
|
|
@@ -2602,7 +3043,7 @@ Terse command-style prompts produce shallow, generic work.
|
|
|
2602
3043
|
if (costText) statsParts.push(costText);
|
|
2603
3044
|
}
|
|
2604
3045
|
return textResult(
|
|
2605
|
-
`${
|
|
3046
|
+
`${fallbackNoteFor(record)}Agent completed in ${formatMs(durationMs)} (${statsParts.join(", ")})${getForegroundOutcomeNote(record.status, record.stoppedBy)}.\n\n` +
|
|
2606
3047
|
(record.result?.trim() || "No output."),
|
|
2607
3048
|
details,
|
|
2608
3049
|
);
|
|
@@ -2685,13 +3126,15 @@ Terse command-style prompts produce shallow, generic work.
|
|
|
2685
3126
|
// run's progress log here would put O(log) work in the render loop.
|
|
2686
3127
|
return [...workflowTasks.values()].map(task => ({
|
|
2687
3128
|
id: task.id,
|
|
2688
|
-
|
|
3129
|
+
// Every task here is created from a parsed `meta`, which also names the run.
|
|
3130
|
+
name: task.meta!.name,
|
|
2689
3131
|
status: task.status,
|
|
2690
3132
|
doneCount: task.doneCount,
|
|
2691
3133
|
totalCount: task.agentCount,
|
|
2692
3134
|
startedAt: task.startTime,
|
|
2693
3135
|
...(task.endTime !== undefined ? { completedAt: task.endTime } : {}),
|
|
2694
3136
|
tokens: task.totalTokens,
|
|
3137
|
+
observationKey: task,
|
|
2695
3138
|
}));
|
|
2696
3139
|
}
|
|
2697
3140
|
|
|
@@ -2708,6 +3151,7 @@ Terse command-style prompts produce shallow, generic work.
|
|
|
2708
3151
|
args: task.args,
|
|
2709
3152
|
signal: task.abortController.signal,
|
|
2710
3153
|
host: createWorkflowHost({
|
|
3154
|
+
homeDir: options.homeDir,
|
|
2711
3155
|
pi,
|
|
2712
3156
|
ctx,
|
|
2713
3157
|
manager,
|
|
@@ -2728,7 +3172,7 @@ Terse command-style prompts produce shallow, generic work.
|
|
|
2728
3172
|
});
|
|
2729
3173
|
completeWorkflowTask(task, result);
|
|
2730
3174
|
} catch (err) {
|
|
2731
|
-
failWorkflowTask(task,
|
|
3175
|
+
failWorkflowTask(task, errorMessage(err));
|
|
2732
3176
|
}
|
|
2733
3177
|
}
|
|
2734
3178
|
|
|
@@ -2745,7 +3189,7 @@ Terse command-style prompts produce shallow, generic work.
|
|
|
2745
3189
|
content: formatWorkflowNotification(task),
|
|
2746
3190
|
details: {
|
|
2747
3191
|
id: task.id,
|
|
2748
|
-
description: `Workflow ${task.
|
|
3192
|
+
description: `Workflow ${task.meta!.name}`,
|
|
2749
3193
|
status: task.status === "completed" ? "completed" : task.status === "killed" ? "stopped" : "error",
|
|
2750
3194
|
toolUses: task.totalToolCalls,
|
|
2751
3195
|
// A workflow has agents, not turns; rendering "↻0" would be noise.
|
|
@@ -2865,6 +3309,7 @@ Terse command-style prompts produce shallow, generic work.
|
|
|
2865
3309
|
? { scriptPath: resumeFrom.scriptPath }
|
|
2866
3310
|
: params,
|
|
2867
3311
|
ctx.cwd,
|
|
3312
|
+
options.homeDir,
|
|
2868
3313
|
);
|
|
2869
3314
|
if (!resolved.ok) return textResult(resolved.message);
|
|
2870
3315
|
|
|
@@ -2875,7 +3320,7 @@ Terse command-style prompts produce shallow, generic work.
|
|
|
2875
3320
|
try {
|
|
2876
3321
|
meta = extractMeta(resolved.script).meta;
|
|
2877
3322
|
} catch (err) {
|
|
2878
|
-
return textResult(
|
|
3323
|
+
return textResult(errorMessage(err));
|
|
2879
3324
|
}
|
|
2880
3325
|
|
|
2881
3326
|
const runId = workflowRunId();
|
|
@@ -2893,7 +3338,7 @@ Terse command-style prompts produce shallow, generic work.
|
|
|
2893
3338
|
} catch (err) {
|
|
2894
3339
|
savedPath = undefined;
|
|
2895
3340
|
journalPath = undefined;
|
|
2896
|
-
console.warn(`[pi-subagents] could not persist workflow script: ${
|
|
3341
|
+
console.warn(`[pi-subagents] could not persist workflow script: ${errorMessage(err)}`);
|
|
2897
3342
|
}
|
|
2898
3343
|
|
|
2899
3344
|
const replay = resumeFrom !== undefined ? readJournal(resumeFrom.journalPath) : undefined;
|
|
@@ -2928,7 +3373,7 @@ Terse command-style prompts produce shallow, generic work.
|
|
|
2928
3373
|
`Task ID: ${runId}\n` +
|
|
2929
3374
|
(task.scriptPath ? `Script: ${task.scriptPath}\n` : "") +
|
|
2930
3375
|
(task.resumedFrom !== undefined
|
|
2931
|
-
? `Resuming ${task.resumedFrom}: ${task.replay
|
|
3376
|
+
? `Resuming ${task.resumedFrom}: ${task.replay!.length} recorded call(s) available to replay.\n`
|
|
2932
3377
|
: params.resumeFromRunId !== undefined
|
|
2933
3378
|
? `Nothing to replay from ${params.resumeFromRunId} — every agent runs live.\n`
|
|
2934
3379
|
: "") +
|
|
@@ -3053,7 +3498,7 @@ Terse command-style prompts produce shallow, generic work.
|
|
|
3053
3498
|
try {
|
|
3054
3499
|
script = readFileSync(path, "utf-8");
|
|
3055
3500
|
} catch (err) {
|
|
3056
|
-
report(`Could not read ${path}: ${
|
|
3501
|
+
report(`Could not read ${path}: ${errorMessage(err)}`, "warning");
|
|
3057
3502
|
return;
|
|
3058
3503
|
}
|
|
3059
3504
|
|
|
@@ -3061,7 +3506,7 @@ Terse command-style prompts produce shallow, generic work.
|
|
|
3061
3506
|
try {
|
|
3062
3507
|
meta = extractMeta(script).meta;
|
|
3063
3508
|
} catch (err) {
|
|
3064
|
-
report(
|
|
3509
|
+
report(errorMessage(err), "warning");
|
|
3065
3510
|
return;
|
|
3066
3511
|
}
|
|
3067
3512
|
|
|
@@ -3100,7 +3545,7 @@ Terse command-style prompts produce shallow, generic work.
|
|
|
3100
3545
|
name: SUBAGENT_TOOL_NAMES.GET_RESULT,
|
|
3101
3546
|
label: "Get Agent Result",
|
|
3102
3547
|
description:
|
|
3103
|
-
"Check status and retrieve a background agent's full result
|
|
3548
|
+
"Check status and retrieve a background agent's full result: use it while the agent runs, or when its completion notification says the result was truncated. Use the agent ID returned by Agent.",
|
|
3104
3549
|
promptSnippet: "Check status and retrieve results from a background agent",
|
|
3105
3550
|
parameters: Type.Object({
|
|
3106
3551
|
agent_id: Type.String({
|
|
@@ -3149,8 +3594,10 @@ Terse command-style prompts produce shallow, generic work.
|
|
|
3149
3594
|
|
|
3150
3595
|
let output =
|
|
3151
3596
|
`Agent: ${record.id}\n` +
|
|
3152
|
-
`Type: ${displayName} | Status: ${record.status}${getStatusNote(record.status)} | ${statsParts.join(" | ")}\n` +
|
|
3153
|
-
`Description: ${record.description}\n
|
|
3597
|
+
`Type: ${displayName} | Status: ${record.status}${getStatusNote(record.status, record.stoppedBy)} | ${statsParts.join(" | ")}\n` +
|
|
3598
|
+
`Description: ${record.description}\n` +
|
|
3599
|
+
(modelSelectionNote(record) ? `${modelSelectionNote(record)}\n` : "") +
|
|
3600
|
+
`\n`;
|
|
3154
3601
|
|
|
3155
3602
|
if (record.status === "running") {
|
|
3156
3603
|
output += "Agent is still running. Use wait: true or check back later.";
|
|
@@ -3163,6 +3610,7 @@ Terse command-style prompts produce shallow, generic work.
|
|
|
3163
3610
|
// Mark result as consumed — suppresses the completion notification
|
|
3164
3611
|
if (record.status !== "running" && record.status !== "queued") {
|
|
3165
3612
|
markConsumed(record, clock.now());
|
|
3613
|
+
fleet.acknowledgeFailure(record.id);
|
|
3166
3614
|
outbox.cancel(record.id);
|
|
3167
3615
|
}
|
|
3168
3616
|
|
|
@@ -3184,23 +3632,46 @@ Terse command-style prompts produce shallow, generic work.
|
|
|
3184
3632
|
name: SUBAGENT_TOOL_NAMES.STEER,
|
|
3185
3633
|
label: "Steer Agent",
|
|
3186
3634
|
description:
|
|
3187
|
-
"Send a
|
|
3188
|
-
"
|
|
3189
|
-
|
|
3635
|
+
"Send a message to an agent. A running agent is interrupted after its current tool execution and the message is injected into its conversation, " +
|
|
3636
|
+
"allowing you to redirect its work mid-run. A queued agent gets the message when it starts. A finished agent is resumed in the background " +
|
|
3637
|
+
"with its full history and the message as its next prompt, exactly like Agent's resume; you will be notified when it completes. " +
|
|
3638
|
+
"A stopped agent is not resumed. To end an agent's run, use stop_subagent: a steer is read only after its current tool call, and the agent may ignore it.",
|
|
3639
|
+
promptSnippet: "Send a message to a background agent: steer it mid-run, or resume it once finished",
|
|
3190
3640
|
parameters: Type.Object({
|
|
3191
3641
|
agent_id: Type.String({
|
|
3192
|
-
description: "The agent ID to
|
|
3642
|
+
description: "The agent ID to message. The agent's handle also works — its `name` if you gave it one, otherwise its type (`explore`, `explore-2`).",
|
|
3193
3643
|
}),
|
|
3194
3644
|
message: Type.String({
|
|
3195
|
-
description: "The
|
|
3645
|
+
description: "The message to send. This will appear as a user message in the agent's conversation.",
|
|
3196
3646
|
}),
|
|
3197
3647
|
}),
|
|
3198
|
-
execute: async (
|
|
3199
|
-
|
|
3200
|
-
|
|
3201
|
-
|
|
3202
|
-
|
|
3648
|
+
execute: async (toolCallId, params, signal, onUpdate, ctx) => {
|
|
3649
|
+
// An evicted id that was reopened answers as the record it continues under (#330).
|
|
3650
|
+
const latest = manager.latestId(params.agent_id);
|
|
3651
|
+
const record = resolveAgentRef(latest);
|
|
3652
|
+
if (record && !isTopLevelAgent(record)) return textResult(agentNotFound(params.agent_id));
|
|
3653
|
+
if (record?.status !== "running" && record?.status !== "queued") {
|
|
3654
|
+
// Finished: continue it, as Claude Code's SendMessage does. An evicted
|
|
3655
|
+
// record is known only by its note; Agent's resume reopens its session
|
|
3656
|
+
// from disk or explains why it cannot.
|
|
3657
|
+
const finished = record ?? manager.getEvictedNote(latest);
|
|
3658
|
+
if (!finished) return textResult(agentNotFound(params.agent_id));
|
|
3659
|
+
if (finished.status === "stopped") {
|
|
3660
|
+
return textResult(`Agent "${finished.id}" was stopped (status: stopped). steer_subagent does not resume a stopped agent.`);
|
|
3661
|
+
}
|
|
3662
|
+
// The Agent tool's own resume path, not a copy: same reopen, turn
|
|
3663
|
+
// budget, transcript and result. `subagent_type` is the record's own,
|
|
3664
|
+
// so its frontmatter governs as it does for an `@handle` resume.
|
|
3665
|
+
return agentTool.execute(toolCallId, {
|
|
3666
|
+
resume: latest === params.agent_id ? finished.id : params.agent_id,
|
|
3667
|
+
prompt: params.message,
|
|
3668
|
+
description: finished.description,
|
|
3669
|
+
subagent_type: finished.type,
|
|
3670
|
+
run_in_background: true,
|
|
3671
|
+
}, signal, onUpdate, ctx);
|
|
3203
3672
|
}
|
|
3673
|
+
// Running or queued under no Plan policy: a steer from a planning parent would drive edits.
|
|
3674
|
+
if (planSteerBlocked(record, pi.events)) return textResult(planSteerRefusal(record.id));
|
|
3204
3675
|
if (!record.session) {
|
|
3205
3676
|
// Queued for a concurrency slot, or running with its session still
|
|
3206
3677
|
// being built: either way the manager flushes `pendingSteers` into the
|
|
@@ -3232,32 +3703,134 @@ Terse command-style prompts produce shallow, generic work.
|
|
|
3232
3703
|
`Current state: ${stateParts.join(" · ")}`,
|
|
3233
3704
|
);
|
|
3234
3705
|
} catch (err) {
|
|
3235
|
-
return textResult(`Failed to steer agent: ${
|
|
3706
|
+
return textResult(`Failed to steer agent: ${errorMessage(err)}`);
|
|
3236
3707
|
}
|
|
3237
3708
|
},
|
|
3238
3709
|
}));
|
|
3239
3710
|
|
|
3711
|
+
// ---- stop_subagent tool ----
|
|
3712
|
+
|
|
3713
|
+
/** A workflow run a stop can still reach: running or paused, not already told to stop. */
|
|
3714
|
+
const isLiveWorkflow = (task: WorkflowTask) => (task.status === "running" || task.status === "paused") && !task.abortController.signal.aborted;
|
|
3715
|
+
|
|
3716
|
+
/** Survivors of a session switch, still running in the orphaned manager (#330). */
|
|
3717
|
+
const runningSurvivors = () => listRunningDetached(currentCtx?.sessionManager?.getSessionFile?.());
|
|
3718
|
+
|
|
3719
|
+
/**
|
|
3720
|
+
* Agents a stop can reach: this session's own running or queued top-level
|
|
3721
|
+
* agents, then survivors. Never a nested or workflow child: those belong to
|
|
3722
|
+
* their owner, which stops them with itself (#446's ownership invariant).
|
|
3723
|
+
*/
|
|
3724
|
+
function stoppableAgents(): AgentRecord[] {
|
|
3725
|
+
const own = manager.listAgents().filter(r => isTopLevelAgent(r) && (r.status === "running" || r.status === "queued"));
|
|
3726
|
+
return [...own, ...runningSurvivors().map(d => d.record).filter(r => r.status === "running")];
|
|
3727
|
+
}
|
|
3728
|
+
|
|
3729
|
+
/** Stop one top-level agent through the manager that owns it, this one or an orphan's. */
|
|
3730
|
+
function stopAgent(record: AgentRecord, by?: AgentRecord["stoppedBy"]): boolean {
|
|
3731
|
+
if (manager.getRecord(record.id) === record) return manager.abort(record.id, by);
|
|
3732
|
+
return !!runningSurvivors().find(d => d.record === record)?.abort(by);
|
|
3733
|
+
}
|
|
3734
|
+
|
|
3735
|
+
/** What a stop that matched nothing lists instead: everything still running or queued. */
|
|
3736
|
+
function describeStoppable(): string {
|
|
3737
|
+
const lines = [
|
|
3738
|
+
...stoppableAgents().map(r => `- ${r.id} @${r.alias ?? r.handle} (${r.status}): ${r.description}`),
|
|
3739
|
+
...[...workflowTasks.values()].filter(isLiveWorkflow).map(t => `- ${t.id} workflow "${t.meta!.name}" (${t.status})`),
|
|
3740
|
+
];
|
|
3741
|
+
return lines.length > 0 ? `Running or queued:\n${lines.join("\n")}` : "Nothing is running or queued.";
|
|
3742
|
+
}
|
|
3743
|
+
|
|
3744
|
+
/**
|
|
3745
|
+
* Stop every running or queued top-level agent, survivor, and workflow run
|
|
3746
|
+
* (whose agents stop with it). A scheduled job that has not fired is not
|
|
3747
|
+
* running, so it is untouched. The human-side stop: records stay unmarked.
|
|
3748
|
+
*/
|
|
3749
|
+
function stopAllRunning(): { agents: number; workflows: number } {
|
|
3750
|
+
const runs = [...workflowTasks.values()].filter(isLiveWorkflow);
|
|
3751
|
+
for (const task of runs) task.abortController.abort();
|
|
3752
|
+
return { agents: stoppableAgents().filter(record => stopAgent(record)).length, workflows: runs.length };
|
|
3753
|
+
}
|
|
3754
|
+
|
|
3755
|
+
registerToolReportingUsage(defineTool({
|
|
3756
|
+
name: SUBAGENT_TOOL_NAMES.STOP,
|
|
3757
|
+
label: "Stop Agent",
|
|
3758
|
+
description:
|
|
3759
|
+
"Stop (cancel, kill) a running or queued background agent, or a SubagentWorkflow run by the run id it returned. " +
|
|
3760
|
+
"Use it when an agent's work is no longer wanted, e.g. a sibling already answered. Unlike steer_subagent, the agent cannot ignore it: " +
|
|
3761
|
+
"the run is aborted at once, its current tool call gets the abort signal, and it starts no further tool call. Its status becomes `stopped`. " +
|
|
3762
|
+
"A running agent's partial output, and any worktree branch with changes, arrives in one completion notice; a queued agent never starts. " +
|
|
3763
|
+
"A workflow stops with all of its agents. A stopped agent is not resumed. " +
|
|
3764
|
+
"A run of another extension's `workflow` tool is not ours to stop: use that extension's control (e.g. `workflow_control` with action `stop`).",
|
|
3765
|
+
promptSnippet: "Stop or cancel a running background agent or workflow",
|
|
3766
|
+
// Plan mode admits a trusted tool only on `readOnlyHint`. Stopping edits no
|
|
3767
|
+
// file and starts nothing; it only ends work already running, so it must
|
|
3768
|
+
// stay callable in Plan, where an orchestrator should still be able to
|
|
3769
|
+
// stand a fan-out down.
|
|
3770
|
+
annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false },
|
|
3771
|
+
parameters: Type.Object({
|
|
3772
|
+
agent_id: Type.String({
|
|
3773
|
+
description: "The agent ID to stop. Its handle or name also works. A SubagentWorkflow run id stops that workflow run.",
|
|
3774
|
+
}),
|
|
3775
|
+
}),
|
|
3776
|
+
execute: async (_toolCallId, params) => {
|
|
3777
|
+
const task = workflowTasks.get(params.agent_id);
|
|
3778
|
+
if (task && isLiveWorkflow(task)) {
|
|
3779
|
+
// Marked first: the run's notice is written when it settles, after this returns.
|
|
3780
|
+
task.stoppedBy = "parent";
|
|
3781
|
+
task.abortController.abort();
|
|
3782
|
+
return textResult(`Workflow "${task.meta!.name}" (${task.id}) stopped, with its agents. A completion notice follows.`);
|
|
3783
|
+
}
|
|
3784
|
+
if (task && (task.status === "running" || task.status === "paused")) {
|
|
3785
|
+
// Told to stop, still unwinding: not "not running", and nothing more to do.
|
|
3786
|
+
return textResult(`Workflow "${task.meta!.name}" (${task.id}) is already stopping. A completion notice follows.`);
|
|
3787
|
+
}
|
|
3788
|
+
const latest = manager.latestId(params.agent_id);
|
|
3789
|
+
const found = resolveAgentRef(latest);
|
|
3790
|
+
const record = found && isTopLevelAgent(found) ? found : undefined;
|
|
3791
|
+
if (record?.status !== "running" && record?.status !== "queued") {
|
|
3792
|
+
const status = record?.status ?? task?.status ?? manager.getEvictedNote(latest)?.status;
|
|
3793
|
+
const what = status ? `"${params.agent_id}" is not running (status: ${status})` : `No agent or workflow "${params.agent_id}"`;
|
|
3794
|
+
return { ...textResult(`${what}, so there is nothing to stop.\n${describeStoppable()}`), isError: true };
|
|
3795
|
+
}
|
|
3796
|
+
const wasQueued = record.status === "queued";
|
|
3797
|
+
stopAgent(record, "parent");
|
|
3798
|
+
const statsParts = [`Tool uses: ${record.toolUses}`];
|
|
3799
|
+
const tokens = formatLifetimeTokens(record);
|
|
3800
|
+
if (tokens) statsParts.push(tokens);
|
|
3801
|
+
return textResult(
|
|
3802
|
+
`Agent ${record.id} stopped.\n` +
|
|
3803
|
+
`Type: ${getDisplayName(record.type)} | Status: ${record.status} | ${statsParts.join(" | ")}\n` +
|
|
3804
|
+
`Description: ${record.description}\n\n` +
|
|
3805
|
+
(wasQueued
|
|
3806
|
+
? "It was still queued, so it never started and no completion notice follows."
|
|
3807
|
+
: "It starts no further tool call. One completion notice with its partial output follows."),
|
|
3808
|
+
);
|
|
3809
|
+
},
|
|
3810
|
+
}));
|
|
3811
|
+
|
|
3240
3812
|
// ---- /agents interactive menu ----
|
|
3241
3813
|
|
|
3242
3814
|
// Directory resolution and the frontmatter edits live in agent-file-toggle.ts
|
|
3243
3815
|
// so they are reachable from tests — this command handler is only registered
|
|
3244
3816
|
// through `registerCommand`, which every test mocks.
|
|
3245
3817
|
|
|
3246
|
-
function getModelLabel(type: string, registry
|
|
3818
|
+
function getModelLabel(type: string, registry: ModelRegistry, cwd: string): string {
|
|
3247
3819
|
const cfg = getAgentConfig(type);
|
|
3248
|
-
|
|
3249
|
-
|
|
3250
|
-
|
|
3251
|
-
const
|
|
3252
|
-
|
|
3253
|
-
|
|
3254
|
-
|
|
3820
|
+
const input = modelRequest(type, cfg?.model).requested;
|
|
3821
|
+
if (!input) return "inherit";
|
|
3822
|
+
const label = getModelLabelFromConfig(input);
|
|
3823
|
+
const selection = selectModel({ homeDir: options.homeDir, agentType: type, definitionModel: cfg?.model, parentModel: undefined, registry, cwd });
|
|
3824
|
+
if (typeof selection === "string" || selection.outcome === "fallback" || !selection.model) {
|
|
3825
|
+
return `${label} (unavailable, fallback: inherit)`;
|
|
3826
|
+
}
|
|
3827
|
+
const resolved = selection.model;
|
|
3255
3828
|
// Surface what it actually resolved to when that differs from the config —
|
|
3256
3829
|
// e.g. a provider fallback or a looser version pin. Cosmetic separator/date
|
|
3257
3830
|
// differences are normalized away so an effectively-identical match stays quiet.
|
|
3258
3831
|
const resolvedFull = `${resolved.provider}/${resolved.id}`;
|
|
3259
3832
|
const norm = (s: string) => s.toLowerCase().replace(/\./g, "-").replace(/-\d{8}$/, "");
|
|
3260
|
-
if (norm(
|
|
3833
|
+
if (norm(input) === norm(resolvedFull)) return label;
|
|
3261
3834
|
return `${label} (→ ${resolvedFull.replace(/-\d{8}$/, "")})`;
|
|
3262
3835
|
}
|
|
3263
3836
|
|
|
@@ -3275,6 +3848,9 @@ Terse command-style prompts produce shallow, generic work.
|
|
|
3275
3848
|
const done = agents.filter(a => a.status === "completed" || a.status === "steered").length;
|
|
3276
3849
|
options.push(`Running agents (${agents.length}) — ${running} running, ${done} done`);
|
|
3277
3850
|
}
|
|
3851
|
+
// Survivors and workflow runs count too: they are what the chord stops.
|
|
3852
|
+
const stoppable = stoppableAgents().length + [...workflowTasks.values()].filter(isLiveWorkflow).length;
|
|
3853
|
+
if (stoppable > 0) options.push(`Stop all running (${stoppable})`);
|
|
3278
3854
|
|
|
3279
3855
|
// Agent types list
|
|
3280
3856
|
if (allNames.length > 0) {
|
|
@@ -3313,6 +3889,12 @@ Terse command-style prompts produce shallow, generic work.
|
|
|
3313
3889
|
if (choice.startsWith("Running agents (")) {
|
|
3314
3890
|
await showRunningAgents(ctx);
|
|
3315
3891
|
await showAgentsMenu(ctx);
|
|
3892
|
+
} else if (choice.startsWith("Stop all running (")) {
|
|
3893
|
+
if (await ctx.ui.confirm("Stop all running", "Stop every running or queued agent and workflow?")) {
|
|
3894
|
+
const { agents: stoppedAgents, workflows } = stopAllRunning();
|
|
3895
|
+
ctx.ui.notify(`Stopped ${stoppedAgents} agent(s) and ${workflows} workflow(s).`, "info");
|
|
3896
|
+
}
|
|
3897
|
+
await showAgentsMenu(ctx);
|
|
3316
3898
|
} else if (choice.startsWith("Agent types (")) {
|
|
3317
3899
|
await showAllAgentsList(ctx);
|
|
3318
3900
|
await showAgentsMenu(ctx);
|
|
@@ -3337,7 +3919,7 @@ Terse command-style prompts produce shallow, generic work.
|
|
|
3337
3919
|
*/
|
|
3338
3920
|
function packageNameOf(cfg: AgentConfig | undefined): string | undefined {
|
|
3339
3921
|
if (cfg?.source !== "package" || !cfg.sourcePath) return undefined;
|
|
3340
|
-
return packageNameForPath(cfg.sourcePath, process.cwd());
|
|
3922
|
+
return packageNameForPath(cfg.sourcePath, process.cwd(), "agents", { homeDir: options.homeDir });
|
|
3341
3923
|
}
|
|
3342
3924
|
|
|
3343
3925
|
async function showAllAgentsList(ctx: ExtensionCommandContext) {
|
|
@@ -3356,7 +3938,7 @@ Terse command-style prompts produce shallow, generic work.
|
|
|
3356
3938
|
// it: the `▪` badge says a package won, not which one.
|
|
3357
3939
|
const items: SettingItem[] = allNames.map((name, i) => {
|
|
3358
3940
|
const cfg = configs[i];
|
|
3359
|
-
const model = getModelLabel(name, ctx.modelRegistry);
|
|
3941
|
+
const model = getModelLabel(name, ctx.modelRegistry, ctx.cwd);
|
|
3360
3942
|
return {
|
|
3361
3943
|
id: name,
|
|
3362
3944
|
label: `${sourceIndicator(cfg)}${name}`,
|
|
@@ -3391,8 +3973,9 @@ Terse command-style prompts produce shallow, generic work.
|
|
|
3391
3973
|
};
|
|
3392
3974
|
});
|
|
3393
3975
|
|
|
3394
|
-
|
|
3395
|
-
|
|
3976
|
+
const cfg = selected ? getAgentConfig(selected) : undefined;
|
|
3977
|
+
if (selected && cfg) {
|
|
3978
|
+
await showAgentDetail(ctx, selected, cfg);
|
|
3396
3979
|
await showAllAgentsList(ctx);
|
|
3397
3980
|
}
|
|
3398
3981
|
}
|
|
@@ -3433,9 +4016,9 @@ Terse command-style prompts produce shallow, generic work.
|
|
|
3433
4016
|
(tui, theme, keybindings, done) => {
|
|
3434
4017
|
overlayOptions = getConversationOverlayOptions(tui);
|
|
3435
4018
|
return new ConversationViewer(tui, session, record, activity, theme, done, () => {
|
|
3436
|
-
|
|
3437
|
-
|
|
3438
|
-
}
|
|
4019
|
+
// The viewer offers stop only while the agent is running or queued, which `abort` always honours.
|
|
4020
|
+
manager.abort(record.id);
|
|
4021
|
+
ctx.ui.notify(`Stopped "${record.description}".`, "info");
|
|
3439
4022
|
}, keybindings, (message: string) => manager.steer(record.id, message), showCost, getViewerMarkdown, (mode) => chooseViewerMarkdown(mode, ctx), getFullscreenScrollbar(),
|
|
3440
4023
|
backgroundActionFor(record, message => ctx.ui.notify(message, "info")), clock);
|
|
3441
4024
|
},
|
|
@@ -3446,13 +4029,7 @@ Terse command-style prompts produce shallow, generic work.
|
|
|
3446
4029
|
);
|
|
3447
4030
|
}
|
|
3448
4031
|
|
|
3449
|
-
async function showAgentDetail(ctx: ExtensionCommandContext, name: string) {
|
|
3450
|
-
const cfg = getAgentConfig(name);
|
|
3451
|
-
if (!cfg) {
|
|
3452
|
-
ctx.ui.notify(`Agent config not found for "${name}".`, "warning");
|
|
3453
|
-
return;
|
|
3454
|
-
}
|
|
3455
|
-
|
|
4032
|
+
async function showAgentDetail(ctx: ExtensionCommandContext, name: string, cfg: AgentConfig) {
|
|
3456
4033
|
const isPackage = cfg.source === "package";
|
|
3457
4034
|
const file = locateAgentFile(name, cfg.sourcePath, undefined, cfg.source);
|
|
3458
4035
|
const isDefault = cfg.isDefault === true;
|
|
@@ -3470,10 +4047,9 @@ Terse command-style prompts produce shallow, generic work.
|
|
|
3470
4047
|
? ["Eject (export as .md)", "Back"]
|
|
3471
4048
|
: ["Eject (export as .md)", "Disable", "Back"];
|
|
3472
4049
|
} else if (disabled && file) {
|
|
3473
|
-
// Disabled agent with a file — offer Enable
|
|
3474
|
-
|
|
3475
|
-
|
|
3476
|
-
: ["Enable", "Edit", "Delete", "Back"];
|
|
4050
|
+
// Disabled agent with a file — offer Enable. Never a built-in: a built-in is only ever
|
|
4051
|
+
// disabled by the very file that replaces it, which makes the entry a user definition.
|
|
4052
|
+
menuOptions = ["Enable", "Edit", "Delete", "Back"];
|
|
3477
4053
|
} else if (isDefault && !file) {
|
|
3478
4054
|
// Default agent with no .md override
|
|
3479
4055
|
menuOptions = ["Eject (export as .md)", "Disable", "Back"];
|
|
@@ -3779,7 +4355,8 @@ Write the file using the write tool. Only write the file, nothing else.`;
|
|
|
3779
4355
|
else if (modelChoice === "sonnet") model = "anthropic/claude-sonnet-4-6";
|
|
3780
4356
|
else if (modelChoice === "opus") model = "anthropic/claude-opus-4-6";
|
|
3781
4357
|
else if (modelChoice === "custom...") {
|
|
3782
|
-
model
|
|
4358
|
+
// A blank answer writes no `model:` line, exactly as no answer does.
|
|
4359
|
+
model = await ctx.ui.input("Model (provider/modelId)");
|
|
3783
4360
|
}
|
|
3784
4361
|
|
|
3785
4362
|
// 5. Thinking
|
|
@@ -3831,6 +4408,7 @@ Write the file using the write tool. Only write the file, nothing else.`;
|
|
|
3831
4408
|
// normalizeMaxTurns() in agent-runner.ts (which maps 0 → undefined).
|
|
3832
4409
|
defaultMaxTurns: getDefaultMaxTurns() ?? 0,
|
|
3833
4410
|
graceTurns: getGraceTurns(),
|
|
4411
|
+
notificationResultChars,
|
|
3834
4412
|
defaultJoinMode: getDefaultJoinMode(),
|
|
3835
4413
|
backgroundByDefault: getBackgroundByDefault(),
|
|
3836
4414
|
schedulingEnabled: isSchedulingEnabled(),
|
|
@@ -3862,6 +4440,8 @@ Write the file using the write tool. Only write the file, nothing else.`;
|
|
|
3862
4440
|
maxActiveSubagents: undefined,
|
|
3863
4441
|
nestedSubagentReserve: undefined,
|
|
3864
4442
|
modelAliases: undefined,
|
|
4443
|
+
modelTiers: undefined,
|
|
4444
|
+
modelResolution: undefined,
|
|
3865
4445
|
agentModels: undefined,
|
|
3866
4446
|
reportUsage: isReportUsageEnabled(),
|
|
3867
4447
|
showCost: isShowCostEnabled(),
|
|
@@ -3909,6 +4489,7 @@ Write the file using the write tool. Only write the file, nothing else.`;
|
|
|
3909
4489
|
|
|
3910
4490
|
const NUMERIC_IDS = new Set([
|
|
3911
4491
|
"maxConcurrent", "maxConcurrentForeground", "defaultMaxTurns", "graceTurns", "maxSubagentDepth",
|
|
4492
|
+
"notificationResultChars",
|
|
3912
4493
|
]);
|
|
3913
4494
|
|
|
3914
4495
|
async function showSettings(ctx: ExtensionCommandContext) {
|
|
@@ -3962,6 +4543,13 @@ Write the file using the write tool. Only write the file, nothing else.`;
|
|
|
3962
4543
|
currentValue: String(msd),
|
|
3963
4544
|
values: [String(msd)],
|
|
3964
4545
|
},
|
|
4546
|
+
{
|
|
4547
|
+
id: "notificationResultChars",
|
|
4548
|
+
label: "Notice result chars",
|
|
4549
|
+
description: "Result characters a completion notice carries per agent (0 = 500-char preview, Enter to type)",
|
|
4550
|
+
currentValue: String(notificationResultChars),
|
|
4551
|
+
values: [String(notificationResultChars)],
|
|
4552
|
+
},
|
|
3965
4553
|
{
|
|
3966
4554
|
id: "joinMode",
|
|
3967
4555
|
label: "Join mode",
|
|
@@ -4159,6 +4747,14 @@ Write the file using the write tool. Only write the file, nothing else.`;
|
|
|
4159
4747
|
setGraceTurns(n);
|
|
4160
4748
|
notifyApplied(ctx, `Grace turns set to ${n}`);
|
|
4161
4749
|
}
|
|
4750
|
+
} else if (id === "notificationResultChars") {
|
|
4751
|
+
const n = parseInt(value, 10);
|
|
4752
|
+
if (n >= 0 && n <= NOTIFICATION_RESULT_CHARS_CEILING) {
|
|
4753
|
+
notificationResultChars = n;
|
|
4754
|
+
notifyApplied(ctx, n === 0
|
|
4755
|
+
? "Completion notices carry a 500-char preview"
|
|
4756
|
+
: `Completion notices carry up to ${n} result chars per agent`);
|
|
4757
|
+
}
|
|
4162
4758
|
} else if (id === "maxSubagentDepth") {
|
|
4163
4759
|
const n = parseInt(value, 10);
|
|
4164
4760
|
if (n >= 0) {
|
|
@@ -4300,7 +4896,8 @@ Write the file using the write tool. Only write the file, nothing else.`;
|
|
|
4300
4896
|
const enabled = value === "on";
|
|
4301
4897
|
setRememberAgents(enabled);
|
|
4302
4898
|
notifyApplied(ctx, `Remember agents ${enabled ? "enabled" : "disabled"}`);
|
|
4303
|
-
} else
|
|
4899
|
+
} else {
|
|
4900
|
+
// widgetMode, the last row: every id `buildItems` offers has an arm.
|
|
4304
4901
|
setWidgetMode(value as WidgetMode);
|
|
4305
4902
|
notifyApplied(ctx, `Widget set to ${value}`);
|
|
4306
4903
|
}
|
|
@@ -4360,7 +4957,9 @@ Write the file using the write tool. Only write the file, nothing else.`;
|
|
|
4360
4957
|
? String(getDefaultMaxTurns() ?? 0)
|
|
4361
4958
|
: result === "maxSubagentDepth"
|
|
4362
4959
|
? String(getMaxSubagentDepth())
|
|
4363
|
-
:
|
|
4960
|
+
: result === "notificationResultChars"
|
|
4961
|
+
? String(notificationResultChars)
|
|
4962
|
+
: String(getGraceTurns());
|
|
4364
4963
|
|
|
4365
4964
|
const label = result === "maxConcurrent"
|
|
4366
4965
|
? "Max concurrency (1+)"
|
|
@@ -4370,7 +4969,9 @@ Write the file using the write tool. Only write the file, nothing else.`;
|
|
|
4370
4969
|
? "Default max turns (0 = unlimited)"
|
|
4371
4970
|
: result === "maxSubagentDepth"
|
|
4372
4971
|
? "Nested depth (0/1 = nesting off)"
|
|
4373
|
-
:
|
|
4972
|
+
: result === "notificationResultChars"
|
|
4973
|
+
? `Notice result chars (0 = preview, max ${NOTIFICATION_RESULT_CHARS_CEILING})`
|
|
4974
|
+
: "Grace turns (1+)";
|
|
4374
4975
|
|
|
4375
4976
|
// Loop until user enters a valid integer or cancels (Esc / null).
|
|
4376
4977
|
// Silently trims whitespace; rejects non-numeric input by re-prompting.
|