@nredd/pi-subagents 0.22.0-nredd.8 → 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 +136 -55
- package/package.json +14 -11
- package/src/abortable.ts +1 -1
- package/src/agent-file-toggle.ts +0 -1
- package/src/agent-manager.ts +360 -103
- package/src/agent-runner.ts +121 -63
- package/src/child-context.ts +19 -1
- package/src/completion-outbox.ts +22 -3
- package/src/cross-extension-rpc.ts +10 -6
- package/src/custom-agents.ts +2 -1
- package/src/default-agents.ts +19 -17
- package/src/detached-results.ts +18 -2
- package/src/enterprise-local.ts +2 -1
- package/src/error-message.ts +11 -0
- package/src/git-path.ts +66 -0
- package/src/group-join.ts +4 -10
- package/src/index.ts +731 -231
- package/src/invocation-config.ts +1 -5
- package/src/mention-clone.ts +4 -3
- package/src/model-resolver.ts +51 -14
- package/src/model-selection.ts +17 -6
- package/src/model-tiers.ts +5 -0
- package/src/nested-tools.ts +80 -21
- package/src/notification-result.ts +118 -0
- package/src/plan-mode-children.ts +176 -0
- package/src/prompts.ts +7 -2
- package/src/quota-admission.ts +16 -6
- package/src/resource-loader.ts +20 -6
- package/src/schedule.ts +65 -19
- package/src/settings.ts +21 -1
- package/src/skill-loader.ts +244 -94
- package/src/status-note.ts +28 -5
- package/src/types.ts +52 -2
- package/src/ui/agent-mention.ts +5 -2
- package/src/ui/agent-widget.ts +41 -22
- package/src/ui/conversation-viewer.ts +4 -2
- package/src/ui/fallback-flag.ts +28 -4
- package/src/ui/fleet-list.ts +89 -54
- package/src/ui/notification-view.ts +4 -1
- package/src/ui/workflow-card.ts +1 -8
- package/src/ui/workflow-dialog.ts +31 -18
- package/src/workflow/host.ts +4 -5
- package/src/workflow/meta.ts +7 -4
- package/src/workflow/progress.ts +4 -3
- package/src/workflow/runtime.ts +23 -7
- package/src/workflow/saved.ts +2 -4
- package/src/workflow/task.ts +12 -7
- package/src/workflow/tool-description.ts +2 -2
- package/src/workflow/worker-source.ts +6 -2
- 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
|
|
@@ -19,7 +20,7 @@ import { Type } from "typebox";
|
|
|
19
20
|
import { abortable } from "./abortable.js";
|
|
20
21
|
import { hasAgentBadge, renderAgentName } from "./agent-color.js";
|
|
21
22
|
import { buildNewAgentFile, disableInContent, enableInContent, isEmptyStub, locateAgentFile as locateAgentFileAtHome, personalAgentsDir as personalAgentsDirAtHome, projectAgentsDir, serializeAgentFile } from "./agent-file-toggle.js";
|
|
22
|
-
import { AgentManager, describeEvicted, isTopLevelAgent, markConsumed } from "./agent-manager.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,27 +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
45
|
import { resolveAgentDir } from "./home-paths.js";
|
|
43
|
-
import { cwdParam, isolationParam, resolveAgentInvocationConfig, resolveCwdParam
|
|
46
|
+
import { cwdParam, isolationParam, resolveAgentInvocationConfig, resolveCwdParam } from "./invocation-config.js";
|
|
44
47
|
import { describeMention, handleBase, isReservedHandle, parseMention, resolveHandleToType, stripAgentPrefix } from "./mention.js";
|
|
45
48
|
import { runMentionClone } from "./mention-clone.js";
|
|
46
49
|
import { applyModelCallEvent, type ModelCallEvent } from "./model-call.js";
|
|
47
50
|
import { type ModelFallback, type ModelRegistry, setAgentModels, setModelAliases, setModelScopeProvider } from "./model-resolver.js";
|
|
48
51
|
import { isScopeModelsEnabled, setScopeModelsEnabled } from "./model-scope.js";
|
|
49
|
-
import { admitModelSelection, describeSelection, modelRequest, modelSelectionNote, selectModel } from "./model-selection.js";
|
|
50
|
-
import { tierDisclosure } from "./model-tiers.js";
|
|
52
|
+
import { admitModelSelection, describeSelection, modelIdentityNote, modelRequest, modelSelectionNote, selectModel } from "./model-selection.js";
|
|
53
|
+
import { configuredTierNames, tierDisclosure } from "./model-tiers.js";
|
|
51
54
|
import { getMaxSubagentDepth, setMaxSubagentDepth } from "./nested-tools.js";
|
|
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";
|
|
52
56
|
import { appendModelFallbackEntry, appendModelTierEntry, createOutputFilePath, ensureOutputFile, getOutputTranscriptDefault, sessionTaskDir, setOutputTranscriptDefault, streamToOutputFile, writeInitialEntry } from "./output-file.js";
|
|
53
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";
|
|
54
59
|
import { getBudgetPolicy, setBudgetPolicy } from "./process-budget.js";
|
|
55
60
|
import { checkAdmission, describeBlock, mayPark, modelRef, QUOTA_RESTART_GRACE_MS } from "./quota-admission.js";
|
|
56
61
|
import { SubagentScheduler } from "./schedule.js";
|
|
57
62
|
import { resolveStorePath, ScheduleStore } from "./schedule-store.js";
|
|
58
63
|
import { applyAndEmitLoaded, loadSettings, type SubagentsSettings, saveAndEmitChanged, type ToolDescriptionMode } from "./settings.js";
|
|
59
64
|
import { hostStartupErrorClass } from "./startup-error.js";
|
|
60
|
-
import { getForegroundOutcomeNote, getStatusNote, partialOutputSuffix } from "./status-note.js";
|
|
61
|
-
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";
|
|
62
67
|
import { createMentionProvider, mentionRoster, type TypeInfo } from "./ui/agent-mention.js";
|
|
63
68
|
import { AgentCallView, AgentResultView, type AgentRowLive, keepRowTicking, keepTicking } from "./ui/agent-row.js";
|
|
64
69
|
import {
|
|
@@ -115,7 +120,7 @@ function formatLifetimeTokens(o: { lifetimeUsage: LifetimeUsage }): string {
|
|
|
115
120
|
* Create an AgentActivity state and spawn callbacks for tracking tool usage.
|
|
116
121
|
* Used by both foreground and background paths to avoid duplication.
|
|
117
122
|
*/
|
|
118
|
-
function createActivityTracker(maxTurns
|
|
123
|
+
function createActivityTracker(maxTurns: number | undefined, onStreamUpdate: (() => void) | undefined, clock: Clock) {
|
|
119
124
|
const state: AgentActivity = {
|
|
120
125
|
activeTools: new Map(),
|
|
121
126
|
toolUses: 0,
|
|
@@ -171,6 +176,11 @@ function createActivityTracker(maxTurns?: number, onStreamUpdate?: () => void, c
|
|
|
171
176
|
*/
|
|
172
177
|
const THINKING_LEVELS = ["off", "minimal", "low", "medium", "high", "xhigh", "max"] as const;
|
|
173
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
|
+
|
|
174
184
|
/** Human-readable status label for agent completion. */
|
|
175
185
|
function getStatusLabel(status: string, error?: string): string {
|
|
176
186
|
switch (status) {
|
|
@@ -182,10 +192,13 @@ function getStatusLabel(status: string, error?: string): string {
|
|
|
182
192
|
}
|
|
183
193
|
}
|
|
184
194
|
|
|
185
|
-
/**
|
|
186
|
-
|
|
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 {
|
|
187
200
|
const status = getStatusLabel(record.status, record.error);
|
|
188
|
-
const durationMs = record
|
|
201
|
+
const durationMs = recordElapsedMs(record, Date.now());
|
|
189
202
|
const totalTokens = getLifetimeTotal(record.lifetimeUsage);
|
|
190
203
|
const contextPercent = getSessionContextPercent(record.session);
|
|
191
204
|
const ctxXml = contextPercent !== null ? `<context_percent>${Math.round(contextPercent)}</context_percent>` : "";
|
|
@@ -195,24 +208,29 @@ function formatTaskNotification(record: AgentRecord, resultMaxLen: number, showC
|
|
|
195
208
|
const cost = showCost ? getLifetimeCost(record.lifetimeUsage) : 0;
|
|
196
209
|
const costXml = cost > 0 ? `<estimated_cost_usd>${cost.toFixed(4)}</estimated_cost_usd>` : "";
|
|
197
210
|
|
|
198
|
-
const
|
|
199
|
-
|
|
200
|
-
? record.result.slice(0, resultMaxLen) + "\n...(truncated, use get_subagent_result for full output)"
|
|
201
|
-
: record.result
|
|
202
|
-
: "No output.";
|
|
211
|
+
const modelNote = [modelSelectionNote(record), modelIdentityNote(record)].filter(Boolean).join("\n");
|
|
212
|
+
const hint = resumeHint(record);
|
|
203
213
|
|
|
204
|
-
|
|
214
|
+
const head = [
|
|
205
215
|
`<task-notification>`,
|
|
206
216
|
`<task-id>${record.id}</task-id>`,
|
|
207
217
|
record.toolCallId ? `<tool-use-id>${escapeXml(record.toolCallId)}</tool-use-id>` : null,
|
|
208
218
|
record.outputFile ? `<output-file>${escapeXml(record.outputFile)}</output-file>` : null,
|
|
209
219
|
`<status>${escapeXml(status)}</status>`,
|
|
210
|
-
`<summary>Agent "${escapeXml(record.description)}" ${record.status}${getStatusNote(record.status)}</summary>`,
|
|
211
|
-
|
|
212
|
-
|
|
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 = [
|
|
213
224
|
`<usage><total_tokens>${totalTokens}</total_tokens><tool_uses>${record.toolUses}</tool_uses>${ctxXml}${compactXml}${costXml}<duration_ms>${durationMs}</duration_ms></usage>`,
|
|
214
225
|
`</task-notification>`,
|
|
215
|
-
].
|
|
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.";
|
|
216
234
|
}
|
|
217
235
|
|
|
218
236
|
/** Build AgentDetails from a base + record-specific fields. */
|
|
@@ -232,7 +250,7 @@ function buildDetails(
|
|
|
232
250
|
cost: getLifetimeCost(record.lifetimeUsage),
|
|
233
251
|
turnCount: activity?.turnCount,
|
|
234
252
|
maxTurns: activity?.maxTurns,
|
|
235
|
-
durationMs: (record
|
|
253
|
+
durationMs: recordElapsedMs(record, Date.now()),
|
|
236
254
|
status: record.status as AgentDetails["status"],
|
|
237
255
|
agentId: record.id,
|
|
238
256
|
error: record.error,
|
|
@@ -242,6 +260,31 @@ function buildDetails(
|
|
|
242
260
|
}
|
|
243
261
|
|
|
244
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
|
+
}
|
|
245
288
|
function buildNotificationDetails(record: AgentRecord, resultMaxLen: number, activity?: AgentActivity): NotificationDetails {
|
|
246
289
|
const totalTokens = getLifetimeTotal(record.lifetimeUsage);
|
|
247
290
|
|
|
@@ -257,9 +300,10 @@ function buildNotificationDetails(record: AgentRecord, resultMaxLen: number, act
|
|
|
257
300
|
// data, and a notification rendered before a mid-session toggle should not
|
|
258
301
|
// be stuck with the old answer.
|
|
259
302
|
totalCost: getLifetimeCost(record.lifetimeUsage),
|
|
260
|
-
durationMs: record
|
|
303
|
+
durationMs: recordElapsedMs(record, Date.now()),
|
|
261
304
|
outputFile: record.outputFile,
|
|
262
305
|
error: record.error,
|
|
306
|
+
resumeHint: resumeHint(record) || undefined,
|
|
263
307
|
modelNote: modelSelectionNote(record) || undefined,
|
|
264
308
|
modelTier: record.modelTier,
|
|
265
309
|
resultPreview: record.result
|
|
@@ -364,7 +408,7 @@ function activateSubagents(pi: ExtensionAPI, options: SubagentsExtensionOptions)
|
|
|
364
408
|
try {
|
|
365
409
|
getBudgetPolicy();
|
|
366
410
|
} catch (error) {
|
|
367
|
-
const message =
|
|
411
|
+
const message = errorMessage(error);
|
|
368
412
|
if (!StartupError) throw new Error(message + " This host also lacks ExtensionStartupError; upgrade pi before retrying.", { cause: error });
|
|
369
413
|
pi.on("session_start", async () => { throw new StartupError(message, { cause: error }); });
|
|
370
414
|
return;
|
|
@@ -402,7 +446,11 @@ function activateSubagents(pi: ExtensionAPI, options: SubagentsExtensionOptions)
|
|
|
402
446
|
});
|
|
403
447
|
|
|
404
448
|
// Fuzzy model names ("opus") prefer what the user enabled in pi's settings.
|
|
405
|
-
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
|
+
});
|
|
406
454
|
|
|
407
455
|
// Read directly rather than waiting for applyAndEmitLoaded below: these decide
|
|
408
456
|
// the initial load, which happens hundreds of lines before settings are applied.
|
|
@@ -483,6 +531,8 @@ function activateSubagents(pi: ExtensionAPI, options: SubagentsExtensionOptions)
|
|
|
483
531
|
persistSettings(ctx, `Viewer markdown set to ${mode}`);
|
|
484
532
|
}
|
|
485
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;
|
|
486
536
|
|
|
487
537
|
// ---- Completion delivery ----
|
|
488
538
|
// Every completion notification goes through the outbox, which sends what is
|
|
@@ -490,15 +540,62 @@ function activateSubagents(pi: ExtensionAPI, options: SubagentsExtensionOptions)
|
|
|
490
540
|
// completion-outbox.ts). Each is built when the agent finishes, and reading
|
|
491
541
|
// the result (get_subagent_result, rpc consume) cancels it before it is sent.
|
|
492
542
|
const outbox = new CompletionOutbox(
|
|
493
|
-
(message) =>
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
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
|
+
},
|
|
499
563
|
() => currentCtx?.signal !== undefined,
|
|
500
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
|
+
},
|
|
501
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
|
+
}
|
|
502
599
|
// A queued result wait must observe completion before its held notification
|
|
503
600
|
// can fire, so successful waits can still suppress that redundant nudge.
|
|
504
601
|
const QUEUE_WAIT_POLL_MS = Math.floor(NOTIFICATION_HOLD_MS / 4);
|
|
@@ -511,11 +608,19 @@ function activateSubagents(pi: ExtensionAPI, options: SubagentsExtensionOptions)
|
|
|
511
608
|
// cannot be formatted is skipped, as a failed send always was.
|
|
512
609
|
try {
|
|
513
610
|
const footer = record.outputFile ? `\nFull transcript available at: ${record.outputFile}` : '';
|
|
514
|
-
const
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
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
|
+
});
|
|
519
624
|
} catch { /* ignore stale completion side-effect errors */ }
|
|
520
625
|
widget.update();
|
|
521
626
|
}
|
|
@@ -527,15 +632,21 @@ function activateSubagents(pi: ExtensionAPI, options: SubagentsExtensionOptions)
|
|
|
527
632
|
|
|
528
633
|
const members = records.filter(r => !r.resultConsumed).flatMap(r => {
|
|
529
634
|
try {
|
|
530
|
-
return [
|
|
635
|
+
return [snapshotNotice(r, GROUP_PREVIEW_RESULT_CHARS)];
|
|
531
636
|
} catch { return []; /* as in sendIndividualNudge */ }
|
|
532
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." : "";
|
|
533
640
|
if (members.length > 0) {
|
|
534
641
|
outbox.add({
|
|
535
642
|
ids: members.map(m => m.id),
|
|
536
|
-
|
|
643
|
+
resultChars: resultCharsOf(members),
|
|
644
|
+
build: (ids, caps) => {
|
|
537
645
|
const unread = members.filter(m => ids.includes(m.id));
|
|
538
|
-
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);
|
|
539
650
|
const label = partial
|
|
540
651
|
? `${unread.length} agent(s) finished (partial — others still running)`
|
|
541
652
|
: `${unread.length} agent(s) finished`;
|
|
@@ -544,8 +655,10 @@ function activateSubagents(pi: ExtensionAPI, options: SubagentsExtensionOptions)
|
|
|
544
655
|
const details = first.details;
|
|
545
656
|
if (rest.length > 0) details.others = rest.map(m => m.details);
|
|
546
657
|
return {
|
|
547
|
-
content: `Background agent group completed: ${label}\n\n${notifications}
|
|
658
|
+
content: `Background agent group completed: ${label}\n\n${notifications}${trailer}`,
|
|
548
659
|
details,
|
|
660
|
+
...(delivered.length > 0 ? { delivered } : {}),
|
|
661
|
+
...(noticed.length > 0 ? { noticed } : {}),
|
|
549
662
|
};
|
|
550
663
|
},
|
|
551
664
|
});
|
|
@@ -557,7 +670,7 @@ function activateSubagents(pi: ExtensionAPI, options: SubagentsExtensionOptions)
|
|
|
557
670
|
|
|
558
671
|
/** Helper: build event data for lifecycle events from an AgentRecord. */
|
|
559
672
|
function buildEventData(record: AgentRecord) {
|
|
560
|
-
const durationMs = record
|
|
673
|
+
const durationMs = recordElapsedMs(record, Date.now());
|
|
561
674
|
// All three fields are lifetime-accumulated (Σ over every assistant message_end),
|
|
562
675
|
// so they survive compaction together — input + output ≤ total always.
|
|
563
676
|
// tokens is omitted when nothing was ever produced (e.g. agent errored before
|
|
@@ -583,6 +696,7 @@ function activateSubagents(pi: ExtensionAPI, options: SubagentsExtensionOptions)
|
|
|
583
696
|
id: record.id,
|
|
584
697
|
type: record.type,
|
|
585
698
|
description: record.description,
|
|
699
|
+
...eventFields(record),
|
|
586
700
|
result: record.result,
|
|
587
701
|
error: record.error,
|
|
588
702
|
status: record.status,
|
|
@@ -590,8 +704,8 @@ function activateSubagents(pi: ExtensionAPI, options: SubagentsExtensionOptions)
|
|
|
590
704
|
durationMs,
|
|
591
705
|
tokens,
|
|
592
706
|
usage,
|
|
593
|
-
//
|
|
594
|
-
|
|
707
|
+
// Stamped by the only caller, the settle path, just before this runs.
|
|
708
|
+
settleSeq: record.settleSeq,
|
|
595
709
|
};
|
|
596
710
|
}
|
|
597
711
|
|
|
@@ -601,6 +715,30 @@ function activateSubagents(pi: ExtensionAPI, options: SubagentsExtensionOptions)
|
|
|
601
715
|
*/
|
|
602
716
|
let detachedSessionFile: string | undefined;
|
|
603
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
|
+
|
|
604
742
|
/** `modelFallback` + `modelNote` event fields, or nothing. Only what is KNOWN when the event fires. */
|
|
605
743
|
function fallbackFields(record: AgentRecord | undefined): Record<string, unknown> {
|
|
606
744
|
return {
|
|
@@ -621,15 +759,40 @@ function activateSubagents(pi: ExtensionAPI, options: SubagentsExtensionOptions)
|
|
|
621
759
|
};
|
|
622
760
|
}
|
|
623
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
|
+
|
|
624
779
|
function spoolDetachedResult(sessionFile: string, record: AgentRecord) {
|
|
625
780
|
if (record.resultConsumed) return;
|
|
626
781
|
const footer = record.outputFile ? `\nFull transcript available at: ${record.outputFile}` : "";
|
|
627
782
|
try {
|
|
783
|
+
const notice = snapshotNotice(record, PREVIEW_RESULT_CHARS);
|
|
628
784
|
spoolDetached(sessionFile, {
|
|
629
785
|
id: record.id,
|
|
630
|
-
content:
|
|
631
|
-
details:
|
|
786
|
+
content: notice.render().text + footer,
|
|
787
|
+
details: notice.details,
|
|
632
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)),
|
|
794
|
+
},
|
|
795
|
+
...(notice.parts ? { notice: { ...notice.parts, footer } } : {}),
|
|
633
796
|
});
|
|
634
797
|
} catch { /* spool unwritable: the transcript file still has the result */ }
|
|
635
798
|
}
|
|
@@ -639,22 +802,62 @@ function activateSubagents(pi: ExtensionAPI, options: SubagentsExtensionOptions)
|
|
|
639
802
|
for (const result of drainDetached(sessionFile)) deliverDetachedResult(result);
|
|
640
803
|
}
|
|
641
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
|
+
|
|
642
830
|
function deliverDetachedResult(result: DetachedResult) {
|
|
643
831
|
try {
|
|
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);
|
|
644
837
|
if (result.record) {
|
|
645
838
|
pi.appendEntry("subagents:record", result.record);
|
|
646
839
|
fleet.ingestFailure(result.record);
|
|
647
840
|
fleet.update();
|
|
648
841
|
}
|
|
649
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;
|
|
650
846
|
outbox.add({
|
|
651
847
|
ids: [details.id, ...(details.others?.map(other => other.id) ?? [])],
|
|
652
|
-
|
|
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 },
|
|
653
852
|
});
|
|
654
853
|
} catch { /* session already gone again; the transcript file still has the result */ }
|
|
655
854
|
}
|
|
656
855
|
|
|
657
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);
|
|
658
861
|
|
|
659
862
|
// Background completion: route through group join or send individual nudge
|
|
660
863
|
const manager = new AgentManager((record) => {
|
|
@@ -674,13 +877,8 @@ function activateSubagents(pi: ExtensionAPI, options: SubagentsExtensionOptions)
|
|
|
674
877
|
|
|
675
878
|
if (!record.resultConsumed) fleet.ingestFailure(record);
|
|
676
879
|
// Emit lifecycle event based on terminal status
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
if (isError) {
|
|
680
|
-
pi.events.emit("subagents:failed", eventData);
|
|
681
|
-
} else {
|
|
682
|
-
pi.events.emit("subagents:completed", eventData);
|
|
683
|
-
}
|
|
880
|
+
record.settleSeq = manager.nextSequence();
|
|
881
|
+
pi.events.emit(lifecycleChannel(record), buildEventData(record));
|
|
684
882
|
|
|
685
883
|
// Persist final record for cross-extension history reconstruction
|
|
686
884
|
pi.appendEntry("subagents:record", historyRecord(record));
|
|
@@ -718,12 +916,14 @@ function activateSubagents(pi: ExtensionAPI, options: SubagentsExtensionOptions)
|
|
|
718
916
|
fleet.ensureTimer();
|
|
719
917
|
fleet.update();
|
|
720
918
|
}
|
|
919
|
+
// Bind later handoff announcements to this exact run.
|
|
920
|
+
lifecycleRuns.set(record, manager.nextSequence());
|
|
721
921
|
// Emit started event when agent transitions to running (including from queue)
|
|
722
922
|
pi.events.emit("subagents:started", {
|
|
723
923
|
id: record.id,
|
|
724
924
|
type: record.type,
|
|
725
925
|
description: record.description,
|
|
726
|
-
...
|
|
926
|
+
...eventFields(record),
|
|
727
927
|
});
|
|
728
928
|
}, (record, info) => {
|
|
729
929
|
if (!isTopLevelAgent(record)) return;
|
|
@@ -735,7 +935,7 @@ function activateSubagents(pi: ExtensionAPI, options: SubagentsExtensionOptions)
|
|
|
735
935
|
reason: info.reason,
|
|
736
936
|
tokensBefore: info.tokensBefore,
|
|
737
937
|
compactionCount: record.compactionCount,
|
|
738
|
-
...
|
|
938
|
+
...eventFields(record),
|
|
739
939
|
});
|
|
740
940
|
}, (record, usage) => {
|
|
741
941
|
// Every assistant message from every agent — nested included, exactly once.
|
|
@@ -772,7 +972,7 @@ function activateSubagents(pi: ExtensionAPI, options: SubagentsExtensionOptions)
|
|
|
772
972
|
// can show `low (asked minimal)` instead of silently relabelling.
|
|
773
973
|
...(invocation?.requestedThinking !== undefined ? { requestedThinking: invocation.requestedThinking } : {}),
|
|
774
974
|
...fallbackFields(record),
|
|
775
|
-
depth: record.depth
|
|
975
|
+
depth: record.depth,
|
|
776
976
|
...(record.parentAgentId !== undefined ? { parentAgentId: record.parentAgentId } : {}),
|
|
777
977
|
...(record.workflowId !== undefined ? { workflowId: record.workflowId } : {}),
|
|
778
978
|
usage: reported,
|
|
@@ -780,7 +980,26 @@ function activateSubagents(pi: ExtensionAPI, options: SubagentsExtensionOptions)
|
|
|
780
980
|
}, (record, message) => {
|
|
781
981
|
if (!isTopLevelAgent(record)) return;
|
|
782
982
|
pi.events.emit("subagents:steered", { id: record.id, message });
|
|
783
|
-
}, clock, options.homeDir)
|
|
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));
|
|
784
1003
|
|
|
785
1004
|
// Expose manager via Symbol.for() global registry for cross-package access.
|
|
786
1005
|
// Standard Node.js pattern for cross-package singletons (used by OpenTelemetry, etc.).
|
|
@@ -851,6 +1070,8 @@ function activateSubagents(pi: ExtensionAPI, options: SubagentsExtensionOptions)
|
|
|
851
1070
|
// Bypasses handle allocation, so a forged value would duplicate a live
|
|
852
1071
|
// agent's name and make `@handle` ambiguous. Same rule: dispatcher only.
|
|
853
1072
|
delete safeOptions.reclaim;
|
|
1073
|
+
// Would make the new agent claim an evicted agent's id on lifecycle events.
|
|
1074
|
+
delete safeOptions.previousId;
|
|
854
1075
|
// Every spawn through here is DETACHED — the caller gets an id back and
|
|
855
1076
|
// awaits nothing. A forged `blocking` would charge it to the foreground
|
|
856
1077
|
// pool and could defer it behind a queue whose gate nobody is holding.
|
|
@@ -876,7 +1097,12 @@ function activateSubagents(pi: ExtensionAPI, options: SubagentsExtensionOptions)
|
|
|
876
1097
|
const byId = manager.getRecord(ref);
|
|
877
1098
|
if (byId) return byId;
|
|
878
1099
|
const resolved = manager.resolveMention(ref);
|
|
879
|
-
|
|
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);
|
|
880
1106
|
};
|
|
881
1107
|
|
|
882
1108
|
const registryEntry = {
|
|
@@ -949,7 +1175,7 @@ function activateSubagents(pi: ExtensionAPI, options: SubagentsExtensionOptions)
|
|
|
949
1175
|
reloadCustomAgents(strictLoad);
|
|
950
1176
|
} catch (err) {
|
|
951
1177
|
if (strictLoad && StartupError) {
|
|
952
|
-
throw new StartupError(
|
|
1178
|
+
throw new StartupError(errorMessage(err), { cause: err });
|
|
953
1179
|
}
|
|
954
1180
|
throw err;
|
|
955
1181
|
}
|
|
@@ -970,6 +1196,8 @@ function activateSubagents(pi: ExtensionAPI, options: SubagentsExtensionOptions)
|
|
|
970
1196
|
unwatchDetached = watchDetached(sessionFile, () => deliverDetached(sessionFile));
|
|
971
1197
|
deliverDetached(sessionFile);
|
|
972
1198
|
}
|
|
1199
|
+
announceSurvivors();
|
|
1200
|
+
unwatchAnnounce ??= pi.events.on("subagents:announce_survivors", announceSurvivors);
|
|
973
1201
|
// Orphans from the previous instance are already registered; show them now.
|
|
974
1202
|
fleet.update();
|
|
975
1203
|
// Guard mirrors the `!scheduler.isActive()` pattern below: session_start
|
|
@@ -1035,7 +1263,7 @@ function activateSubagents(pi: ExtensionAPI, options: SubagentsExtensionOptions)
|
|
|
1035
1263
|
|
|
1036
1264
|
/** Agent types `@` can start, in the shape the roster wants. */
|
|
1037
1265
|
const mentionTypes = (): TypeInfo[] =>
|
|
1038
|
-
getAvailableTypes().map(name => ({ name, description: getAgentConfig(name)
|
|
1266
|
+
getAvailableTypes().map(name => ({ name, description: getAgentConfig(name)!.description }));
|
|
1039
1267
|
|
|
1040
1268
|
/**
|
|
1041
1269
|
* `@handle message` typed at the prompt addresses that agent instead of the
|
|
@@ -1134,65 +1362,9 @@ function activateSubagents(pi: ExtensionAPI, options: SubagentsExtensionOptions)
|
|
|
1134
1362
|
if (resolved?.kind === "tombstone") {
|
|
1135
1363
|
const entry = resolved.entry;
|
|
1136
1364
|
const target = `@${entry.alias ?? entry.handle}`;
|
|
1137
|
-
|
|
1138
|
-
|
|
1139
|
-
|
|
1140
|
-
// the catch below. A `/new` in another pi window or a manual delete makes
|
|
1141
|
-
// the conversation unrecoverable (Claude Code's `not_reachable`), so drop
|
|
1142
|
-
// the entry — a row that can only ever fail is worse than none — and say
|
|
1143
|
-
// so rather than quietly sending this message to an unrelated agent.
|
|
1144
|
-
if (!existsSync(entry.sessionFile)) {
|
|
1145
|
-
manager.dropTombstone(entry.handle);
|
|
1146
|
-
ctx.ui.notify(`Could not resume ${target} — its session is gone.`, "warning");
|
|
1147
|
-
return { action: "handled" };
|
|
1148
|
-
}
|
|
1149
|
-
|
|
1150
|
-
// The Agent tool deliberately falls back to general-purpose for a type it
|
|
1151
|
-
// cannot resolve (#183), which covers a deleted file AND a merely
|
|
1152
|
-
// disabled one. A resume must not inherit that: reopening this
|
|
1153
|
-
// conversation under a different agent's prompt and tools is not
|
|
1154
|
-
// continuing it, and the new record would re-tombstone under the
|
|
1155
|
-
// substitute, so the handle would never find its way back.
|
|
1156
|
-
reloadCustomAgents();
|
|
1157
|
-
const dispatch = resolveSpawnType(entry.type);
|
|
1158
|
-
if (!dispatch.ok || dispatch.fellBackFrom !== undefined) {
|
|
1159
|
-
// The tombstone stays: re-enabling the agent makes the handle work
|
|
1160
|
-
// again, which a drop would foreclose.
|
|
1161
|
-
ctx.ui.notify(`Could not resume ${target} — the ${entry.type} agent is no longer available.`, "warning");
|
|
1162
|
-
return { action: "handled" };
|
|
1163
|
-
}
|
|
1164
|
-
|
|
1165
|
-
try {
|
|
1166
|
-
// spawnResolved, not spawnTopLevel: the latter strips
|
|
1167
|
-
// `resumeSessionFile` and `reclaim` as untrusted. This path is the
|
|
1168
|
-
// exception — both come from a tombstone this extension wrote.
|
|
1169
|
-
const id = spawnResolved(pi, ctx, dispatch.type, mention.message, {
|
|
1170
|
-
description: entry.description,
|
|
1171
|
-
reclaim: { handle: entry.handle, alias: entry.alias },
|
|
1172
|
-
resumeSessionFile: entry.sessionFile,
|
|
1173
|
-
// Historical disclosures never replace the continuation's current selection.
|
|
1174
|
-
...(entry.modelFallback ? { previousModelFallback: entry.modelFallback } : {}),
|
|
1175
|
-
...(entry.modelTier ? { previousModelTier: entry.modelTier } : {}),
|
|
1176
|
-
isBackground: true,
|
|
1177
|
-
});
|
|
1178
|
-
// The agent may still be starting — wait, so a startup failure lands in
|
|
1179
|
-
// the catch below instead of being announced as a resume.
|
|
1180
|
-
await manager.awaitStartup(id);
|
|
1181
|
-
// The tombstone deliberately stays. `resolveMention` prefers the live
|
|
1182
|
-
// record holding these same names, so it cannot shadow the resume — and
|
|
1183
|
-
// if this run dies before establishing its own session, the original
|
|
1184
|
-
// transcript is still the right thing for the next mention to reopen.
|
|
1185
|
-
// Once the resumed record is evicted it overwrites this entry in place,
|
|
1186
|
-
// keyed by the same handle, so nothing accumulates.
|
|
1187
|
-
ctx.ui.notify(`Resuming ${target}`, "info");
|
|
1188
|
-
} catch (err) {
|
|
1189
|
-
// The type is already settled above, so what is left is a spawn-time
|
|
1190
|
-
// failure: a strict worktree-isolation error, an unusable cwd.
|
|
1191
|
-
ctx.ui.notify(
|
|
1192
|
-
`Could not resume ${target}: ${err instanceof Error ? err.message : String(err)}`,
|
|
1193
|
-
"warning",
|
|
1194
|
-
);
|
|
1195
|
-
}
|
|
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");
|
|
1196
1368
|
return { action: "handled" };
|
|
1197
1369
|
}
|
|
1198
1370
|
|
|
@@ -1243,7 +1415,7 @@ function activateSubagents(pi: ExtensionAPI, options: SubagentsExtensionOptions)
|
|
|
1243
1415
|
ctx.ui.notify(`Started ${label} directly — ${result.error}`, "warning");
|
|
1244
1416
|
} catch (err) {
|
|
1245
1417
|
ctx.ui.notify(
|
|
1246
|
-
`Could not start ${label}: ${
|
|
1418
|
+
`Could not start ${label}: ${errorMessage(err)}`,
|
|
1247
1419
|
"error",
|
|
1248
1420
|
);
|
|
1249
1421
|
}
|
|
@@ -1267,7 +1439,7 @@ function activateSubagents(pi: ExtensionAPI, options: SubagentsExtensionOptions)
|
|
|
1267
1439
|
await manager.awaitStartup(id);
|
|
1268
1440
|
ctx.ui.notify(`Started @${handleBase(type)}`, "info");
|
|
1269
1441
|
} catch (err) {
|
|
1270
|
-
ctx.ui.notify(`Could not start @${handleBase(type)}: ${
|
|
1442
|
+
ctx.ui.notify(`Could not start @${handleBase(type)}: ${errorMessage(err)}`, "error");
|
|
1271
1443
|
}
|
|
1272
1444
|
return { action: "handled" };
|
|
1273
1445
|
});
|
|
@@ -1288,6 +1460,8 @@ function activateSubagents(pi: ExtensionAPI, options: SubagentsExtensionOptions)
|
|
|
1288
1460
|
const sessionFile = currentCtx?.sessionManager?.getSessionFile?.();
|
|
1289
1461
|
unwatchDetached?.();
|
|
1290
1462
|
unwatchDetached = undefined;
|
|
1463
|
+
unwatchAnnounce?.();
|
|
1464
|
+
unwatchAnnounce = undefined;
|
|
1291
1465
|
rpcHandle?.unsubSpawn();
|
|
1292
1466
|
rpcHandle?.unsubStop();
|
|
1293
1467
|
rpcHandle?.unsubPing();
|
|
@@ -1318,7 +1492,12 @@ function activateSubagents(pi: ExtensionAPI, options: SubagentsExtensionOptions)
|
|
|
1318
1492
|
}
|
|
1319
1493
|
const pending = outbox.takePending();
|
|
1320
1494
|
if (pending) {
|
|
1321
|
-
|
|
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 }); }
|
|
1322
1501
|
catch { console.error("[pi-subagents] Could not spool held completion notices; results remain in their transcripts."); }
|
|
1323
1502
|
}
|
|
1324
1503
|
}
|
|
@@ -1350,7 +1529,7 @@ function activateSubagents(pi: ExtensionAPI, options: SubagentsExtensionOptions)
|
|
|
1350
1529
|
registerRunningDetached(sessionFile, {
|
|
1351
1530
|
record,
|
|
1352
1531
|
activity: agentActivity.get(record.id),
|
|
1353
|
-
abort: () => manager.abort(record.id),
|
|
1532
|
+
abort: (by) => manager.abort(record.id, by),
|
|
1354
1533
|
steer: (message) => manager.steer(record.id, message),
|
|
1355
1534
|
});
|
|
1356
1535
|
}
|
|
@@ -1400,17 +1579,17 @@ function activateSubagents(pi: ExtensionAPI, options: SubagentsExtensionOptions)
|
|
|
1400
1579
|
wake();
|
|
1401
1580
|
sent++;
|
|
1402
1581
|
// It is a background agent from here on, so announce it as one — as a
|
|
1403
|
-
// detached resume re-announces an existing id.
|
|
1404
|
-
|
|
1405
|
-
|
|
1406
|
-
|
|
1407
|
-
|
|
1408
|
-
|
|
1409
|
-
|
|
1410
|
-
|
|
1411
|
-
|
|
1412
|
-
|
|
1413
|
-
}
|
|
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
|
+
});
|
|
1414
1593
|
}
|
|
1415
1594
|
if (sent > 0) {
|
|
1416
1595
|
widget.ensureTimer();
|
|
@@ -1474,6 +1653,7 @@ function activateSubagents(pi: ExtensionAPI, options: SubagentsExtensionOptions)
|
|
|
1474
1653
|
// Agents the previous instance of this session left running (/reload, /new, ...).
|
|
1475
1654
|
() => listRunningDetached(currentCtx?.sessionManager?.getSessionFile?.()), clock);
|
|
1476
1655
|
fleet.setParkedSource(() => scheduler.list().filter(job => job.enabled && job.quotaParked && job.lastStatus !== "running").length);
|
|
1656
|
+
fleet.setStopAll(stopAllRunning);
|
|
1477
1657
|
/** Replay UI-only state from the active branch, like workflow/goal custom snapshots. */
|
|
1478
1658
|
function restoreFleetDisclosure(ctx: ExtensionContext): void {
|
|
1479
1659
|
fleet.restoreSession(() => ctx.sessionManager?.getBranch?.() ?? [], state => {
|
|
@@ -1624,6 +1804,100 @@ function activateSubagents(pi: ExtensionAPI, options: SubagentsExtensionOptions)
|
|
|
1624
1804
|
return getAgentConfig(record.type)?.maxTurns ?? explicit ?? record.invocation?.maxTurns ?? getDefaultMaxTurns();
|
|
1625
1805
|
}
|
|
1626
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
|
+
|
|
1627
1901
|
/**
|
|
1628
1902
|
* Launch a detached resume of an existing agent and wire everything a
|
|
1629
1903
|
* re-running agent needs: transcript anchoring, activity tracking, join-mode
|
|
@@ -1643,13 +1917,16 @@ function activateSubagents(pi: ExtensionAPI, options: SubagentsExtensionOptions)
|
|
|
1643
1917
|
opts: { outputTranscript: boolean; maxTurns?: number; toolCallId?: string },
|
|
1644
1918
|
): Promise<AgentRecord | undefined> {
|
|
1645
1919
|
const id = existing.id;
|
|
1646
|
-
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;
|
|
1647
1924
|
// Assigned unconditionally: the completion notification carries this as
|
|
1648
1925
|
// `<tool-use-id>`, so a mention-resume (which passes none) has to CLEAR the
|
|
1649
1926
|
// id left by the spawn that created the record. Keeping it would point the
|
|
1650
1927
|
// orchestrator's new result at a tool call that was answered runs ago.
|
|
1651
1928
|
existing.toolCallId = opts.toolCallId;
|
|
1652
|
-
|
|
1929
|
+
existing.joinMode = joinMode;
|
|
1653
1930
|
// Reuse the agent's transcript rather than starting a fresh one: the
|
|
1654
1931
|
// path is deterministic per agent+session, so writing an initial entry
|
|
1655
1932
|
// would truncate the previous run's turns (see ensureOutputFile).
|
|
@@ -1663,7 +1940,8 @@ function activateSubagents(pi: ExtensionAPI, options: SubagentsExtensionOptions)
|
|
|
1663
1940
|
// Anchor streaming past the turns already on disk, captured BEFORE the
|
|
1664
1941
|
// run starts. The resumed prompt lands as an ordinary user message at
|
|
1665
1942
|
// this index, so it is written exactly once.
|
|
1666
|
-
|
|
1943
|
+
// Both callers resume only a record that still holds its session.
|
|
1944
|
+
const transcriptAnchor = existing.session!.messages.length;
|
|
1667
1945
|
|
|
1668
1946
|
const { state: bgState, callbacks: bgCallbacks } = createActivityTracker(normalizeMaxTurns(opts.maxTurns), undefined, clock);
|
|
1669
1947
|
// resumeAgent has no onSessionCreated — the session predates this run —
|
|
@@ -1717,7 +1995,8 @@ function activateSubagents(pi: ExtensionAPI, options: SubagentsExtensionOptions)
|
|
|
1717
1995
|
type: existing.type,
|
|
1718
1996
|
description: existing.description,
|
|
1719
1997
|
isBackground: true,
|
|
1720
|
-
...
|
|
1998
|
+
...(record.status === "queued" && priorSettleSeq !== undefined ? { queuedAfterSettleSeq: priorSettleSeq, queuedResumeSeq: resumeSeq } : {}),
|
|
1999
|
+
...eventFields(existing),
|
|
1721
2000
|
});
|
|
1722
2001
|
|
|
1723
2002
|
return record;
|
|
@@ -1738,17 +2017,28 @@ function activateSubagents(pi: ExtensionAPI, options: SubagentsExtensionOptions)
|
|
|
1738
2017
|
outbox.onTurnEnd(event.message.role === "assistant" ? event.message.stopReason : undefined);
|
|
1739
2018
|
});
|
|
1740
2019
|
pi.on("agent_end", () => outbox.onAgentEnd());
|
|
1741
|
-
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
|
+
});
|
|
1742
2031
|
|
|
1743
2032
|
/** Build the full type list text dynamically from available agents only. */
|
|
1744
2033
|
const buildTypeListText = () => {
|
|
1745
2034
|
const available = getAvailableTypes();
|
|
1746
2035
|
|
|
1747
2036
|
return available.map((name) => {
|
|
1748
|
-
|
|
1749
|
-
const
|
|
2037
|
+
// Every available type is registered, so its config exists.
|
|
2038
|
+
const cfg = getAgentConfig(name)!;
|
|
2039
|
+
const modelSuffix = cfg.model ? ` (${getModelLabelFromConfig(cfg.model)})` : "";
|
|
1750
2040
|
const toolsSuffix = ` (Tools: ${formatToolsSuffix(cfg)})`;
|
|
1751
|
-
return `- ${name}: ${cfg
|
|
2041
|
+
return `- ${name}: ${cfg.description}${modelSuffix}${toolsSuffix}`;
|
|
1752
2042
|
}).join("\n");
|
|
1753
2043
|
};
|
|
1754
2044
|
|
|
@@ -1761,8 +2051,8 @@ function activateSubagents(pi: ExtensionAPI, options: SubagentsExtensionOptions)
|
|
|
1761
2051
|
/** Compact type list: one line per agent, first sentence only. */
|
|
1762
2052
|
const buildCompactTypeListText = () =>
|
|
1763
2053
|
getAvailableTypes().map((name) => {
|
|
1764
|
-
const cfg = getAgentConfig(name)
|
|
1765
|
-
return `- ${name}: ${firstSentence(cfg
|
|
2054
|
+
const cfg = getAgentConfig(name)!;
|
|
2055
|
+
return `- ${name}: ${firstSentence(cfg.description)} (Tools: ${formatToolsSuffix(cfg)})`;
|
|
1766
2056
|
}).join("\n");
|
|
1767
2057
|
|
|
1768
2058
|
/** Derive a short model label from a model string. */
|
|
@@ -1790,6 +2080,7 @@ function activateSubagents(pi: ExtensionAPI, options: SubagentsExtensionOptions)
|
|
|
1790
2080
|
setBudgetPolicy,
|
|
1791
2081
|
setDefaultMaxTurns,
|
|
1792
2082
|
setGraceTurns,
|
|
2083
|
+
setNotificationResultChars: (n) => { notificationResultChars = n; },
|
|
1793
2084
|
setDefaultJoinMode,
|
|
1794
2085
|
setBackgroundByDefault,
|
|
1795
2086
|
setSchedulingEnabled,
|
|
@@ -1852,13 +2143,35 @@ function activateSubagents(pi: ExtensionAPI, options: SubagentsExtensionOptions)
|
|
|
1852
2143
|
// With no per-result note by design, the model would have every reason to go
|
|
1853
2144
|
// on reporting a `pi-agent-*` branch that was never created.
|
|
1854
2145
|
const isolationGuideline = isWorktreeIsolationEnabled()
|
|
1855
|
-
? `\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\`.`
|
|
1856
2147
|
: "";
|
|
1857
2148
|
|
|
1858
2149
|
const isolationCompactGuideline = isWorktreeIsolationEnabled()
|
|
1859
|
-
? `\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.`
|
|
1860
2151
|
: "";
|
|
1861
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
|
+
|
|
1862
2175
|
// Compact Agent tool description (#91, `toolDescriptionMode: "compact"`) —
|
|
1863
2176
|
// the same load-bearing facts as the full version at ~75% fewer tokens, for
|
|
1864
2177
|
// small/local models. Per-option details live in the param descriptions.
|
|
@@ -1872,7 +2185,7 @@ Notes:
|
|
|
1872
2185
|
- Parallel work: one message, multiple Agent calls — they run concurrently.
|
|
1873
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.
|
|
1874
2187
|
- The result is not shown to the user — summarize it for them. Verify an agent's claimed code changes before reporting work done.
|
|
1875
|
-
- 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}`;
|
|
1876
2189
|
|
|
1877
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.
|
|
1878
2191
|
|
|
@@ -1897,10 +2210,11 @@ If the target is already known, use a direct tool — \`read\` for a known path,
|
|
|
1897
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.
|
|
1898
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.
|
|
1899
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.
|
|
1900
|
-
- 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.
|
|
1901
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.
|
|
1902
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.
|
|
1903
|
-
-
|
|
2217
|
+
- ${modelGuideline()}
|
|
1904
2218
|
- Use thinking to control extended thinking level.
|
|
1905
2219
|
- Use inherit_context if the agent needs the parent conversation history.${isolationGuideline}${scheduleGuideline}
|
|
1906
2220
|
|
|
@@ -1949,7 +2263,7 @@ Terse command-style prompts produce shallow, generic work.
|
|
|
1949
2263
|
if (text) return renderToolDescriptionTemplate(text);
|
|
1950
2264
|
console.warn(`[pi-subagents] ${path} is empty — ignoring`);
|
|
1951
2265
|
} catch (err) {
|
|
1952
|
-
console.warn(`[pi-subagents] failed to read ${path}: ${
|
|
2266
|
+
console.warn(`[pi-subagents] failed to read ${path}: ${errorMessage(err)}`);
|
|
1953
2267
|
}
|
|
1954
2268
|
}
|
|
1955
2269
|
return undefined;
|
|
@@ -2001,8 +2315,7 @@ Terse command-style prompts produce shallow, generic work.
|
|
|
2001
2315
|
}),
|
|
2002
2316
|
model: Type.Optional(
|
|
2003
2317
|
Type.String({
|
|
2004
|
-
description:
|
|
2005
|
-
'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.',
|
|
2318
|
+
description: modelParamDescription(),
|
|
2006
2319
|
}),
|
|
2007
2320
|
),
|
|
2008
2321
|
thinking: Type.Optional(
|
|
@@ -2023,7 +2336,7 @@ Terse command-style prompts produce shallow, generic work.
|
|
|
2023
2336
|
),
|
|
2024
2337
|
resume: Type.Optional(
|
|
2025
2338
|
Type.String({
|
|
2026
|
-
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).",
|
|
2027
2340
|
}),
|
|
2028
2341
|
),
|
|
2029
2342
|
isolated: Type.Optional(
|
|
@@ -2200,11 +2513,12 @@ Terse command-style prompts produce shallow, generic work.
|
|
|
2200
2513
|
rec ? rec.modelFallback : modelFallback;
|
|
2201
2514
|
/** A resume's lead-in: the record's note only (the type note describes a spawn, not a resume). */
|
|
2202
2515
|
const resumeNoteFor = (rec: AgentRecord): string => {
|
|
2203
|
-
const note = modelNoteOf(rec);
|
|
2516
|
+
const note = [modelNoteOf(rec), modelIdentityNote(rec)].filter(Boolean).join("\n");
|
|
2204
2517
|
return note ? `${note}\n\n` : "";
|
|
2205
2518
|
};
|
|
2206
2519
|
const fallbackNoteFor = (rec: AgentRecord | undefined): string => {
|
|
2207
|
-
|
|
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");
|
|
2208
2522
|
return typeNote + (modelNote ? `${modelNote}\n\n` : "");
|
|
2209
2523
|
};
|
|
2210
2524
|
const detailBase = {
|
|
@@ -2272,6 +2586,7 @@ Terse command-style prompts produce shallow, generic work.
|
|
|
2272
2586
|
if (!scheduler.isActive()) {
|
|
2273
2587
|
return textResult("Scheduler is not active in this session yet. Try again after the session has fully started.");
|
|
2274
2588
|
}
|
|
2589
|
+
if (planningNow(pi.events)) return textResult(PLAN_SCHEDULE_REFUSAL);
|
|
2275
2590
|
try {
|
|
2276
2591
|
const job = scheduler.addJob({
|
|
2277
2592
|
name: params.description as string,
|
|
@@ -2294,18 +2609,57 @@ Terse command-style prompts produce shallow, generic work.
|
|
|
2294
2609
|
`Manage via /agents → Scheduled jobs.`,
|
|
2295
2610
|
);
|
|
2296
2611
|
} catch (err) {
|
|
2297
|
-
return textResult(
|
|
2612
|
+
return textResult(errorMessage(err));
|
|
2298
2613
|
}
|
|
2299
2614
|
}
|
|
2300
2615
|
|
|
2301
2616
|
// Resume existing agent
|
|
2302
2617
|
if (params.resume) {
|
|
2303
|
-
|
|
2304
|
-
|
|
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)) {
|
|
2305
2659
|
return textResult(`Agent not found: "${params.resume}". It may have been cleaned up.`);
|
|
2306
2660
|
}
|
|
2307
2661
|
if (!existing.session) {
|
|
2308
|
-
return textResult(
|
|
2662
|
+
return textResult(`${continued}Agent "${existing.id}" has no active session to resume.`);
|
|
2309
2663
|
}
|
|
2310
2664
|
// Not `effectiveMaxTurns`: that was resolved for `subagent_type`, which
|
|
2311
2665
|
// a resume ignores, so it may describe another agent.
|
|
@@ -2323,7 +2677,7 @@ Terse command-style prompts produce shallow, generic work.
|
|
|
2323
2677
|
// instead of letting it read as a generic failure.
|
|
2324
2678
|
if (existing.status === "running" || existing.status === "queued") {
|
|
2325
2679
|
return textResult(
|
|
2326
|
-
|
|
2680
|
+
`${continued}Agent "${existing.id}" is still ${existing.status} — it can only be resumed once its current run finishes.\n` +
|
|
2327
2681
|
`Use steer_subagent to send it a message mid-run, or get_subagent_result to wait for it.`,
|
|
2328
2682
|
);
|
|
2329
2683
|
}
|
|
@@ -2342,7 +2696,7 @@ Terse command-style prompts produce shallow, generic work.
|
|
|
2342
2696
|
|
|
2343
2697
|
const isQueued = record.status === "queued";
|
|
2344
2698
|
return textResult(
|
|
2345
|
-
`${resumeNoteFor(record)}Agent ${isQueued ? "queued" : "resumed"} in background.\n` +
|
|
2699
|
+
`${continued}${resumeNoteFor(record)}Agent ${isQueued ? "queued" : "resumed"} in background.\n` +
|
|
2346
2700
|
`Agent ID: ${id}\n` +
|
|
2347
2701
|
`Type: ${existing.type}\n` +
|
|
2348
2702
|
(record.outputFile ? `Output file: ${record.outputFile}\n` : "") +
|
|
@@ -2365,7 +2719,7 @@ Terse command-style prompts produce shallow, generic work.
|
|
|
2365
2719
|
fleet.update();
|
|
2366
2720
|
let record: AgentRecord | undefined;
|
|
2367
2721
|
try {
|
|
2368
|
-
record = await manager.resume(
|
|
2722
|
+
record = await manager.resume(existing.id, params.prompt, signal, {
|
|
2369
2723
|
maxTurns: resumeMaxTurns,
|
|
2370
2724
|
onTurnEnd: resumeCallbacks.onTurnEnd,
|
|
2371
2725
|
onTextDelta: resumeCallbacks.onTextDelta,
|
|
@@ -2387,7 +2741,7 @@ Terse command-style prompts produce shallow, generic work.
|
|
|
2387
2741
|
return textResult(`${resumeNoteFor(record)}Agent failed: ${record.error}${partialOutputSuffix(record)}`, buildDetails(detailBaseFor(record), record, resumeState));
|
|
2388
2742
|
}
|
|
2389
2743
|
return textResult(
|
|
2390
|
-
`${resumeNoteFor(record)}${record.result?.trim() || "No output."}`,
|
|
2744
|
+
`${continued}${resumeNoteFor(record)}${record.result?.trim() || "No output."}`,
|
|
2391
2745
|
buildDetails(detailBaseFor(record), record, resumeState),
|
|
2392
2746
|
);
|
|
2393
2747
|
}
|
|
@@ -2426,6 +2780,7 @@ Terse command-style prompts produce shallow, generic work.
|
|
|
2426
2780
|
if (!canPark || !mayPark(quotaBlock)) {
|
|
2427
2781
|
throw new Error(`${selection?.tier ? `${modelNoteOf(undefined)}\n` : ""}${describeBlock(quotaBlock)} The agent was not started.`);
|
|
2428
2782
|
}
|
|
2783
|
+
if (planningNow(pi.events)) throw new Error(`${describeBlock(quotaBlock)} ${PLAN_PARK_REFUSAL}`);
|
|
2429
2784
|
const job = scheduler.addJob({
|
|
2430
2785
|
name: `${params.description} (quota ${nanoid(4)})`,
|
|
2431
2786
|
description: params.description as string,
|
|
@@ -2443,7 +2798,7 @@ Terse command-style prompts produce shallow, generic work.
|
|
|
2443
2798
|
});
|
|
2444
2799
|
return textResult(
|
|
2445
2800
|
`${fallbackNoteFor(undefined)}${describeBlock(quotaBlock)} Queued "${job.name}" (id: ${job.id}) to start at ` +
|
|
2446
|
-
`${
|
|
2801
|
+
`${job.schedule}. Manage via /agents → Scheduled jobs.`,
|
|
2447
2802
|
);
|
|
2448
2803
|
}
|
|
2449
2804
|
|
|
@@ -2452,8 +2807,7 @@ Terse command-style prompts produce shallow, generic work.
|
|
|
2452
2807
|
const { state: bgState, callbacks: bgCallbacks } = createActivityTracker(effectiveMaxTurns, undefined, clock);
|
|
2453
2808
|
|
|
2454
2809
|
// Wrap onSessionCreated to wire output file streaming.
|
|
2455
|
-
// The callback
|
|
2456
|
-
// rather than closing over a value that doesn't exist yet.
|
|
2810
|
+
// The callback reads record.outputFile prepared before launch.
|
|
2457
2811
|
let id: string;
|
|
2458
2812
|
const origBgOnSession = bgCallbacks.onSessionCreated;
|
|
2459
2813
|
bgCallbacks.onSessionCreated = (session: any) => {
|
|
@@ -2481,18 +2835,17 @@ Terse command-style prompts produce shallow, generic work.
|
|
|
2481
2835
|
cwd,
|
|
2482
2836
|
invocation: agentInvocation,
|
|
2483
2837
|
rootSessionId: ctx.sessionManager.getSessionId(),
|
|
2838
|
+
onPrepare: (agentId) => attachTranscript(manager.getRecord(agentId), agentId),
|
|
2484
2839
|
...bgCallbacks,
|
|
2485
2840
|
});
|
|
2486
2841
|
|
|
2487
|
-
//
|
|
2488
|
-
//
|
|
2489
|
-
const joinMode =
|
|
2490
|
-
|
|
2491
|
-
|
|
2492
|
-
|
|
2493
|
-
|
|
2494
|
-
attachTranscript(record, id);
|
|
2495
|
-
}
|
|
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;
|
|
2496
2849
|
|
|
2497
2850
|
// With isolation: "worktree" the agent isn't running yet — the repo
|
|
2498
2851
|
// copy is an awaited git call. Wait for it here, after the synchronous
|
|
@@ -2514,7 +2867,7 @@ Terse command-style prompts produce shallow, generic work.
|
|
|
2514
2867
|
type: subagentType,
|
|
2515
2868
|
description: params.description,
|
|
2516
2869
|
isBackground: true,
|
|
2517
|
-
...
|
|
2870
|
+
...eventFields(manager.getRecord(id)),
|
|
2518
2871
|
});
|
|
2519
2872
|
|
|
2520
2873
|
return backgroundResult(id, record);
|
|
@@ -2573,8 +2926,7 @@ Terse command-style prompts produce shallow, generic work.
|
|
|
2573
2926
|
const { state: fgState, callbacks: fgCallbacks } = createActivityTracker(effectiveMaxTurns, streamUpdate, clock);
|
|
2574
2927
|
|
|
2575
2928
|
// Wire session creation: register in widget + stream to output file.
|
|
2576
|
-
// The output file path is
|
|
2577
|
-
// before onSessionCreated fires — same pattern as background agents.
|
|
2929
|
+
// The output file path is prepared before launch, as for background agents.
|
|
2578
2930
|
const origOnSession = fgCallbacks.onSessionCreated;
|
|
2579
2931
|
fgCallbacks.onSessionCreated = (session: any) => {
|
|
2580
2932
|
origOnSession(session);
|
|
@@ -2592,16 +2944,11 @@ Terse command-style prompts produce shallow, generic work.
|
|
|
2592
2944
|
widget.ensureTimer();
|
|
2593
2945
|
fleet.ensureTimer();
|
|
2594
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);
|
|
2595
2949
|
break;
|
|
2596
2950
|
}
|
|
2597
2951
|
}
|
|
2598
|
-
// Stream conversation to output file (foreground agent logging)
|
|
2599
|
-
if (fgId) {
|
|
2600
|
-
const rec = manager.getRecord(fgId);
|
|
2601
|
-
if (rec?.outputFile) {
|
|
2602
|
-
rec.outputCleanup = streamToOutputFile(session, rec.outputFile, fgId, ctx.cwd);
|
|
2603
|
-
}
|
|
2604
|
-
}
|
|
2605
2952
|
};
|
|
2606
2953
|
|
|
2607
2954
|
// Animate spinner at ~80ms (smooth rotation through 10 braille frames)
|
|
@@ -2640,12 +2987,12 @@ Terse command-style prompts produce shallow, generic work.
|
|
|
2640
2987
|
// widget and the `finally` cleanup below, none of which should see an
|
|
2641
2988
|
// agent that has no session and may never get one.
|
|
2642
2989
|
onQueued: (_id, ahead) => { queuedAhead = ahead; streamUpdate(); },
|
|
2990
|
+
onPrepare: (agentId) => attachTranscript(manager.getRecord(agentId), agentId),
|
|
2643
2991
|
...fgCallbacks,
|
|
2644
2992
|
}, (fgAgentId) => {
|
|
2645
|
-
// onSpawned
|
|
2646
|
-
//
|
|
2647
|
-
const fgRec = manager.getRecord(fgAgentId)
|
|
2648
|
-
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)!;
|
|
2649
2996
|
// Fires once the run promise exists, i.e. the agent is running. From
|
|
2650
2997
|
// here until this call returns, the run can be handed off.
|
|
2651
2998
|
handoffId = fgAgentId;
|
|
@@ -2653,10 +3000,8 @@ Terse command-style prompts produce shallow, generic work.
|
|
|
2653
3000
|
// What a background spawn sets up front: the notification names
|
|
2654
3001
|
// this tool call, and it reports alone — its spawn batch is long
|
|
2655
3002
|
// closed.
|
|
2656
|
-
|
|
2657
|
-
|
|
2658
|
-
fgRec.joinMode = "async";
|
|
2659
|
-
}
|
|
3003
|
+
fgRec.toolCallId = toolCallId;
|
|
3004
|
+
fgRec.joinMode = "async";
|
|
2660
3005
|
backgrounded = true;
|
|
2661
3006
|
wakeBackgrounded();
|
|
2662
3007
|
});
|
|
@@ -2690,7 +3035,7 @@ Terse command-style prompts produce shallow, generic work.
|
|
|
2690
3035
|
return textResult(`${fallbackNoteFor(record)}Agent failed: ${record.error}${partialOutputSuffix(record)}`, details);
|
|
2691
3036
|
}
|
|
2692
3037
|
|
|
2693
|
-
const durationMs = (record
|
|
3038
|
+
const durationMs = recordElapsedMs(record, Date.now());
|
|
2694
3039
|
const statsParts = [`${record.toolUses} tool uses`];
|
|
2695
3040
|
if (tokenText) statsParts.push(tokenText);
|
|
2696
3041
|
if (showCost) {
|
|
@@ -2698,7 +3043,7 @@ Terse command-style prompts produce shallow, generic work.
|
|
|
2698
3043
|
if (costText) statsParts.push(costText);
|
|
2699
3044
|
}
|
|
2700
3045
|
return textResult(
|
|
2701
|
-
`${fallbackNoteFor(record)}Agent completed in ${formatMs(durationMs)} (${statsParts.join(", ")})${getForegroundOutcomeNote(record.status)}.\n\n` +
|
|
3046
|
+
`${fallbackNoteFor(record)}Agent completed in ${formatMs(durationMs)} (${statsParts.join(", ")})${getForegroundOutcomeNote(record.status, record.stoppedBy)}.\n\n` +
|
|
2702
3047
|
(record.result?.trim() || "No output."),
|
|
2703
3048
|
details,
|
|
2704
3049
|
);
|
|
@@ -2781,7 +3126,8 @@ Terse command-style prompts produce shallow, generic work.
|
|
|
2781
3126
|
// run's progress log here would put O(log) work in the render loop.
|
|
2782
3127
|
return [...workflowTasks.values()].map(task => ({
|
|
2783
3128
|
id: task.id,
|
|
2784
|
-
|
|
3129
|
+
// Every task here is created from a parsed `meta`, which also names the run.
|
|
3130
|
+
name: task.meta!.name,
|
|
2785
3131
|
status: task.status,
|
|
2786
3132
|
doneCount: task.doneCount,
|
|
2787
3133
|
totalCount: task.agentCount,
|
|
@@ -2826,7 +3172,7 @@ Terse command-style prompts produce shallow, generic work.
|
|
|
2826
3172
|
});
|
|
2827
3173
|
completeWorkflowTask(task, result);
|
|
2828
3174
|
} catch (err) {
|
|
2829
|
-
failWorkflowTask(task,
|
|
3175
|
+
failWorkflowTask(task, errorMessage(err));
|
|
2830
3176
|
}
|
|
2831
3177
|
}
|
|
2832
3178
|
|
|
@@ -2843,7 +3189,7 @@ Terse command-style prompts produce shallow, generic work.
|
|
|
2843
3189
|
content: formatWorkflowNotification(task),
|
|
2844
3190
|
details: {
|
|
2845
3191
|
id: task.id,
|
|
2846
|
-
description: `Workflow ${task.
|
|
3192
|
+
description: `Workflow ${task.meta!.name}`,
|
|
2847
3193
|
status: task.status === "completed" ? "completed" : task.status === "killed" ? "stopped" : "error",
|
|
2848
3194
|
toolUses: task.totalToolCalls,
|
|
2849
3195
|
// A workflow has agents, not turns; rendering "↻0" would be noise.
|
|
@@ -2974,7 +3320,7 @@ Terse command-style prompts produce shallow, generic work.
|
|
|
2974
3320
|
try {
|
|
2975
3321
|
meta = extractMeta(resolved.script).meta;
|
|
2976
3322
|
} catch (err) {
|
|
2977
|
-
return textResult(
|
|
3323
|
+
return textResult(errorMessage(err));
|
|
2978
3324
|
}
|
|
2979
3325
|
|
|
2980
3326
|
const runId = workflowRunId();
|
|
@@ -2992,7 +3338,7 @@ Terse command-style prompts produce shallow, generic work.
|
|
|
2992
3338
|
} catch (err) {
|
|
2993
3339
|
savedPath = undefined;
|
|
2994
3340
|
journalPath = undefined;
|
|
2995
|
-
console.warn(`[pi-subagents] could not persist workflow script: ${
|
|
3341
|
+
console.warn(`[pi-subagents] could not persist workflow script: ${errorMessage(err)}`);
|
|
2996
3342
|
}
|
|
2997
3343
|
|
|
2998
3344
|
const replay = resumeFrom !== undefined ? readJournal(resumeFrom.journalPath) : undefined;
|
|
@@ -3027,7 +3373,7 @@ Terse command-style prompts produce shallow, generic work.
|
|
|
3027
3373
|
`Task ID: ${runId}\n` +
|
|
3028
3374
|
(task.scriptPath ? `Script: ${task.scriptPath}\n` : "") +
|
|
3029
3375
|
(task.resumedFrom !== undefined
|
|
3030
|
-
? `Resuming ${task.resumedFrom}: ${task.replay
|
|
3376
|
+
? `Resuming ${task.resumedFrom}: ${task.replay!.length} recorded call(s) available to replay.\n`
|
|
3031
3377
|
: params.resumeFromRunId !== undefined
|
|
3032
3378
|
? `Nothing to replay from ${params.resumeFromRunId} — every agent runs live.\n`
|
|
3033
3379
|
: "") +
|
|
@@ -3152,7 +3498,7 @@ Terse command-style prompts produce shallow, generic work.
|
|
|
3152
3498
|
try {
|
|
3153
3499
|
script = readFileSync(path, "utf-8");
|
|
3154
3500
|
} catch (err) {
|
|
3155
|
-
report(`Could not read ${path}: ${
|
|
3501
|
+
report(`Could not read ${path}: ${errorMessage(err)}`, "warning");
|
|
3156
3502
|
return;
|
|
3157
3503
|
}
|
|
3158
3504
|
|
|
@@ -3160,7 +3506,7 @@ Terse command-style prompts produce shallow, generic work.
|
|
|
3160
3506
|
try {
|
|
3161
3507
|
meta = extractMeta(script).meta;
|
|
3162
3508
|
} catch (err) {
|
|
3163
|
-
report(
|
|
3509
|
+
report(errorMessage(err), "warning");
|
|
3164
3510
|
return;
|
|
3165
3511
|
}
|
|
3166
3512
|
|
|
@@ -3199,7 +3545,7 @@ Terse command-style prompts produce shallow, generic work.
|
|
|
3199
3545
|
name: SUBAGENT_TOOL_NAMES.GET_RESULT,
|
|
3200
3546
|
label: "Get Agent Result",
|
|
3201
3547
|
description:
|
|
3202
|
-
"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.",
|
|
3203
3549
|
promptSnippet: "Check status and retrieve results from a background agent",
|
|
3204
3550
|
parameters: Type.Object({
|
|
3205
3551
|
agent_id: Type.String({
|
|
@@ -3248,7 +3594,7 @@ Terse command-style prompts produce shallow, generic work.
|
|
|
3248
3594
|
|
|
3249
3595
|
let output =
|
|
3250
3596
|
`Agent: ${record.id}\n` +
|
|
3251
|
-
`Type: ${displayName} | Status: ${record.status}${getStatusNote(record.status)} | ${statsParts.join(" | ")}\n` +
|
|
3597
|
+
`Type: ${displayName} | Status: ${record.status}${getStatusNote(record.status, record.stoppedBy)} | ${statsParts.join(" | ")}\n` +
|
|
3252
3598
|
`Description: ${record.description}\n` +
|
|
3253
3599
|
(modelSelectionNote(record) ? `${modelSelectionNote(record)}\n` : "") +
|
|
3254
3600
|
`\n`;
|
|
@@ -3286,23 +3632,46 @@ Terse command-style prompts produce shallow, generic work.
|
|
|
3286
3632
|
name: SUBAGENT_TOOL_NAMES.STEER,
|
|
3287
3633
|
label: "Steer Agent",
|
|
3288
3634
|
description:
|
|
3289
|
-
"Send a
|
|
3290
|
-
"
|
|
3291
|
-
|
|
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",
|
|
3292
3640
|
parameters: Type.Object({
|
|
3293
3641
|
agent_id: Type.String({
|
|
3294
|
-
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`).",
|
|
3295
3643
|
}),
|
|
3296
3644
|
message: Type.String({
|
|
3297
|
-
description: "The
|
|
3645
|
+
description: "The message to send. This will appear as a user message in the agent's conversation.",
|
|
3298
3646
|
}),
|
|
3299
3647
|
}),
|
|
3300
|
-
execute: async (
|
|
3301
|
-
|
|
3302
|
-
|
|
3303
|
-
|
|
3304
|
-
|
|
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);
|
|
3305
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));
|
|
3306
3675
|
if (!record.session) {
|
|
3307
3676
|
// Queued for a concurrency slot, or running with its session still
|
|
3308
3677
|
// being built: either way the manager flushes `pendingSteers` into the
|
|
@@ -3334,24 +3703,124 @@ Terse command-style prompts produce shallow, generic work.
|
|
|
3334
3703
|
`Current state: ${stateParts.join(" · ")}`,
|
|
3335
3704
|
);
|
|
3336
3705
|
} catch (err) {
|
|
3337
|
-
return textResult(`Failed to steer agent: ${
|
|
3706
|
+
return textResult(`Failed to steer agent: ${errorMessage(err)}`);
|
|
3338
3707
|
}
|
|
3339
3708
|
},
|
|
3340
3709
|
}));
|
|
3341
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
|
+
|
|
3342
3812
|
// ---- /agents interactive menu ----
|
|
3343
3813
|
|
|
3344
3814
|
// Directory resolution and the frontmatter edits live in agent-file-toggle.ts
|
|
3345
3815
|
// so they are reachable from tests — this command handler is only registered
|
|
3346
3816
|
// through `registerCommand`, which every test mocks.
|
|
3347
3817
|
|
|
3348
|
-
function getModelLabel(type: string, registry
|
|
3818
|
+
function getModelLabel(type: string, registry: ModelRegistry, cwd: string): string {
|
|
3349
3819
|
const cfg = getAgentConfig(type);
|
|
3350
3820
|
const input = modelRequest(type, cfg?.model).requested;
|
|
3351
3821
|
if (!input) return "inherit";
|
|
3352
3822
|
const label = getModelLabelFromConfig(input);
|
|
3353
|
-
|
|
3354
|
-
const selection = selectModel({ homeDir: options.homeDir, agentType: type, definitionModel: cfg?.model, parentModel: undefined, registry, cwd: currentCtx?.cwd ?? process.cwd() });
|
|
3823
|
+
const selection = selectModel({ homeDir: options.homeDir, agentType: type, definitionModel: cfg?.model, parentModel: undefined, registry, cwd });
|
|
3355
3824
|
if (typeof selection === "string" || selection.outcome === "fallback" || !selection.model) {
|
|
3356
3825
|
return `${label} (unavailable, fallback: inherit)`;
|
|
3357
3826
|
}
|
|
@@ -3379,6 +3848,9 @@ Terse command-style prompts produce shallow, generic work.
|
|
|
3379
3848
|
const done = agents.filter(a => a.status === "completed" || a.status === "steered").length;
|
|
3380
3849
|
options.push(`Running agents (${agents.length}) — ${running} running, ${done} done`);
|
|
3381
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})`);
|
|
3382
3854
|
|
|
3383
3855
|
// Agent types list
|
|
3384
3856
|
if (allNames.length > 0) {
|
|
@@ -3417,6 +3889,12 @@ Terse command-style prompts produce shallow, generic work.
|
|
|
3417
3889
|
if (choice.startsWith("Running agents (")) {
|
|
3418
3890
|
await showRunningAgents(ctx);
|
|
3419
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);
|
|
3420
3898
|
} else if (choice.startsWith("Agent types (")) {
|
|
3421
3899
|
await showAllAgentsList(ctx);
|
|
3422
3900
|
await showAgentsMenu(ctx);
|
|
@@ -3460,7 +3938,7 @@ Terse command-style prompts produce shallow, generic work.
|
|
|
3460
3938
|
// it: the `▪` badge says a package won, not which one.
|
|
3461
3939
|
const items: SettingItem[] = allNames.map((name, i) => {
|
|
3462
3940
|
const cfg = configs[i];
|
|
3463
|
-
const model = getModelLabel(name, ctx.modelRegistry);
|
|
3941
|
+
const model = getModelLabel(name, ctx.modelRegistry, ctx.cwd);
|
|
3464
3942
|
return {
|
|
3465
3943
|
id: name,
|
|
3466
3944
|
label: `${sourceIndicator(cfg)}${name}`,
|
|
@@ -3538,9 +4016,9 @@ Terse command-style prompts produce shallow, generic work.
|
|
|
3538
4016
|
(tui, theme, keybindings, done) => {
|
|
3539
4017
|
overlayOptions = getConversationOverlayOptions(tui);
|
|
3540
4018
|
return new ConversationViewer(tui, session, record, activity, theme, done, () => {
|
|
3541
|
-
|
|
3542
|
-
|
|
3543
|
-
}
|
|
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");
|
|
3544
4022
|
}, keybindings, (message: string) => manager.steer(record.id, message), showCost, getViewerMarkdown, (mode) => chooseViewerMarkdown(mode, ctx), getFullscreenScrollbar(),
|
|
3545
4023
|
backgroundActionFor(record, message => ctx.ui.notify(message, "info")), clock);
|
|
3546
4024
|
},
|
|
@@ -3569,10 +4047,9 @@ Terse command-style prompts produce shallow, generic work.
|
|
|
3569
4047
|
? ["Eject (export as .md)", "Back"]
|
|
3570
4048
|
: ["Eject (export as .md)", "Disable", "Back"];
|
|
3571
4049
|
} else if (disabled && file) {
|
|
3572
|
-
// Disabled agent with a file — offer Enable
|
|
3573
|
-
|
|
3574
|
-
|
|
3575
|
-
: ["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"];
|
|
3576
4053
|
} else if (isDefault && !file) {
|
|
3577
4054
|
// Default agent with no .md override
|
|
3578
4055
|
menuOptions = ["Eject (export as .md)", "Disable", "Back"];
|
|
@@ -3878,7 +4355,8 @@ Write the file using the write tool. Only write the file, nothing else.`;
|
|
|
3878
4355
|
else if (modelChoice === "sonnet") model = "anthropic/claude-sonnet-4-6";
|
|
3879
4356
|
else if (modelChoice === "opus") model = "anthropic/claude-opus-4-6";
|
|
3880
4357
|
else if (modelChoice === "custom...") {
|
|
3881
|
-
model
|
|
4358
|
+
// A blank answer writes no `model:` line, exactly as no answer does.
|
|
4359
|
+
model = await ctx.ui.input("Model (provider/modelId)");
|
|
3882
4360
|
}
|
|
3883
4361
|
|
|
3884
4362
|
// 5. Thinking
|
|
@@ -3930,6 +4408,7 @@ Write the file using the write tool. Only write the file, nothing else.`;
|
|
|
3930
4408
|
// normalizeMaxTurns() in agent-runner.ts (which maps 0 → undefined).
|
|
3931
4409
|
defaultMaxTurns: getDefaultMaxTurns() ?? 0,
|
|
3932
4410
|
graceTurns: getGraceTurns(),
|
|
4411
|
+
notificationResultChars,
|
|
3933
4412
|
defaultJoinMode: getDefaultJoinMode(),
|
|
3934
4413
|
backgroundByDefault: getBackgroundByDefault(),
|
|
3935
4414
|
schedulingEnabled: isSchedulingEnabled(),
|
|
@@ -4010,6 +4489,7 @@ Write the file using the write tool. Only write the file, nothing else.`;
|
|
|
4010
4489
|
|
|
4011
4490
|
const NUMERIC_IDS = new Set([
|
|
4012
4491
|
"maxConcurrent", "maxConcurrentForeground", "defaultMaxTurns", "graceTurns", "maxSubagentDepth",
|
|
4492
|
+
"notificationResultChars",
|
|
4013
4493
|
]);
|
|
4014
4494
|
|
|
4015
4495
|
async function showSettings(ctx: ExtensionCommandContext) {
|
|
@@ -4063,6 +4543,13 @@ Write the file using the write tool. Only write the file, nothing else.`;
|
|
|
4063
4543
|
currentValue: String(msd),
|
|
4064
4544
|
values: [String(msd)],
|
|
4065
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
|
+
},
|
|
4066
4553
|
{
|
|
4067
4554
|
id: "joinMode",
|
|
4068
4555
|
label: "Join mode",
|
|
@@ -4260,6 +4747,14 @@ Write the file using the write tool. Only write the file, nothing else.`;
|
|
|
4260
4747
|
setGraceTurns(n);
|
|
4261
4748
|
notifyApplied(ctx, `Grace turns set to ${n}`);
|
|
4262
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
|
+
}
|
|
4263
4758
|
} else if (id === "maxSubagentDepth") {
|
|
4264
4759
|
const n = parseInt(value, 10);
|
|
4265
4760
|
if (n >= 0) {
|
|
@@ -4401,7 +4896,8 @@ Write the file using the write tool. Only write the file, nothing else.`;
|
|
|
4401
4896
|
const enabled = value === "on";
|
|
4402
4897
|
setRememberAgents(enabled);
|
|
4403
4898
|
notifyApplied(ctx, `Remember agents ${enabled ? "enabled" : "disabled"}`);
|
|
4404
|
-
} else
|
|
4899
|
+
} else {
|
|
4900
|
+
// widgetMode, the last row: every id `buildItems` offers has an arm.
|
|
4405
4901
|
setWidgetMode(value as WidgetMode);
|
|
4406
4902
|
notifyApplied(ctx, `Widget set to ${value}`);
|
|
4407
4903
|
}
|
|
@@ -4461,7 +4957,9 @@ Write the file using the write tool. Only write the file, nothing else.`;
|
|
|
4461
4957
|
? String(getDefaultMaxTurns() ?? 0)
|
|
4462
4958
|
: result === "maxSubagentDepth"
|
|
4463
4959
|
? String(getMaxSubagentDepth())
|
|
4464
|
-
:
|
|
4960
|
+
: result === "notificationResultChars"
|
|
4961
|
+
? String(notificationResultChars)
|
|
4962
|
+
: String(getGraceTurns());
|
|
4465
4963
|
|
|
4466
4964
|
const label = result === "maxConcurrent"
|
|
4467
4965
|
? "Max concurrency (1+)"
|
|
@@ -4471,7 +4969,9 @@ Write the file using the write tool. Only write the file, nothing else.`;
|
|
|
4471
4969
|
? "Default max turns (0 = unlimited)"
|
|
4472
4970
|
: result === "maxSubagentDepth"
|
|
4473
4971
|
? "Nested depth (0/1 = nesting off)"
|
|
4474
|
-
:
|
|
4972
|
+
: result === "notificationResultChars"
|
|
4973
|
+
? `Notice result chars (0 = preview, max ${NOTIFICATION_RESULT_CHARS_CEILING})`
|
|
4974
|
+
: "Grace turns (1+)";
|
|
4475
4975
|
|
|
4476
4976
|
// Loop until user enters a valid integer or cancels (Esc / null).
|
|
4477
4977
|
// Silently trims whitespace; rejects non-numeric input by re-prompting.
|