@nredd/pi-subagents 0.0.0-stage → 0.22.0-nredd.5
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/LICENSE +21 -0
- package/README.md +1244 -2
- package/package.json +75 -4
- package/src/abortable.ts +43 -0
- package/src/agent-color.ts +161 -0
- package/src/agent-file-toggle.ts +297 -0
- package/src/agent-manager.ts +1874 -0
- package/src/agent-runner.ts +1552 -0
- package/src/agent-source-badge.ts +75 -0
- package/src/agent-types.ts +346 -0
- package/src/child-context.ts +15 -0
- package/src/clock.ts +25 -0
- package/src/completion-outbox.ts +125 -0
- package/src/context.ts +58 -0
- package/src/cross-extension-rpc.ts +221 -0
- package/src/custom-agents.ts +436 -0
- package/src/default-agents.ts +126 -0
- package/src/detached-results.ts +134 -0
- package/src/enabled-models.ts +180 -0
- package/src/enterprise-local.ts +66 -0
- package/src/env.ts +33 -0
- package/src/group-join.ts +148 -0
- package/src/index.ts +4423 -0
- package/src/invocation-config.ts +189 -0
- package/src/memory.ts +179 -0
- package/src/mention-clone.ts +195 -0
- package/src/mention.ts +141 -0
- package/src/model-call.ts +83 -0
- package/src/model-resolver.ts +266 -0
- package/src/model-scope.ts +70 -0
- package/src/nested-tools.ts +448 -0
- package/src/output-file.ts +164 -0
- package/src/package-resources.ts +624 -0
- package/src/prompts.ts +156 -0
- package/src/quota-admission.ts +99 -0
- package/src/schedule-store.ts +153 -0
- package/src/schedule.ts +446 -0
- package/src/settings.ts +728 -0
- package/src/skill-loader.ts +137 -0
- package/src/startup-error.ts +29 -0
- package/src/status-note.ts +90 -0
- package/src/structured-output.ts +130 -0
- package/src/types.ts +404 -0
- package/src/ui/agent-mention.ts +216 -0
- package/src/ui/agent-row.ts +235 -0
- package/src/ui/agent-widget.ts +692 -0
- package/src/ui/conversation-viewer.ts +832 -0
- package/src/ui/fleet-list.ts +675 -0
- package/src/ui/notification-view.ts +120 -0
- package/src/ui/schedule-menu.ts +105 -0
- package/src/ui/select-item.ts +45 -0
- package/src/ui/viewer-keys.ts +39 -0
- package/src/ui/workflow-card.ts +462 -0
- package/src/ui/workflow-dialog.ts +1115 -0
- package/src/ui/workflow-menu.ts +193 -0
- package/src/usage.ts +167 -0
- package/src/workflow/collisions.ts +123 -0
- package/src/workflow/entry.ts +47 -0
- package/src/workflow/host.ts +401 -0
- package/src/workflow/journal.ts +164 -0
- package/src/workflow/json-schema.ts +129 -0
- package/src/workflow/meta.ts +326 -0
- package/src/workflow/progress.ts +549 -0
- package/src/workflow/runtime.ts +1216 -0
- package/src/workflow/saved.ts +259 -0
- package/src/workflow/task.ts +302 -0
- package/src/workflow/tool-description.ts +200 -0
- package/src/workflow/worker-source.ts +781 -0
- package/src/worktree.ts +230 -0
- package/src/xml.ts +13 -0
package/src/index.ts
ADDED
|
@@ -0,0 +1,4423 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* pi-agents — A pi extension providing Claude Code-style autonomous sub-agents.
|
|
3
|
+
*
|
|
4
|
+
* Tools:
|
|
5
|
+
* Agent — LLM-callable: spawn a sub-agent
|
|
6
|
+
* get_subagent_result — LLM-callable: check background agent status/result
|
|
7
|
+
* steer_subagent — LLM-callable: send a steering message to a running agent
|
|
8
|
+
*
|
|
9
|
+
* Commands:
|
|
10
|
+
* /agents — Interactive agent management menu
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { existsSync, mkdirSync, readFileSync, unlinkSync, writeFileSync } from "node:fs";
|
|
14
|
+
import { isAbsolute, join } from "node:path";
|
|
15
|
+
import { defineTool, type ExtensionAPI, type ExtensionCommandContext, type ExtensionContext, type ExtensionFactory, getAgentDir, getSettingsListTheme } from "@earendil-works/pi-coding-agent";
|
|
16
|
+
import { Container, isKeyRelease, Key, matchesKey, type ScrollViewScrollbar, type SettingItem, SettingsList, Spacer, Text } from "@earendil-works/pi-tui";
|
|
17
|
+
import { nanoid } from "nanoid";
|
|
18
|
+
import { Type } from "typebox";
|
|
19
|
+
import { abortable } from "./abortable.js";
|
|
20
|
+
import { hasAgentBadge, renderAgentName } from "./agent-color.js";
|
|
21
|
+
import { buildNewAgentFile, disableInContent, enableInContent, isEmptyStub, locateAgentFile, personalAgentsDir, projectAgentsDir, serializeAgentFile } from "./agent-file-toggle.js";
|
|
22
|
+
import { AgentManager, describeEvicted, isTopLevelAgent, markConsumed } from "./agent-manager.js";
|
|
23
|
+
import { getAgentConversation, getDefaultMaxTurns, getGraceTurns, getRememberAgents, normalizeMaxTurns, resolveEffectiveMaxTurns, SUBAGENT_TOOL_NAMES, setDefaultMaxTurns, setGraceTurns, setRememberAgents, steerAgent } from "./agent-runner.js";
|
|
24
|
+
import { rowDescription, sourceIndicator, sourceLegend } from "./agent-source-badge.js";
|
|
25
|
+
import { BUILTIN_TOOL_NAMES, getAgentConfig, getAllTypes, getAvailableTypes, getConfig, getFallbackSubagent, isDefaultsDisabled, NO_FALLBACK, registerAgents, resolveSpawnType, resolveType, setDefaultsDisabled, setFallbackSubagent } from "./agent-types.js";
|
|
26
|
+
import { inChildSessionContext } from "./child-context.js";
|
|
27
|
+
import { type Clock, realClock } from "./clock.js";
|
|
28
|
+
import { CompletionOutbox, NOTIFICATION_HOLD_MS } from "./completion-outbox.js";
|
|
29
|
+
import { type RpcHandle, registerRpcHandlers } from "./cross-extension-rpc.js";
|
|
30
|
+
import { loadCustomAgents } from "./custom-agents.js";
|
|
31
|
+
import {
|
|
32
|
+
type DetachedResult,
|
|
33
|
+
drainDetached,
|
|
34
|
+
listRunningDetached,
|
|
35
|
+
registerRunningDetached,
|
|
36
|
+
spoolDetached,
|
|
37
|
+
unregisterRunningDetached,
|
|
38
|
+
watchDetached,
|
|
39
|
+
} from "./detached-results.js";
|
|
40
|
+
import { readEnabledModels, resolveEnabledModels } from "./enabled-models.js";
|
|
41
|
+
import { GroupJoinManager } from "./group-join.js";
|
|
42
|
+
import { cwdParam, isolationParam, resolveAgentInvocationConfig, resolveCwdParam, resolveJoinMode } from "./invocation-config.js";
|
|
43
|
+
import { describeMention, handleBase, isReservedHandle, parseMention, resolveHandleToType, stripAgentPrefix } from "./mention.js";
|
|
44
|
+
import { runMentionClone } from "./mention-clone.js";
|
|
45
|
+
import { applyModelCallEvent, type ModelCallEvent } from "./model-call.js";
|
|
46
|
+
import { describeModel, describeVirtualModel, isVirtualModel, type ModelRegistry, resolveModel, setAgentModels, setModelAliases, setModelScopeProvider } from "./model-resolver.js";
|
|
47
|
+
import { checkModelScope, isScopeModelsEnabled, setScopeModelsEnabled } from "./model-scope.js";
|
|
48
|
+
import { getMaxSubagentDepth, setMaxSubagentDepth } from "./nested-tools.js";
|
|
49
|
+
import { createOutputFilePath, ensureOutputFile, getOutputTranscriptDefault, sessionTaskDir, setOutputTranscriptDefault, streamToOutputFile, writeInitialEntry } from "./output-file.js";
|
|
50
|
+
import { getPackageAgentsGate, getPackageWorkflowsGate, invalidatePackageCache, isProjectTrusted, packageNameForPath, setPackageAgentsGate, setPackageWorkflowsGate, setProjectTrusted } from "./package-resources.js";
|
|
51
|
+
import { checkAdmission, describeBlock, mayPark, modelRef, QUOTA_RESTART_GRACE_MS } from "./quota-admission.js";
|
|
52
|
+
import { SubagentScheduler } from "./schedule.js";
|
|
53
|
+
import { resolveStorePath, ScheduleStore } from "./schedule-store.js";
|
|
54
|
+
import { applyAndEmitLoaded, loadSettings, type SubagentsSettings, saveAndEmitChanged, type ToolDescriptionMode } from "./settings.js";
|
|
55
|
+
import { hostStartupErrorClass } from "./startup-error.js";
|
|
56
|
+
import { getForegroundOutcomeNote, getStatusNote, partialOutputSuffix } from "./status-note.js";
|
|
57
|
+
import { type AgentConfig, type AgentInvocation, type AgentMentionMode, type AgentRecord, type JoinMode, type NotificationDetails, type SubagentType, type ViewerMarkdownMode, type WidgetMode } from "./types.js";
|
|
58
|
+
import { createMentionProvider, mentionRoster, type TypeInfo } from "./ui/agent-mention.js";
|
|
59
|
+
import { AgentCallView, AgentResultView, type AgentRowLive, keepRowTicking, keepTicking } from "./ui/agent-row.js";
|
|
60
|
+
import {
|
|
61
|
+
type AgentActivity,
|
|
62
|
+
type AgentDetails,
|
|
63
|
+
AgentWidget,
|
|
64
|
+
buildInvocationTags,
|
|
65
|
+
describeActivity,
|
|
66
|
+
formatCost,
|
|
67
|
+
formatDuration,
|
|
68
|
+
formatMs,
|
|
69
|
+
formatTokens,
|
|
70
|
+
getDisplayName,
|
|
71
|
+
getPromptModeLabel,
|
|
72
|
+
SPINNER,
|
|
73
|
+
type UICtx,
|
|
74
|
+
} from "./ui/agent-widget.js";
|
|
75
|
+
import { ConversationViewer, getConversationOverlayOptions, type ViewerBackgroundAction } from "./ui/conversation-viewer.js";
|
|
76
|
+
import { FleetList, type FleetUICtx, type FleetWorkflow } from "./ui/fleet-list.js";
|
|
77
|
+
import { renderNotification } from "./ui/notification-view.js";
|
|
78
|
+
import { showSchedulesMenu } from "./ui/schedule-menu.js";
|
|
79
|
+
import { selectItem } from "./ui/select-item.js";
|
|
80
|
+
import { renderWorkflowCard, renderWorkflowEntryCard } from "./ui/workflow-card.js";
|
|
81
|
+
import { openWorkflowFromFleet, showWorkflowsMenu, type WorkflowMenuDeps } from "./ui/workflow-menu.js";
|
|
82
|
+
import { getLifetimeCost, getLifetimeTotal, getSessionContextPercent, type LifetimeUsage, PendingUsagePool, toReportedUsage } from "./usage.js";
|
|
83
|
+
import { decideWorkflowCollision, FOREIGN_WORKFLOW_TOOL_NAMES } from "./workflow/collisions.js";
|
|
84
|
+
import { WORKFLOW_ENTRY_TYPE, type WorkflowEntryData, workflowEntryData } from "./workflow/entry.js";
|
|
85
|
+
import { createWorkflowHost } from "./workflow/host.js";
|
|
86
|
+
import { appendJournal, readJournal, type WorkflowJournalEntry } from "./workflow/journal.js";
|
|
87
|
+
import { extractMeta, type WorkflowMeta, workflowCallName } from "./workflow/meta.js";
|
|
88
|
+
import { elapsedMs } from "./workflow/progress.js";
|
|
89
|
+
import { runWorkflow } from "./workflow/runtime.js";
|
|
90
|
+
import { resolveWorkflowScript } from "./workflow/saved.js";
|
|
91
|
+
import { completeWorkflowTask, createWorkflowTask, failWorkflowTask, formatWorkflowNotification, resolveResumeTarget, updateWorkflowProgressBatch, type WorkflowTask, workflowResultText, workflowRunId } from "./workflow/task.js";
|
|
92
|
+
import { fullWorkflowToolDescription } from "./workflow/tool-description.js";
|
|
93
|
+
import { isWorktreeIsolationEnabled, setWorktreeIsolationEnabled } from "./worktree.js";
|
|
94
|
+
import { escapeXml } from "./xml.js";
|
|
95
|
+
|
|
96
|
+
// ---- Shared helpers ----
|
|
97
|
+
|
|
98
|
+
/** Tool execute return value for a text response. */
|
|
99
|
+
function textResult(msg: string, details?: AgentDetails) {
|
|
100
|
+
return { content: [{ type: "text" as const, text: msg }], details: details as any };
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** Format an agent's lifetime token total, or "" when zero. */
|
|
104
|
+
function formatLifetimeTokens(o: { lifetimeUsage: LifetimeUsage }): string {
|
|
105
|
+
const t = getLifetimeTotal(o.lifetimeUsage);
|
|
106
|
+
return t > 0 ? formatTokens(t) : "";
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Create an AgentActivity state and spawn callbacks for tracking tool usage.
|
|
111
|
+
* Used by both foreground and background paths to avoid duplication.
|
|
112
|
+
*/
|
|
113
|
+
function createActivityTracker(maxTurns?: number, onStreamUpdate?: () => void, clock: Clock = realClock) {
|
|
114
|
+
const state: AgentActivity = {
|
|
115
|
+
activeTools: new Map(),
|
|
116
|
+
toolUses: 0,
|
|
117
|
+
turnCount: 1,
|
|
118
|
+
maxTurns,
|
|
119
|
+
responseText: "",
|
|
120
|
+
session: undefined,
|
|
121
|
+
};
|
|
122
|
+
|
|
123
|
+
const callbacks = {
|
|
124
|
+
onToolActivity: (activity: { type: "start" | "end"; toolName: string }) => {
|
|
125
|
+
if (activity.type === "start") {
|
|
126
|
+
state.activeTools.set(activity.toolName + "_" + Date.now(), activity.toolName);
|
|
127
|
+
} else {
|
|
128
|
+
for (const [key, name] of state.activeTools) {
|
|
129
|
+
if (name === activity.toolName) { state.activeTools.delete(key); break; }
|
|
130
|
+
}
|
|
131
|
+
state.toolUses++;
|
|
132
|
+
}
|
|
133
|
+
onStreamUpdate?.();
|
|
134
|
+
},
|
|
135
|
+
onTextDelta: (_delta: string, fullText: string) => {
|
|
136
|
+
state.responseText = fullText;
|
|
137
|
+
onStreamUpdate?.();
|
|
138
|
+
},
|
|
139
|
+
onTurnEnd: (turnCount: number) => {
|
|
140
|
+
state.turnCount = turnCount;
|
|
141
|
+
onStreamUpdate?.();
|
|
142
|
+
},
|
|
143
|
+
onModelCall: (event: ModelCallEvent) => {
|
|
144
|
+
state.modelCall = applyModelCallEvent(state.modelCall, event, clock.now());
|
|
145
|
+
onStreamUpdate?.();
|
|
146
|
+
},
|
|
147
|
+
onSessionCreated: (session: any) => {
|
|
148
|
+
state.session = session;
|
|
149
|
+
},
|
|
150
|
+
// Spend is accumulated on the AgentRecord (agent-manager), which is what
|
|
151
|
+
// every surface reads; this callback exists here only to repaint on it.
|
|
152
|
+
onAssistantUsage: (_usage: LifetimeUsage) => {
|
|
153
|
+
onStreamUpdate?.();
|
|
154
|
+
},
|
|
155
|
+
};
|
|
156
|
+
|
|
157
|
+
return { state, callbacks };
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* Advertised thinking levels, ordered to mirror pi-ai's EXTENDED_THINKING_LEVELS
|
|
162
|
+
* (`off` + every `ThinkingLevel`). Single source for the Agent tool description,
|
|
163
|
+
* the generated-agent template, and the `/agents` wizard so these lists can't
|
|
164
|
+
* drift behind pi again (#147). Availability of any level still depends on the
|
|
165
|
+
* host pi version and the selected model — pi clamps unsupported levels down.
|
|
166
|
+
*/
|
|
167
|
+
const THINKING_LEVELS = ["off", "minimal", "low", "medium", "high", "xhigh", "max"] as const;
|
|
168
|
+
|
|
169
|
+
/** Human-readable status label for agent completion. */
|
|
170
|
+
function getStatusLabel(status: string, error?: string): string {
|
|
171
|
+
switch (status) {
|
|
172
|
+
case "error": return `Error: ${error ?? "unknown"}`;
|
|
173
|
+
case "aborted": return "Aborted (max turns exceeded)";
|
|
174
|
+
case "steered": return "Wrapped up (turn limit)";
|
|
175
|
+
case "stopped": return "Stopped";
|
|
176
|
+
default: return "Done";
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/** Format a structured task notification matching Claude Code's <task-notification> XML. */
|
|
181
|
+
function formatTaskNotification(record: AgentRecord, resultMaxLen: number, showCost = false): string {
|
|
182
|
+
const status = getStatusLabel(record.status, record.error);
|
|
183
|
+
const durationMs = record.completedAt ? record.completedAt - record.startedAt : 0;
|
|
184
|
+
const totalTokens = getLifetimeTotal(record.lifetimeUsage);
|
|
185
|
+
const contextPercent = getSessionContextPercent(record.session);
|
|
186
|
+
const ctxXml = contextPercent !== null ? `<context_percent>${Math.round(contextPercent)}</context_percent>` : "";
|
|
187
|
+
const compactXml = record.compactionCount ? `<compactions>${record.compactionCount}</compactions>` : "";
|
|
188
|
+
// Only under `showCost`: this is LLM context, and a figure the orchestrator
|
|
189
|
+
// did not ask for is a figure it may start reporting unprompted.
|
|
190
|
+
const cost = showCost ? getLifetimeCost(record.lifetimeUsage) : 0;
|
|
191
|
+
const costXml = cost > 0 ? `<estimated_cost_usd>${cost.toFixed(4)}</estimated_cost_usd>` : "";
|
|
192
|
+
|
|
193
|
+
const resultPreview = record.result
|
|
194
|
+
? record.result.length > resultMaxLen
|
|
195
|
+
? record.result.slice(0, resultMaxLen) + "\n...(truncated, use get_subagent_result for full output)"
|
|
196
|
+
: record.result
|
|
197
|
+
: "No output.";
|
|
198
|
+
|
|
199
|
+
return [
|
|
200
|
+
`<task-notification>`,
|
|
201
|
+
`<task-id>${record.id}</task-id>`,
|
|
202
|
+
record.toolCallId ? `<tool-use-id>${escapeXml(record.toolCallId)}</tool-use-id>` : null,
|
|
203
|
+
record.outputFile ? `<output-file>${escapeXml(record.outputFile)}</output-file>` : null,
|
|
204
|
+
`<status>${escapeXml(status)}</status>`,
|
|
205
|
+
`<summary>Agent "${escapeXml(record.description)}" ${record.status}${getStatusNote(record.status)}</summary>`,
|
|
206
|
+
`<result>${escapeXml(resultPreview)}</result>`,
|
|
207
|
+
`<usage><total_tokens>${totalTokens}</total_tokens><tool_uses>${record.toolUses}</tool_uses>${ctxXml}${compactXml}${costXml}<duration_ms>${durationMs}</duration_ms></usage>`,
|
|
208
|
+
`</task-notification>`,
|
|
209
|
+
].filter(Boolean).join('\n');
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/** Build AgentDetails from a base + record-specific fields. */
|
|
213
|
+
function buildDetails(
|
|
214
|
+
base: Pick<AgentDetails, "displayName" | "description" | "subagentType" | "modelName" | "tags">,
|
|
215
|
+
record: { toolUses: number; startedAt: number; completedAt?: number; status: string; error?: string; id?: string; session?: any; outputFile?: string; lifetimeUsage: LifetimeUsage },
|
|
216
|
+
activity?: AgentActivity,
|
|
217
|
+
overrides?: Partial<AgentDetails>,
|
|
218
|
+
): AgentDetails {
|
|
219
|
+
return {
|
|
220
|
+
...base,
|
|
221
|
+
toolUses: record.toolUses,
|
|
222
|
+
tokens: formatLifetimeTokens(record),
|
|
223
|
+
// Raw, and unconditional: `tokens` is preformatted because it is one stat,
|
|
224
|
+
// but a cost is joined by "·" in one surface, "," in another and "|" in a
|
|
225
|
+
// third — so it travels as a number and each renderer punctuates its own.
|
|
226
|
+
cost: getLifetimeCost(record.lifetimeUsage),
|
|
227
|
+
turnCount: activity?.turnCount,
|
|
228
|
+
maxTurns: activity?.maxTurns,
|
|
229
|
+
durationMs: (record.completedAt ?? Date.now()) - record.startedAt,
|
|
230
|
+
status: record.status as AgentDetails["status"],
|
|
231
|
+
agentId: record.id,
|
|
232
|
+
error: record.error,
|
|
233
|
+
outputFile: record.outputFile,
|
|
234
|
+
...overrides,
|
|
235
|
+
};
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
/** Build notification details for the custom message renderer. */
|
|
239
|
+
function buildNotificationDetails(record: AgentRecord, resultMaxLen: number, activity?: AgentActivity): NotificationDetails {
|
|
240
|
+
const totalTokens = getLifetimeTotal(record.lifetimeUsage);
|
|
241
|
+
|
|
242
|
+
return {
|
|
243
|
+
id: record.id,
|
|
244
|
+
description: record.description,
|
|
245
|
+
status: record.status,
|
|
246
|
+
toolUses: record.toolUses,
|
|
247
|
+
turnCount: activity?.turnCount ?? 0,
|
|
248
|
+
maxTurns: activity?.maxTurns,
|
|
249
|
+
totalTokens,
|
|
250
|
+
// Carried unconditionally; the renderer gates on the setting. Details are
|
|
251
|
+
// data, and a notification rendered before a mid-session toggle should not
|
|
252
|
+
// be stuck with the old answer.
|
|
253
|
+
totalCost: getLifetimeCost(record.lifetimeUsage),
|
|
254
|
+
durationMs: record.completedAt ? record.completedAt - record.startedAt : 0,
|
|
255
|
+
outputFile: record.outputFile,
|
|
256
|
+
error: record.error,
|
|
257
|
+
resultPreview: record.result
|
|
258
|
+
? record.result.length > resultMaxLen
|
|
259
|
+
? record.result.slice(0, resultMaxLen) + "…"
|
|
260
|
+
: record.result
|
|
261
|
+
: "No output.",
|
|
262
|
+
};
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
/**
|
|
266
|
+
* Format an agent's tool scope for the Agent tool description.
|
|
267
|
+
*
|
|
268
|
+
* This suffix names the BUILT-IN scope and flags, without listing them, that
|
|
269
|
+
* extension tools load too — they are resolved when the agent runs (extensions
|
|
270
|
+
* can register asynchronously), so they cannot be enumerated while the
|
|
271
|
+
* description is being built. An agent with `tools: "*, ext:mcp/search"`
|
|
272
|
+
* therefore renders "* + extension tools".
|
|
273
|
+
*
|
|
274
|
+
* Two distinctions matter, both of them capability claims the orchestrator acts on:
|
|
275
|
+
*
|
|
276
|
+
* - absent vs empty. `builtinToolNames: undefined` means the agent never narrowed
|
|
277
|
+
* its tools (the shipped defaults); `[]` is what `tools: none` and an `ext:`-only
|
|
278
|
+
* `tools:` parse to, and the runtime really does hand those agents no built-ins.
|
|
279
|
+
* Rendering both "*" tells the orchestrator a tool-less agent can run `bash`.
|
|
280
|
+
* - empty-with-extensions vs empty-without. Zero built-ins does NOT imply zero
|
|
281
|
+
* tools: `tools: none` alongside `extensions:` still surfaces every extension
|
|
282
|
+
* tool (see test/fixtures/.pi/agents/tools-none.md, which expects three). Calling
|
|
283
|
+
* that "none" understates the agent instead of overstating it — better, but still
|
|
284
|
+
* wrong, and it would route work away from the only agent able to do it. "none"
|
|
285
|
+
* is therefore reserved for agents that genuinely can call nothing: `isolated`
|
|
286
|
+
* agents and those with `extensions: false`.
|
|
287
|
+
*/
|
|
288
|
+
export function formatToolsSuffix(cfg: AgentConfig | undefined): string {
|
|
289
|
+
const tools = cfg?.builtinToolNames;
|
|
290
|
+
// `isolated` overrides extensions to false in the runner, so both mean the
|
|
291
|
+
// agent has no extension tools.
|
|
292
|
+
const noExtensionTools = cfg?.isolated === true || cfg?.extensions === false;
|
|
293
|
+
if (tools && tools.length === 0) {
|
|
294
|
+
return noExtensionTools ? "none" : "no built-ins, extension tools only";
|
|
295
|
+
}
|
|
296
|
+
const isFullSet = !tools
|
|
297
|
+
|| (tools.length === BUILTIN_TOOL_NAMES.length && BUILTIN_TOOL_NAMES.every((t) => tools.includes(t)));
|
|
298
|
+
const builtins = isFullSet ? "*" : tools.join(", ");
|
|
299
|
+
// Extension tools resolve at run time, so they cannot be enumerated here, but
|
|
300
|
+
// omitting the fact reads as "built-ins only": an agent that can search the
|
|
301
|
+
// web through an extension then looks web-less, and the work gets routed away.
|
|
302
|
+
return noExtensionTools ? builtins : `${builtins} + extension tools`;
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
/** CLI flag that runs a workflow script at session start. */
|
|
306
|
+
export const WORKFLOW_FILE_FLAG = "subagents-workflow-file";
|
|
307
|
+
|
|
308
|
+
/**
|
|
309
|
+
* Re-exported from where they now live, because this is where they were
|
|
310
|
+
* defined and a consumer (or a test) that matched a session entry on
|
|
311
|
+
* {@link WORKFLOW_ENTRY_TYPE} imports it from here.
|
|
312
|
+
*/
|
|
313
|
+
export { FOREIGN_WORKFLOW_TOOL_NAMES, WORKFLOW_ENTRY_TYPE, type WorkflowEntryData, workflowEntryData };
|
|
314
|
+
|
|
315
|
+
/** Statuses the Agent row has a rendering for. */
|
|
316
|
+
const AGENT_ROW_STATUSES = new Set(["running", "background", "completed", "steered", "aborted", "stopped", "error"]);
|
|
317
|
+
|
|
318
|
+
export type { Clock, TimerHandle } from "./clock.js";
|
|
319
|
+
|
|
320
|
+
/** Optional host dependencies, shared by the scheduler and completion delivery. */
|
|
321
|
+
export interface SubagentsExtensionOptions {
|
|
322
|
+
clock?: Clock;
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
/** Create the extension with an optional host-owned clock; the default export uses native time. */
|
|
326
|
+
export function createSubagentsExtension(options: SubagentsExtensionOptions = {}): ExtensionFactory {
|
|
327
|
+
return pi => activateSubagents(pi, options);
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
export default createSubagentsExtension();
|
|
331
|
+
|
|
332
|
+
function activateSubagents(pi: ExtensionAPI, options: SubagentsExtensionOptions) {
|
|
333
|
+
const clock = options.clock ?? realClock;
|
|
334
|
+
// Child AgentSessions load normal extensions. Re-entering this extension there
|
|
335
|
+
// would create another manager and leak handlers. Nested orchestration is
|
|
336
|
+
// injected as scoped custom tools by the existing manager instead.
|
|
337
|
+
if (inChildSessionContext()) return;
|
|
338
|
+
|
|
339
|
+
// ---- Register custom notification renderer ----
|
|
340
|
+
// Core collapses a custom message to this renderer's first line and draws the
|
|
341
|
+
// ▸/▾ gutter itself; see ui/notification-view.ts.
|
|
342
|
+
pi.registerMessageRenderer<NotificationDetails>(
|
|
343
|
+
"subagent-notification",
|
|
344
|
+
(message, { expanded }, theme) => {
|
|
345
|
+
const d = message.details;
|
|
346
|
+
if (!d) return undefined;
|
|
347
|
+
return renderNotification(d, expanded, theme, showCost);
|
|
348
|
+
}
|
|
349
|
+
);
|
|
350
|
+
|
|
351
|
+
// ---- Workflow run rendered as a session entry ----
|
|
352
|
+
// A workflow launched from the CLI flag has no tool call to hang its result
|
|
353
|
+
// card on, so it renders here instead — through the SAME layout the tool
|
|
354
|
+
// result uses, not a second one. Custom entries with no registered renderer
|
|
355
|
+
// are silently dropped by the host, which is why this is registered at
|
|
356
|
+
// activation rather than lazily.
|
|
357
|
+
pi.registerEntryRenderer<WorkflowEntryData>(WORKFLOW_ENTRY_TYPE, (entry, _options, theme) =>
|
|
358
|
+
renderWorkflowEntryCard(entry.data, theme));
|
|
359
|
+
|
|
360
|
+
// Registered at activation; READ from session_start. The host applies CLI
|
|
361
|
+
// values after every extension factory has run, so `getFlag` here would only
|
|
362
|
+
// ever hand back the registered default (see the read site below).
|
|
363
|
+
pi.registerFlag(WORKFLOW_FILE_FLAG, {
|
|
364
|
+
type: "string",
|
|
365
|
+
description:
|
|
366
|
+
`Run a workflow script at startup: --${WORKFLOW_FILE_FLAG}=<path>. ` +
|
|
367
|
+
"Use the `=` form — the space form consumes the next argument, which would swallow a following prompt.",
|
|
368
|
+
});
|
|
369
|
+
|
|
370
|
+
// Fuzzy model names ("opus") prefer what the user enabled in pi's settings.
|
|
371
|
+
setModelScopeProvider(registry => resolveEnabledModels(readEnabledModels(process.cwd()), registry, process.cwd()));
|
|
372
|
+
|
|
373
|
+
// Read directly rather than waiting for applyAndEmitLoaded below: these decide
|
|
374
|
+
// the initial load, which happens hundreds of lines before settings are applied.
|
|
375
|
+
const bootSettings = loadSettings(process.cwd());
|
|
376
|
+
let strictAgentFiles = bootSettings.strictAgentFiles === true;
|
|
377
|
+
// Strict agent files fail a bind by throwing the host's `ExtensionStartupError`
|
|
378
|
+
// from `session_start`. A host without that class would silently degrade to
|
|
379
|
+
// warnings, so refuse here, before any project agent definition is read.
|
|
380
|
+
const StartupError = hostStartupErrorClass();
|
|
381
|
+
if (strictAgentFiles && !StartupError) {
|
|
382
|
+
throw new Error(
|
|
383
|
+
"[pi-subagents] strictAgentFiles requires a pi host that exports ExtensionStartupError, which this host lacks. " +
|
|
384
|
+
"Upgrade pi, or set strictAgentFiles to false.",
|
|
385
|
+
);
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
// Seed the package gates from the same merged settings, before the first load
|
|
389
|
+
// below reads them. `applyAndEmitLoaded` sets them again through the appliers;
|
|
390
|
+
// doing it here as well is what makes the *initial* registry honour them.
|
|
391
|
+
setPackageAgentsGate(bootSettings.packageAgents);
|
|
392
|
+
setPackageWorkflowsGate(bootSettings.packageWorkflows);
|
|
393
|
+
// Project trust is unknown at activation: pi's canonical answer exists only on
|
|
394
|
+
// `ctx` at `session_start`. Start untrusted, so this load reads global and
|
|
395
|
+
// globally configured package agents only; `session_start` reloads the
|
|
396
|
+
// project's and re-registers the `Agent` tool with the result.
|
|
397
|
+
setProjectTrusted(false);
|
|
398
|
+
|
|
399
|
+
/** Reload agents from package/global/project agent dirs and merge with defaults (called on init and each Agent invocation). */
|
|
400
|
+
const reloadCustomAgents = (strict = false) => {
|
|
401
|
+
const userAgents = loadCustomAgents(process.cwd(), strict);
|
|
402
|
+
registerAgents(userAgents);
|
|
403
|
+
};
|
|
404
|
+
|
|
405
|
+
// Activation load: global and package agents only (project trust is not yet
|
|
406
|
+
// known). The first canonical load on `session_start` is the strict one; a bad
|
|
407
|
+
// edit mid-session must not kill the session on the next unrelated spawn, so
|
|
408
|
+
// every later reload keeps warning.
|
|
409
|
+
reloadCustomAgents(strictAgentFiles);
|
|
410
|
+
let canonicalAgentsLoaded = false;
|
|
411
|
+
|
|
412
|
+
// ---- Agent activity tracking + widget ----
|
|
413
|
+
const agentActivity = new Map<string, AgentActivity>();
|
|
414
|
+
|
|
415
|
+
// ---- Usage reporting (both off by default; see SubagentsSettings) ----
|
|
416
|
+
/** Attach subagent spend to tool results, so the parent session counts it. */
|
|
417
|
+
let reportUsage = false;
|
|
418
|
+
function isReportUsageEnabled(): boolean { return reportUsage; }
|
|
419
|
+
function setReportUsage(b: boolean): void {
|
|
420
|
+
reportUsage = b;
|
|
421
|
+
// Whatever accumulated while it was on is stale the moment it goes off:
|
|
422
|
+
// draining it later would bill the parent for a window the user opted out
|
|
423
|
+
// of, in one lump, on some unrelated later tool call.
|
|
424
|
+
if (!b) pendingUsage.drain();
|
|
425
|
+
}
|
|
426
|
+
/** Show `~$X` next to token counts in the subagent surfaces. */
|
|
427
|
+
let showCost = false;
|
|
428
|
+
function isShowCostEnabled(): boolean { return showCost; }
|
|
429
|
+
function setShowCost(b: boolean): void { showCost = b; widget.update(); fleet.update(); }
|
|
430
|
+
/** Name the model and thinking level on the widget's running rows. */
|
|
431
|
+
let showModel = false;
|
|
432
|
+
function isShowModelEnabled(): boolean { return showModel; }
|
|
433
|
+
function setShowModel(b: boolean): void { showModel = b; widget.update(); fleet.update(); }
|
|
434
|
+
/**
|
|
435
|
+
* How much of the conversation viewer renders as Markdown. Read through a
|
|
436
|
+
* getter by the viewer rather than captured like `showCost`, because the
|
|
437
|
+
* viewer's `m` key writes back here while the overlay is on screen.
|
|
438
|
+
*/
|
|
439
|
+
let viewerMarkdown: ViewerMarkdownMode = "assistant";
|
|
440
|
+
function getViewerMarkdown(): ViewerMarkdownMode { return viewerMarkdown; }
|
|
441
|
+
function setViewerMarkdown(mode: ViewerMarkdownMode): void { viewerMarkdown = mode; }
|
|
442
|
+
/**
|
|
443
|
+
* The viewer's `m` key, from either entry point: set the mode and persist it,
|
|
444
|
+
* so the key and `/agents → Settings` stay one setting rather than one per
|
|
445
|
+
* entry point. `ctx` carries only the warning a failed write notifies with,
|
|
446
|
+
* and the fleet list may be acting without one.
|
|
447
|
+
*/
|
|
448
|
+
function chooseViewerMarkdown(mode: ViewerMarkdownMode, ctx?: ExtensionCommandContext): void {
|
|
449
|
+
setViewerMarkdown(mode);
|
|
450
|
+
persistSettings(ctx, `Viewer markdown set to ${mode}`);
|
|
451
|
+
}
|
|
452
|
+
const pendingUsage = new PendingUsagePool();
|
|
453
|
+
|
|
454
|
+
// ---- Completion delivery ----
|
|
455
|
+
// Every completion notification goes through the outbox, which sends what is
|
|
456
|
+
// pending as one message at the parent's next boundary (see
|
|
457
|
+
// completion-outbox.ts). Each is built when the agent finishes, and reading
|
|
458
|
+
// the result (get_subagent_result, rpc consume) cancels it before it is sent.
|
|
459
|
+
const outbox = new CompletionOutbox(
|
|
460
|
+
(message) => pi.sendMessage<NotificationDetails>({
|
|
461
|
+
customType: "subagent-notification",
|
|
462
|
+
content: message.content,
|
|
463
|
+
display: true,
|
|
464
|
+
details: message.details,
|
|
465
|
+
}, { deliverAs: "steer", triggerTurn: true }),
|
|
466
|
+
() => currentCtx?.signal !== undefined,
|
|
467
|
+
options.clock,
|
|
468
|
+
);
|
|
469
|
+
// A queued result wait must observe completion before its held notification
|
|
470
|
+
// can fire, so successful waits can still suppress that redundant nudge.
|
|
471
|
+
const QUEUE_WAIT_POLL_MS = Math.floor(NOTIFICATION_HOLD_MS / 4);
|
|
472
|
+
|
|
473
|
+
function sendIndividualNudge(record: AgentRecord) {
|
|
474
|
+
agentActivity.delete(record.id);
|
|
475
|
+
widget.markFinished(record.id);
|
|
476
|
+
fleet.onAgentFinished(record.id);
|
|
477
|
+
// Built now, so a resume before the send cannot rewrite it. A record that
|
|
478
|
+
// cannot be formatted is skipped, as a failed send always was.
|
|
479
|
+
try {
|
|
480
|
+
const footer = record.outputFile ? `\nFull transcript available at: ${record.outputFile}` : '';
|
|
481
|
+
const message = {
|
|
482
|
+
content: formatTaskNotification(record, 500, showCost) + footer,
|
|
483
|
+
details: buildNotificationDetails(record, 500, agentActivity.get(record.id)),
|
|
484
|
+
};
|
|
485
|
+
outbox.add({ ids: [record.id], build: () => message });
|
|
486
|
+
} catch { /* ignore stale completion side-effect errors */ }
|
|
487
|
+
widget.update();
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
// ---- Group join manager (`group` join mode only) ----
|
|
491
|
+
const groupJoin = new GroupJoinManager(
|
|
492
|
+
(records, partial) => {
|
|
493
|
+
for (const r of records) { agentActivity.delete(r.id); widget.markFinished(r.id); fleet.onAgentFinished(r.id); }
|
|
494
|
+
|
|
495
|
+
const members = records.filter(r => !r.resultConsumed).flatMap(r => {
|
|
496
|
+
try {
|
|
497
|
+
return [{ id: r.id, text: formatTaskNotification(r, 300, showCost), details: buildNotificationDetails(r, 300, agentActivity.get(r.id)) }];
|
|
498
|
+
} catch { return []; /* as in sendIndividualNudge */ }
|
|
499
|
+
});
|
|
500
|
+
if (members.length > 0) {
|
|
501
|
+
outbox.add({
|
|
502
|
+
ids: members.map(m => m.id),
|
|
503
|
+
build: (ids) => {
|
|
504
|
+
const unread = members.filter(m => ids.includes(m.id));
|
|
505
|
+
const notifications = unread.map(m => m.text).join('\n\n');
|
|
506
|
+
const label = partial
|
|
507
|
+
? `${unread.length} agent(s) finished (partial — others still running)`
|
|
508
|
+
: `${unread.length} agent(s) finished`;
|
|
509
|
+
|
|
510
|
+
const [first, ...rest] = unread;
|
|
511
|
+
const details = first.details;
|
|
512
|
+
if (rest.length > 0) details.others = rest.map(m => m.details);
|
|
513
|
+
return {
|
|
514
|
+
content: `Background agent group completed: ${label}\n\n${notifications}\n\nUse get_subagent_result for full output.`,
|
|
515
|
+
details,
|
|
516
|
+
};
|
|
517
|
+
},
|
|
518
|
+
});
|
|
519
|
+
}
|
|
520
|
+
widget.update();
|
|
521
|
+
},
|
|
522
|
+
30_000,
|
|
523
|
+
);
|
|
524
|
+
|
|
525
|
+
/** Helper: build event data for lifecycle events from an AgentRecord. */
|
|
526
|
+
function buildEventData(record: AgentRecord) {
|
|
527
|
+
const durationMs = record.completedAt ? record.completedAt - record.startedAt : Date.now() - record.startedAt;
|
|
528
|
+
// All three fields are lifetime-accumulated (Σ over every assistant message_end),
|
|
529
|
+
// so they survive compaction together — input + output ≤ total always.
|
|
530
|
+
// tokens is omitted when nothing was ever produced (e.g. agent errored before
|
|
531
|
+
// any message_end fired), preserving prior payload shape.
|
|
532
|
+
const u = record.lifetimeUsage;
|
|
533
|
+
const total = getLifetimeTotal(u);
|
|
534
|
+
const tokens = total > 0
|
|
535
|
+
? { input: u.input, output: u.output, total }
|
|
536
|
+
: undefined;
|
|
537
|
+
// The whole run's spend as a pi `Usage` — pi's convention for handing spend
|
|
538
|
+
// to a consumer, so `usage.cost.total` and `usage.cacheRead` are where a
|
|
539
|
+
// listener already expects them and anything pi adds to `Usage` arrives
|
|
540
|
+
// without a change here. Omitted when nothing was spent, so "spent nothing"
|
|
541
|
+
// and "never ran" stay distinguishable. Ungated by `showCost`: that setting
|
|
542
|
+
// governs what a human is shown, not what the event carries.
|
|
543
|
+
//
|
|
544
|
+
// `tokens` above is the other convention, kept as it shipped: a flat view
|
|
545
|
+
// model like pi's own `SessionStats`, carrying the DISPLAY total, which
|
|
546
|
+
// excludes cacheRead (#38). The two answer different questions and neither
|
|
547
|
+
// derives from the other.
|
|
548
|
+
const usage = toReportedUsage(u);
|
|
549
|
+
return {
|
|
550
|
+
id: record.id,
|
|
551
|
+
type: record.type,
|
|
552
|
+
description: record.description,
|
|
553
|
+
result: record.result,
|
|
554
|
+
error: record.error,
|
|
555
|
+
status: record.status,
|
|
556
|
+
toolUses: record.toolUses,
|
|
557
|
+
durationMs,
|
|
558
|
+
tokens,
|
|
559
|
+
usage,
|
|
560
|
+
};
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
/**
|
|
564
|
+
* Set when a session switch left background agents running in this (now
|
|
565
|
+
* orphaned) instance; their results are spooled for that session to pick up.
|
|
566
|
+
*/
|
|
567
|
+
let detachedSessionFile: string | undefined;
|
|
568
|
+
|
|
569
|
+
function spoolDetachedResult(sessionFile: string, record: AgentRecord) {
|
|
570
|
+
if (record.resultConsumed) return;
|
|
571
|
+
const footer = record.outputFile ? `\nFull transcript available at: ${record.outputFile}` : "";
|
|
572
|
+
try {
|
|
573
|
+
spoolDetached(sessionFile, {
|
|
574
|
+
id: record.id,
|
|
575
|
+
content: formatTaskNotification(record, 500, showCost) + footer,
|
|
576
|
+
details: buildNotificationDetails(record, 500),
|
|
577
|
+
record: {
|
|
578
|
+
id: record.id, type: record.type, description: record.description,
|
|
579
|
+
status: record.status, result: record.result, error: record.error,
|
|
580
|
+
startedAt: record.startedAt, completedAt: record.completedAt,
|
|
581
|
+
},
|
|
582
|
+
});
|
|
583
|
+
} catch { /* spool unwritable: the transcript file still has the result */ }
|
|
584
|
+
}
|
|
585
|
+
|
|
586
|
+
/** Re-announce agents that finished while this session was switched away. */
|
|
587
|
+
function deliverDetached(sessionFile: string) {
|
|
588
|
+
for (const result of drainDetached(sessionFile)) deliverDetachedResult(result);
|
|
589
|
+
}
|
|
590
|
+
|
|
591
|
+
function deliverDetachedResult(result: DetachedResult) {
|
|
592
|
+
try {
|
|
593
|
+
if (result.record) pi.appendEntry("subagents:record", result.record);
|
|
594
|
+
const details = result.details as NotificationDetails;
|
|
595
|
+
outbox.add({
|
|
596
|
+
ids: [details.id, ...(details.others?.map(other => other.id) ?? [])],
|
|
597
|
+
build: () => ({ content: result.content, details }),
|
|
598
|
+
});
|
|
599
|
+
} catch { /* session already gone again; the transcript file still has the result */ }
|
|
600
|
+
}
|
|
601
|
+
|
|
602
|
+
let unwatchDetached: (() => void) | undefined;
|
|
603
|
+
|
|
604
|
+
// Background completion: route through group join or send individual nudge
|
|
605
|
+
const manager = new AgentManager((record) => {
|
|
606
|
+
// Owned children — nested, or a workflow's — report only through their
|
|
607
|
+
// owner: the parent's scoped tools, or the workflow's card, notification
|
|
608
|
+
// and dialog. Keep them out of top-level lifecycle, transcript,
|
|
609
|
+
// notification, and UI channels.
|
|
610
|
+
if (!isTopLevelAgent(record)) return;
|
|
611
|
+
|
|
612
|
+
// Orphaned by a session switch: this closure's `pi` is stale, so hand the
|
|
613
|
+
// result to the session it belongs to instead of announcing it here.
|
|
614
|
+
if (detachedSessionFile !== undefined) {
|
|
615
|
+
unregisterRunningDetached(detachedSessionFile, record.id);
|
|
616
|
+
spoolDetachedResult(detachedSessionFile, record);
|
|
617
|
+
return;
|
|
618
|
+
}
|
|
619
|
+
|
|
620
|
+
// Emit lifecycle event based on terminal status
|
|
621
|
+
const isError = record.status === "error" || record.status === "stopped" || record.status === "aborted";
|
|
622
|
+
const eventData = buildEventData(record);
|
|
623
|
+
if (isError) {
|
|
624
|
+
pi.events.emit("subagents:failed", eventData);
|
|
625
|
+
} else {
|
|
626
|
+
pi.events.emit("subagents:completed", eventData);
|
|
627
|
+
}
|
|
628
|
+
|
|
629
|
+
// Persist final record for cross-extension history reconstruction
|
|
630
|
+
pi.appendEntry("subagents:record", {
|
|
631
|
+
id: record.id, type: record.type, description: record.description,
|
|
632
|
+
status: record.status, result: record.result, error: record.error,
|
|
633
|
+
startedAt: record.startedAt, completedAt: record.completedAt,
|
|
634
|
+
});
|
|
635
|
+
|
|
636
|
+
// Skip notification if result was already consumed via get_subagent_result
|
|
637
|
+
if (record.resultConsumed) {
|
|
638
|
+
agentActivity.delete(record.id);
|
|
639
|
+
widget.markFinished(record.id);
|
|
640
|
+
fleet.onAgentFinished(record.id);
|
|
641
|
+
widget.update();
|
|
642
|
+
return;
|
|
643
|
+
}
|
|
644
|
+
|
|
645
|
+
// A `group` spawn whose turn has not ended yet: its group is registered at
|
|
646
|
+
// that turn's end, which picks this completion up retroactively.
|
|
647
|
+
if (turnGroupSpawns.includes(record.id)) {
|
|
648
|
+
widget.update();
|
|
649
|
+
return;
|
|
650
|
+
}
|
|
651
|
+
|
|
652
|
+
const result = groupJoin.onAgentComplete(record);
|
|
653
|
+
if (result === 'pass') {
|
|
654
|
+
sendIndividualNudge(record);
|
|
655
|
+
}
|
|
656
|
+
// 'held' → do nothing, group will fire later
|
|
657
|
+
// 'delivered' → group callback already fired
|
|
658
|
+
widget.update();
|
|
659
|
+
}, undefined, (record) => {
|
|
660
|
+
if (!isTopLevelAgent(record)) return;
|
|
661
|
+
// Agent-tool spawns refresh these surfaces in their tool handler, but RPC
|
|
662
|
+
// and scheduler spawns enter through the manager directly.
|
|
663
|
+
if (currentCtx?.hasUI) {
|
|
664
|
+
widget.ensureTimer();
|
|
665
|
+
widget.update();
|
|
666
|
+
fleet.ensureTimer();
|
|
667
|
+
fleet.update();
|
|
668
|
+
}
|
|
669
|
+
// Emit started event when agent transitions to running (including from queue)
|
|
670
|
+
pi.events.emit("subagents:started", {
|
|
671
|
+
id: record.id,
|
|
672
|
+
type: record.type,
|
|
673
|
+
description: record.description,
|
|
674
|
+
});
|
|
675
|
+
}, (record, info) => {
|
|
676
|
+
if (!isTopLevelAgent(record)) return;
|
|
677
|
+
// Emit compacted event when agent's session compacts (preserves count on record).
|
|
678
|
+
pi.events.emit("subagents:compacted", {
|
|
679
|
+
id: record.id,
|
|
680
|
+
type: record.type,
|
|
681
|
+
description: record.description,
|
|
682
|
+
reason: info.reason,
|
|
683
|
+
tokensBefore: info.tokensBefore,
|
|
684
|
+
compactionCount: record.compactionCount,
|
|
685
|
+
});
|
|
686
|
+
}, (record, usage) => {
|
|
687
|
+
// Every assistant message from every agent — nested included, exactly once.
|
|
688
|
+
// Parked here until a tool result can carry it back to the parent session;
|
|
689
|
+
// see `PendingUsagePool`. Skipped entirely when the feature is off, so no
|
|
690
|
+
// pool grows in a session that will never drain it.
|
|
691
|
+
if (reportUsage) pendingUsage.add(usage);
|
|
692
|
+
// The same message, live, for anyone keeping their own ledger — a footer
|
|
693
|
+
// that breaks spend down by agent type and thinking level, say. This is the
|
|
694
|
+
// one place every agent's spend passes through, which is why it is emitted
|
|
695
|
+
// here and not from the lifecycle events: those fire for top-level agents
|
|
696
|
+
// only, once, at the end, so a workflow's children and nested agents would
|
|
697
|
+
// never be counted, and a running agent would read as free until it
|
|
698
|
+
// stopped. Deliberately ungated by `reportUsage` — that decides whether the
|
|
699
|
+
// parent session's own totals include this spend, not whether it happened.
|
|
700
|
+
//
|
|
701
|
+
// `model` and `thinking` are read off the record per message, not captured
|
|
702
|
+
// at spawn: the record is authoritative only once the session exists and pi
|
|
703
|
+
// has clamped the level to the model (see `onSessionCreated`).
|
|
704
|
+
const reported = toReportedUsage(usage);
|
|
705
|
+
if (!reported) return;
|
|
706
|
+
// A key whose value is unknown is omitted, never `undefined`: `model` and
|
|
707
|
+
// `thinking` until the session has reported them, `parentAgentId` and
|
|
708
|
+
// `workflowId` on a top-level agent.
|
|
709
|
+
const invocation = record.invocation;
|
|
710
|
+
pi.events.emit("subagents:usage", {
|
|
711
|
+
id: record.id,
|
|
712
|
+
type: record.type,
|
|
713
|
+
description: record.description,
|
|
714
|
+
...(invocation?.modelId !== undefined ? { model: invocation.modelId } : {}),
|
|
715
|
+
...(invocation?.thinking !== undefined ? { thinking: invocation.thinking } : {}),
|
|
716
|
+
// Set only when the caller did not get the level it asked for (pi
|
|
717
|
+
// clamped it to the model, or an agent file outranked it), so a ledger
|
|
718
|
+
// can show `low (asked minimal)` instead of silently relabelling.
|
|
719
|
+
...(invocation?.requestedThinking !== undefined ? { requestedThinking: invocation.requestedThinking } : {}),
|
|
720
|
+
depth: record.depth ?? 1,
|
|
721
|
+
...(record.parentAgentId !== undefined ? { parentAgentId: record.parentAgentId } : {}),
|
|
722
|
+
...(record.workflowId !== undefined ? { workflowId: record.workflowId } : {}),
|
|
723
|
+
usage: reported,
|
|
724
|
+
});
|
|
725
|
+
}, (record, message) => {
|
|
726
|
+
if (!isTopLevelAgent(record)) return;
|
|
727
|
+
pi.events.emit("subagents:steered", { id: record.id, message });
|
|
728
|
+
}, clock);
|
|
729
|
+
|
|
730
|
+
// Expose manager via Symbol.for() global registry for cross-package access.
|
|
731
|
+
// Standard Node.js pattern for cross-package singletons (used by OpenTelemetry, etc.).
|
|
732
|
+
// Documented for callers in docs/rpc.md ("The manager registry").
|
|
733
|
+
//
|
|
734
|
+
// Claim the slot only if it's free: subagent sessions re-activate this
|
|
735
|
+
// extension in the same process (session.bindExtensions in agent-runner.ts),
|
|
736
|
+
// and unconditionally overwriting would point the registry at a short-lived
|
|
737
|
+
// child manager — and the child's shutdown would then delete the root
|
|
738
|
+
// session's entry. The first activation (the root session) wins; child
|
|
739
|
+
// activations leave it alone.
|
|
740
|
+
const MANAGER_KEY = Symbol.for("pi-subagents:manager");
|
|
741
|
+
// Process-external callers may supply arbitrary options. Nested ownership and
|
|
742
|
+
// config-root metadata are internal capabilities issued only by scoped tools.
|
|
743
|
+
/**
|
|
744
|
+
* Resolve the agent type and spawn. Trusts its options — every caller must
|
|
745
|
+
* either be in-process or have gone through `spawnTopLevel` first.
|
|
746
|
+
*/
|
|
747
|
+
const spawnResolved = (piRef: any, ctxRef: any, type: string, prompt: string, options: any) => {
|
|
748
|
+
// Cross-extension callers get the same dispatch contract as the LLM (#183).
|
|
749
|
+
// The RPC layer already throws for an unresolvable model rather than falling
|
|
750
|
+
// back silently; a bad agent type should not be quieter. Throws become error
|
|
751
|
+
// envelopes at the RPC boundary. Reload first so an agent file added mid
|
|
752
|
+
// session is spawnable here too, not only through the Agent tool.
|
|
753
|
+
reloadCustomAgents();
|
|
754
|
+
const dispatch = resolveSpawnType(type);
|
|
755
|
+
if (!dispatch.ok) throw new Error(dispatch.message);
|
|
756
|
+
// Every programmatic spawn lands here — cross-extension RPC, both `@handle`
|
|
757
|
+
// mention paths, and the `Symbol.for("pi-subagents:manager")` registry — and
|
|
758
|
+
// none came through the Agent tool, which is where the UI activity tracker is
|
|
759
|
+
// otherwise created. Without one the widget and FleetView have no tool name
|
|
760
|
+
// and no turn count, so the row reads `thinking…` for the agent's whole life
|
|
761
|
+
// while the header's tool-use count climbs beside it (#181). Double-tracking
|
|
762
|
+
// is not possible: the Agent tool calls `manager.spawn` directly. The tracker
|
|
763
|
+
// callbacks are the funnel's own — a caller's are not honoured, since a
|
|
764
|
+
// half-wired tracker renders worse than none.
|
|
765
|
+
//
|
|
766
|
+
// The turn limit is resolved rather than read off `options`, which a mention
|
|
767
|
+
// spawn deliberately omits so the agent's own config can decide: a tracker
|
|
768
|
+
// built with `undefined` renders `↻3` where the Agent tool renders `↻3≤20`.
|
|
769
|
+
// Like the tool's own, it is a prediction — editing the agent file mid-run
|
|
770
|
+
// leaves the displayed ceiling stale.
|
|
771
|
+
const { state, callbacks } = createActivityTracker(resolveEffectiveMaxTurns(dispatch.type, options?.maxTurns), undefined, clock);
|
|
772
|
+
// Repaints are left to the manager's `onStart` callback, which already starts
|
|
773
|
+
// the widget/fleet timers for agents that enter this way.
|
|
774
|
+
const id = manager.spawn(piRef, ctxRef, dispatch.type, prompt, { ...options, ...callbacks });
|
|
775
|
+
agentActivity.set(id, state);
|
|
776
|
+
return id;
|
|
777
|
+
};
|
|
778
|
+
|
|
779
|
+
const spawnTopLevel = (piRef: any, ctxRef: any, type: string, prompt: string, options: any) => {
|
|
780
|
+
const safeOptions = { ...(options ?? {}) };
|
|
781
|
+
delete safeOptions.parentAgentId;
|
|
782
|
+
// Internal too: a forged value would hide an RPC-spawned agent inside
|
|
783
|
+
// someone else's workflow, and take it out of the concurrency pool with it.
|
|
784
|
+
delete safeOptions.workflowId;
|
|
785
|
+
delete safeOptions.depth;
|
|
786
|
+
delete safeOptions.maxSubagentDepth;
|
|
787
|
+
delete safeOptions.configCwd;
|
|
788
|
+
delete safeOptions.configProjectTrusted;
|
|
789
|
+
// Also internal: it names a transcript directory, so a forged value would
|
|
790
|
+
// be a path-traversal primitive.
|
|
791
|
+
delete safeOptions.rootSessionId;
|
|
792
|
+
// Worse than rootSessionId: this one names a file to OPEN and replay as a
|
|
793
|
+
// conversation. Only the mention dispatcher may set it, and only from a
|
|
794
|
+
// path this extension itself recorded — never from anything a caller sent.
|
|
795
|
+
delete safeOptions.resumeSessionFile;
|
|
796
|
+
// Bypasses handle allocation, so a forged value would duplicate a live
|
|
797
|
+
// agent's name and make `@handle` ambiguous. Same rule: dispatcher only.
|
|
798
|
+
delete safeOptions.reclaim;
|
|
799
|
+
// Every spawn through here is DETACHED — the caller gets an id back and
|
|
800
|
+
// awaits nothing. A forged `blocking` would charge it to the foreground
|
|
801
|
+
// pool and could defer it behind a queue whose gate nobody is holding.
|
|
802
|
+
delete safeOptions.blocking;
|
|
803
|
+
return spawnResolved(piRef, ctxRef, type, prompt, safeOptions);
|
|
804
|
+
};
|
|
805
|
+
|
|
806
|
+
/**
|
|
807
|
+
* Resolve a tool's `agent_id` as an id OR a handle, so the model addresses
|
|
808
|
+
* agents by the same names the user types. Ids are tried first, keeping the
|
|
809
|
+
* existing behaviour exact — a handle is only consulted when the string is
|
|
810
|
+
* not an id at all. Only live records: a tombstone has nothing to steer and
|
|
811
|
+
* no result to read. Callers still enforce the nested-ownership rejection.
|
|
812
|
+
*/
|
|
813
|
+
const resolveAgentRef = (ref: string): AgentRecord | undefined => {
|
|
814
|
+
const byId = manager.getRecord(ref);
|
|
815
|
+
if (byId) return byId;
|
|
816
|
+
const resolved = manager.resolveMention(ref);
|
|
817
|
+
return resolved?.kind === "live" ? resolved.record : undefined;
|
|
818
|
+
};
|
|
819
|
+
|
|
820
|
+
const registryEntry = {
|
|
821
|
+
waitForAll: () => manager.waitForAll(),
|
|
822
|
+
hasRunning: () => manager.hasRunning(),
|
|
823
|
+
spawn: spawnTopLevel,
|
|
824
|
+
getRecord: (id: string) => {
|
|
825
|
+
const record = manager.getRecord(id);
|
|
826
|
+
return record !== undefined && isTopLevelAgent(record) ? record : undefined;
|
|
827
|
+
},
|
|
828
|
+
};
|
|
829
|
+
const ownsManagerRegistry = (globalThis as any)[MANAGER_KEY] === undefined;
|
|
830
|
+
if (ownsManagerRegistry) {
|
|
831
|
+
(globalThis as any)[MANAGER_KEY] = registryEntry;
|
|
832
|
+
}
|
|
833
|
+
|
|
834
|
+
// --- Cross-extension RPC via pi.events ---
|
|
835
|
+
let currentCtx: ExtensionContext | undefined;
|
|
836
|
+
// RPC handlers + the `subagents:ready` broadcast are wired on `session_start`
|
|
837
|
+
// (a bound lifecycle event), not at factory time. pi runs every extension
|
|
838
|
+
// factory before the `extensions:` filter and only fires lifecycle events for
|
|
839
|
+
// survivors, so a child session that filtered pi-subagents out never reaches
|
|
840
|
+
// session_start — and must not advertise or answer RPC it can't service
|
|
841
|
+
// (currentCtx would stay undefined → spawn always "No active session"). Gating
|
|
842
|
+
// here makes a filtered session behave like an absent one (#142).
|
|
843
|
+
let rpcHandle: RpcHandle | undefined;
|
|
844
|
+
/** Whether the `@handle` autocomplete wrapper has been stacked on pi's provider. */
|
|
845
|
+
let mentionProviderRegistered = false;
|
|
846
|
+
|
|
847
|
+
// ---- Subagent scheduler ----
|
|
848
|
+
// Session-scoped: store is constructed inside session_start once sessionId
|
|
849
|
+
// is available. Mirrors pi-chonky-tasks's session-scoped task store —
|
|
850
|
+
// schedules reset on /new, restore on /resume.
|
|
851
|
+
const scheduler = new SubagentScheduler({ clock: options.clock });
|
|
852
|
+
|
|
853
|
+
function startScheduler(ctx: ExtensionContext) {
|
|
854
|
+
try {
|
|
855
|
+
const sessionId = ctx.sessionManager?.getSessionId?.();
|
|
856
|
+
if (!sessionId) return; // sessionId not yet available — try again on next event
|
|
857
|
+
const path = resolveStorePath(ctx.cwd, sessionId);
|
|
858
|
+
const store = new ScheduleStore(path);
|
|
859
|
+
scheduler.start(pi, ctx, manager, store);
|
|
860
|
+
pi.events.emit("subagents:scheduler_ready", { sessionId, jobCount: store.list().length });
|
|
861
|
+
} catch (err) {
|
|
862
|
+
// Scheduling is non-essential — log and move on so the rest of the
|
|
863
|
+
// extension keeps working if e.g. .pi/ is unwritable.
|
|
864
|
+
console.warn("[pi-subagents] Failed to start scheduler:", err);
|
|
865
|
+
}
|
|
866
|
+
}
|
|
867
|
+
|
|
868
|
+
// Capture ctx from session_start for RPC spawn handler + start the scheduler.
|
|
869
|
+
// This also wires the RPC handlers and broadcasts readiness — on the first
|
|
870
|
+
// bound session_start, so a filtered-out activation never advertises (#142).
|
|
871
|
+
pi.on("session_start", async (_event, ctx) => {
|
|
872
|
+
currentCtx = ctx;
|
|
873
|
+
// Pi resolves project trust before the first session starts, so this is the
|
|
874
|
+
// earliest honest answer. The activation-time load defaulted to untrusted,
|
|
875
|
+
// so a trusted project's `packages[]` only becomes visible now.
|
|
876
|
+
setProjectTrusted(ctx.isProjectTrusted?.() === true);
|
|
877
|
+
// Unconditional, and not folded into the call above: `/reload` and a new or
|
|
878
|
+
// resumed session all land here, and any of them may follow a `pi install`
|
|
879
|
+
// that added a package the cached root list predates.
|
|
880
|
+
invalidatePackageCache();
|
|
881
|
+
// The first load with the canonical trust answer is strict. Latched until it
|
|
882
|
+
// succeeds, so a retried bind is strict again.
|
|
883
|
+
const strictLoad = strictAgentFiles && !canonicalAgentsLoaded;
|
|
884
|
+
try {
|
|
885
|
+
reloadCustomAgents(strictLoad);
|
|
886
|
+
} catch (err) {
|
|
887
|
+
if (strictLoad && StartupError) {
|
|
888
|
+
throw new StartupError(err instanceof Error ? err.message : String(err), { cause: err });
|
|
889
|
+
}
|
|
890
|
+
throw err;
|
|
891
|
+
}
|
|
892
|
+
canonicalAgentsLoaded = true;
|
|
893
|
+
// The type list in the `Agent` description and parameters was built from the
|
|
894
|
+
// activation load; rebuild it from the canonical one before any provider turn.
|
|
895
|
+
refreshAgentTool();
|
|
896
|
+
if (ctx.hasUI) {
|
|
897
|
+
widget.setUICtx(ctx.ui);
|
|
898
|
+
fleet.setUICtx(ctx.ui as any);
|
|
899
|
+
watchHandoffKey(ctx.ui);
|
|
900
|
+
}
|
|
901
|
+
manager.clearCompleted(true);
|
|
902
|
+
const sessionFile = ctx.sessionManager?.getSessionFile?.();
|
|
903
|
+
if (sessionFile) {
|
|
904
|
+
unwatchDetached?.();
|
|
905
|
+
unwatchDetached = watchDetached(sessionFile, () => deliverDetached(sessionFile));
|
|
906
|
+
deliverDetached(sessionFile);
|
|
907
|
+
}
|
|
908
|
+
// Orphans from the previous instance are already registered; show them now.
|
|
909
|
+
fleet.update();
|
|
910
|
+
// Guard mirrors the `!scheduler.isActive()` pattern below: session_start
|
|
911
|
+
// fires once per activation, but a double-bind must not leak listeners.
|
|
912
|
+
if (!rpcHandle) {
|
|
913
|
+
rpcHandle = registerRpcHandlers({
|
|
914
|
+
events: pi.events,
|
|
915
|
+
pi,
|
|
916
|
+
getCtx: () => currentCtx,
|
|
917
|
+
manager: {
|
|
918
|
+
spawn: spawnTopLevel,
|
|
919
|
+
awaitStartup: (id) => manager.awaitStartup(id),
|
|
920
|
+
getRecord: (id) => manager.getRecord(id),
|
|
921
|
+
// Unguarded on purpose: the stop handler now runs the top-level check
|
|
922
|
+
// itself off `getRecord`, and reports the refusal instead of the
|
|
923
|
+
// "Agent not found" a false from here used to be read as.
|
|
924
|
+
abort: (id) => manager.abort(id),
|
|
925
|
+
consumeResult: (id) => {
|
|
926
|
+
const record = resolveAgentRef(id);
|
|
927
|
+
// Same guard as get_subagent_result: a running agent has no result
|
|
928
|
+
// to consume, and its notification is still the caller's only
|
|
929
|
+
// signal that it finished.
|
|
930
|
+
if (!record || record.parentAgentId) return false;
|
|
931
|
+
if (record.status === "running" || record.status === "queued") return false;
|
|
932
|
+
markConsumed(record, clock.now());
|
|
933
|
+
outbox.cancel(record.id);
|
|
934
|
+
return true;
|
|
935
|
+
},
|
|
936
|
+
steer: (id, message) => manager.steer(id, message),
|
|
937
|
+
},
|
|
938
|
+
});
|
|
939
|
+
// Broadcast readiness so extensions loaded alongside us can discover us.
|
|
940
|
+
// Emitting after all factories have run (rather than at factory time)
|
|
941
|
+
// also avoids the race where a consumer loaded after us misses the event.
|
|
942
|
+
pi.events.emit("subagents:ready", {});
|
|
943
|
+
}
|
|
944
|
+
if (isSchedulingEnabled() && !scheduler.isActive()) startScheduler(ctx);
|
|
945
|
+
// Stack `@handle` suggestions on pi's built-in autocomplete. Registered at
|
|
946
|
+
// most once per activation: pi appends wrappers to a list it never prunes,
|
|
947
|
+
// so a second call would layer a duplicate provider on the first. TUI only
|
|
948
|
+
// — print mode has no such method, and RPC mode's is a no-op.
|
|
949
|
+
if (ctx.mode === "tui" && !mentionProviderRegistered) {
|
|
950
|
+
mentionProviderRegistered = true;
|
|
951
|
+
ctx.ui.addAutocompleteProvider(current =>
|
|
952
|
+
createMentionProvider(
|
|
953
|
+
current,
|
|
954
|
+
// Plain text, not renderAgentName: the same label FleetView and the
|
|
955
|
+
// widget show, but the autocomplete description cannot carry ANSI.
|
|
956
|
+
() => mentionRoster(manager, mentionTypes(), type => getConfig(type).displayName),
|
|
957
|
+
isAgentMentionsEnabled,
|
|
958
|
+
),
|
|
959
|
+
);
|
|
960
|
+
}
|
|
961
|
+
// Last, and only here: CLI flag values are applied by the host AFTER every
|
|
962
|
+
// extension factory has run, so this is the earliest point the real value
|
|
963
|
+
// exists. Detached inside — a workflow must not hold up session startup.
|
|
964
|
+
resolveWorkflowCollisions(ctx);
|
|
965
|
+
runWorkflowFlag(ctx);
|
|
966
|
+
});
|
|
967
|
+
|
|
968
|
+
/** Agent types `@` can start, in the shape the roster wants. */
|
|
969
|
+
const mentionTypes = (): TypeInfo[] =>
|
|
970
|
+
getAvailableTypes().map(name => ({ name, description: getAgentConfig(name)?.description ?? name }));
|
|
971
|
+
|
|
972
|
+
/**
|
|
973
|
+
* `@handle message` typed at the prompt addresses that agent instead of the
|
|
974
|
+
* main model — Claude Code's prompt mention, same grammar (see mention.ts).
|
|
975
|
+
*
|
|
976
|
+
* The handle names the *agent*, not one process, so one syntax covers its
|
|
977
|
+
* whole lifecycle: message it while it runs, resume it once it has finished,
|
|
978
|
+
* start it if it never ran. Everything that isn't an agent mention falls
|
|
979
|
+
* through untouched, which is what keeps `@src/foo.ts summarize this`, a bare
|
|
980
|
+
* `@handle`, and ordinary prose working. A delivered mention costs no
|
|
981
|
+
* main-model turn; the answer arrives through the ordinary completion
|
|
982
|
+
* notification either way.
|
|
983
|
+
*/
|
|
984
|
+
pi.on("input", async (event, ctx) => {
|
|
985
|
+
// Never hijack text the extension layer itself submitted (pi.sendMessage,
|
|
986
|
+
// scheduled prompts) — only something a person typed can be a mention.
|
|
987
|
+
if (event.source === "extension" || !isAgentMentionsEnabled()) return { action: "continue" };
|
|
988
|
+
// Claiming the turn is TUI only, matching the `@` completion that teaches
|
|
989
|
+
// the syntax. Pi defaults `session.prompt()` to source "interactive", so a
|
|
990
|
+
// headless `pi -p "@explore …"` reaches here too — and claiming it would
|
|
991
|
+
// answer with silence, which the background hold cannot fix: `handled`
|
|
992
|
+
// returns from prompt() before any turn starts, so the loop that patch wraps
|
|
993
|
+
// never runs (it holds subagents spawned by the Agent tool MID-turn, a
|
|
994
|
+
// different path). The agent would detach, `ctx.ui.notify` is a no-op
|
|
995
|
+
// outside the TUI, and print mode would exit having printed nothing.
|
|
996
|
+
//
|
|
997
|
+
// `model` mode has none of that problem: it queues a reminder and lets the
|
|
998
|
+
// turn run, so the answer is the model's own, printed as usual. It is the
|
|
999
|
+
// only branch allowed to act headlessly; everything else falls through to
|
|
1000
|
+
// the main model exactly as it did before mentions existed.
|
|
1001
|
+
const canDispatchDirectly = ctx.mode === "tui";
|
|
1002
|
+
if (!canDispatchDirectly && getAgentMentionMode() !== "model") return { action: "continue" };
|
|
1003
|
+
|
|
1004
|
+
const mention = parseMention(event.text);
|
|
1005
|
+
if (!mention) return { action: "continue" };
|
|
1006
|
+
|
|
1007
|
+
// `@main` addresses the main conversation, never a subagent — the one name
|
|
1008
|
+
// `assignHandle` refuses to allocate. An explicit escape hatch for text
|
|
1009
|
+
// that would otherwise read as a mention, so the prefix is dropped and the
|
|
1010
|
+
// rest goes to the model with its attachments intact.
|
|
1011
|
+
if (isReservedHandle(mention.handle)) {
|
|
1012
|
+
return { action: "transform", text: mention.message, ...(event.images && { images: event.images }) };
|
|
1013
|
+
}
|
|
1014
|
+
|
|
1015
|
+
// As typed first, so an agent actually called `agent-foo` wins over Claude
|
|
1016
|
+
// Code's `@agent-` + `foo` spelling rather than being shadowed by it.
|
|
1017
|
+
const alias = stripAgentPrefix(mention.handle);
|
|
1018
|
+
const resolved = manager.resolveMention(mention.handle)
|
|
1019
|
+
?? (alias ? manager.resolveMention(alias) : undefined);
|
|
1020
|
+
|
|
1021
|
+
// Steering and resuming are direct in every mode, so headless they are not
|
|
1022
|
+
// available at all. Falling through here rather than dropping to the start
|
|
1023
|
+
// path below matters: the handle names an agent that already exists, and
|
|
1024
|
+
// asking the model to start another one is not what was typed.
|
|
1025
|
+
if (resolved && !canDispatchDirectly) return { action: "continue" };
|
|
1026
|
+
|
|
1027
|
+
if (resolved?.kind === "live") {
|
|
1028
|
+
const record = resolved.record;
|
|
1029
|
+
const target = `@${record.alias ?? record.handle ?? mention.handle}`;
|
|
1030
|
+
|
|
1031
|
+
if (record.status === "running" || record.status === "queued") {
|
|
1032
|
+
// Steering interrupts after the current tool call, exactly like the
|
|
1033
|
+
// steer_subagent tool. Un-consume the result so the agent's reply to
|
|
1034
|
+
// this message is still relayed even if the LLM read its last answer.
|
|
1035
|
+
record.resultConsumed = false;
|
|
1036
|
+
record.consumedAt = undefined;
|
|
1037
|
+
manager.steer(record.id, mention.message);
|
|
1038
|
+
ctx.ui.notify(`Sent to ${target}`, "info");
|
|
1039
|
+
return { action: "handled" };
|
|
1040
|
+
}
|
|
1041
|
+
|
|
1042
|
+
if (record.session) {
|
|
1043
|
+
// Both derived from the record's OWN type: a mention names an existing
|
|
1044
|
+
// agent, so its frontmatter is what governs — `output_transcript: false`
|
|
1045
|
+
// must keep holding, since record.outputFile is the sole gate every
|
|
1046
|
+
// downstream consumer keys off and a resume must not re-open it.
|
|
1047
|
+
const resumedRecord = await startBackgroundResume(ctx, record, mention.message, {
|
|
1048
|
+
outputTranscript: getAgentConfig(record.type)?.outputTranscript ?? getOutputTranscriptDefault(),
|
|
1049
|
+
maxTurns: resumeTurnLimit(record),
|
|
1050
|
+
});
|
|
1051
|
+
ctx.ui.notify(
|
|
1052
|
+
resumedRecord ? `Resuming ${target}` : `Could not resume ${target} — it is still running.`,
|
|
1053
|
+
resumedRecord ? "info" : "warning",
|
|
1054
|
+
);
|
|
1055
|
+
return { action: "handled" };
|
|
1056
|
+
}
|
|
1057
|
+
// A live record with no session never got far enough to continue, so it
|
|
1058
|
+
// falls through to the start-fresh path below, like Claude's
|
|
1059
|
+
// `no_transcript`.
|
|
1060
|
+
}
|
|
1061
|
+
|
|
1062
|
+
// Evicted, but its conversation is still on disk: reopen it. This is an
|
|
1063
|
+
// ordinary spawn carrying a session file, so the new record picks up the
|
|
1064
|
+
// widget, fleet row, transcript and completion notification unchanged —
|
|
1065
|
+
// and `reclaim` hands it back the names the tombstone was holding.
|
|
1066
|
+
if (resolved?.kind === "tombstone") {
|
|
1067
|
+
const entry = resolved.entry;
|
|
1068
|
+
const target = `@${entry.alias ?? entry.handle}`;
|
|
1069
|
+
|
|
1070
|
+
// Checked here rather than left to SessionManager.open: that runs inside
|
|
1071
|
+
// runAgent, whose rejection lands on the record as an agent error, not in
|
|
1072
|
+
// the catch below. A `/new` in another pi window or a manual delete makes
|
|
1073
|
+
// the conversation unrecoverable (Claude Code's `not_reachable`), so drop
|
|
1074
|
+
// the entry — a row that can only ever fail is worse than none — and say
|
|
1075
|
+
// so rather than quietly sending this message to an unrelated agent.
|
|
1076
|
+
if (!existsSync(entry.sessionFile)) {
|
|
1077
|
+
manager.dropTombstone(entry.handle);
|
|
1078
|
+
ctx.ui.notify(`Could not resume ${target} — its session is gone.`, "warning");
|
|
1079
|
+
return { action: "handled" };
|
|
1080
|
+
}
|
|
1081
|
+
|
|
1082
|
+
// The Agent tool deliberately falls back to general-purpose for a type it
|
|
1083
|
+
// cannot resolve (#183), which covers a deleted file AND a merely
|
|
1084
|
+
// disabled one. A resume must not inherit that: reopening this
|
|
1085
|
+
// conversation under a different agent's prompt and tools is not
|
|
1086
|
+
// continuing it, and the new record would re-tombstone under the
|
|
1087
|
+
// substitute, so the handle would never find its way back.
|
|
1088
|
+
reloadCustomAgents();
|
|
1089
|
+
const dispatch = resolveSpawnType(entry.type);
|
|
1090
|
+
if (!dispatch.ok || dispatch.fellBackFrom !== undefined) {
|
|
1091
|
+
// The tombstone stays: re-enabling the agent makes the handle work
|
|
1092
|
+
// again, which a drop would foreclose.
|
|
1093
|
+
ctx.ui.notify(`Could not resume ${target} — the ${entry.type} agent is no longer available.`, "warning");
|
|
1094
|
+
return { action: "handled" };
|
|
1095
|
+
}
|
|
1096
|
+
|
|
1097
|
+
try {
|
|
1098
|
+
// spawnResolved, not spawnTopLevel: the latter strips
|
|
1099
|
+
// `resumeSessionFile` and `reclaim` as untrusted. This path is the
|
|
1100
|
+
// exception — both come from a tombstone this extension wrote.
|
|
1101
|
+
const id = spawnResolved(pi, ctx, dispatch.type, mention.message, {
|
|
1102
|
+
description: entry.description,
|
|
1103
|
+
reclaim: { handle: entry.handle, alias: entry.alias },
|
|
1104
|
+
resumeSessionFile: entry.sessionFile,
|
|
1105
|
+
isBackground: true,
|
|
1106
|
+
});
|
|
1107
|
+
// The agent may still be starting — wait, so a startup failure lands in
|
|
1108
|
+
// the catch below instead of being announced as a resume.
|
|
1109
|
+
await manager.awaitStartup(id);
|
|
1110
|
+
// The tombstone deliberately stays. `resolveMention` prefers the live
|
|
1111
|
+
// record holding these same names, so it cannot shadow the resume — and
|
|
1112
|
+
// if this run dies before establishing its own session, the original
|
|
1113
|
+
// transcript is still the right thing for the next mention to reopen.
|
|
1114
|
+
// Once the resumed record is evicted it overwrites this entry in place,
|
|
1115
|
+
// keyed by the same handle, so nothing accumulates.
|
|
1116
|
+
ctx.ui.notify(`Resuming ${target}`, "info");
|
|
1117
|
+
} catch (err) {
|
|
1118
|
+
// The type is already settled above, so what is left is a spawn-time
|
|
1119
|
+
// failure: a strict worktree-isolation error, an unusable cwd.
|
|
1120
|
+
ctx.ui.notify(
|
|
1121
|
+
`Could not resume ${target}: ${err instanceof Error ? err.message : String(err)}`,
|
|
1122
|
+
"warning",
|
|
1123
|
+
);
|
|
1124
|
+
}
|
|
1125
|
+
return { action: "handled" };
|
|
1126
|
+
}
|
|
1127
|
+
|
|
1128
|
+
// No agent under that handle — but the name may still be an agent type, in
|
|
1129
|
+
// which case the mention starts one.
|
|
1130
|
+
const typeHandle = mention.handle;
|
|
1131
|
+
const type = resolveHandleToType(typeHandle, getAvailableTypes())
|
|
1132
|
+
?? (alias ? resolveHandleToType(alias, getAvailableTypes()) : undefined);
|
|
1133
|
+
if (!type) return { action: "continue" };
|
|
1134
|
+
|
|
1135
|
+
// Claude Code never starts the agent itself: `@agent-<type>` becomes an
|
|
1136
|
+
// attachment asking the main model to do it, and the model writes the
|
|
1137
|
+
// agent's prompt from the conversation rather than forwarding the typed
|
|
1138
|
+
// text. That buys a real `Agent` tool call — transcript, per-tool widget
|
|
1139
|
+
// detail, tool-use-id correlation, join grouping — and a prompt with the
|
|
1140
|
+
// context a cold spawn lacks.
|
|
1141
|
+
//
|
|
1142
|
+
// It also costs a visible turn, spent narrating a decision the user already
|
|
1143
|
+
// made by typing the handle. So the turn is taken by a clone of this
|
|
1144
|
+
// conversation instead (mention-clone.ts): same messages, same system
|
|
1145
|
+
// prompt, off-screen, holding only the `Agent` tool. Nothing reaches the
|
|
1146
|
+
// chat, and what it starts is an ordinary top-level agent.
|
|
1147
|
+
if (getAgentMentionMode() === "model") {
|
|
1148
|
+
const label = `@${handleBase(type)}`;
|
|
1149
|
+
// "Prompting", not "Starting": in this mode nothing starts until the
|
|
1150
|
+
// off-screen clone has taken a whole model turn writing the agent's
|
|
1151
|
+
// prompt, and that wait is the one thing the chat cannot show. `direct`
|
|
1152
|
+
// says "Started" because by then it has. The distinction tells the user
|
|
1153
|
+
// which of the two they are waiting on.
|
|
1154
|
+
ctx.ui.notify(`Prompting ${label}…`, "info");
|
|
1155
|
+
// Not awaited: the clone runs a full model turn, and prompt() is blocked
|
|
1156
|
+
// until this hook returns. The user gets their prompt back immediately
|
|
1157
|
+
// and the agent appears in the widget when it starts.
|
|
1158
|
+
void runMentionClone({ ctx, type, message: mention.message, agentTool: registeredAgentTool })
|
|
1159
|
+
.then(async (result) => {
|
|
1160
|
+
if (result.spawned) return;
|
|
1161
|
+
// A clone that could not run must not swallow the mention: start the
|
|
1162
|
+
// agent the direct way rather than leaving the user with a toast and
|
|
1163
|
+
// nothing running.
|
|
1164
|
+
try {
|
|
1165
|
+
const id = spawnTopLevel(pi, ctx, type, mention.message, {
|
|
1166
|
+
description: describeMention(mention.message),
|
|
1167
|
+
isBackground: true,
|
|
1168
|
+
});
|
|
1169
|
+
// Same reason as the direct path below: the agent may still be
|
|
1170
|
+
// starting, and a failure there must reach this catch.
|
|
1171
|
+
await manager.awaitStartup(id);
|
|
1172
|
+
ctx.ui.notify(`Started ${label} directly — ${result.error}`, "warning");
|
|
1173
|
+
} catch (err) {
|
|
1174
|
+
ctx.ui.notify(
|
|
1175
|
+
`Could not start ${label}: ${err instanceof Error ? err.message : String(err)}`,
|
|
1176
|
+
"error",
|
|
1177
|
+
);
|
|
1178
|
+
}
|
|
1179
|
+
});
|
|
1180
|
+
return { action: "handled" };
|
|
1181
|
+
}
|
|
1182
|
+
|
|
1183
|
+
try {
|
|
1184
|
+
// Nothing else to pass: runAgent resolves model, thinking, max turns and
|
|
1185
|
+
// `isolated` from the agent's own config when the spawn omits them, and the
|
|
1186
|
+
// manager's onStart/onComplete callbacks own the widget, the fleet list
|
|
1187
|
+
// and the completion notification — the same contract the scheduler and
|
|
1188
|
+
// cross-extension RPC spawns run under.
|
|
1189
|
+
const id = spawnTopLevel(pi, ctx, type, mention.message, {
|
|
1190
|
+
description: describeMention(mention.message),
|
|
1191
|
+
isBackground: true,
|
|
1192
|
+
});
|
|
1193
|
+
// The agent may still be starting (a worktree copy is an awaited git
|
|
1194
|
+
// call) — report a failure that lands there as a failed start, not as a
|
|
1195
|
+
// "Started" toast for an agent that never ran.
|
|
1196
|
+
await manager.awaitStartup(id);
|
|
1197
|
+
ctx.ui.notify(`Started @${handleBase(type)}`, "info");
|
|
1198
|
+
} catch (err) {
|
|
1199
|
+
ctx.ui.notify(`Could not start @${handleBase(type)}: ${err instanceof Error ? err.message : String(err)}`, "error");
|
|
1200
|
+
}
|
|
1201
|
+
return { action: "handled" };
|
|
1202
|
+
});
|
|
1203
|
+
|
|
1204
|
+
pi.on("session_before_switch", () => {
|
|
1205
|
+
// Keep unread records until shutdown can transfer their held notices.
|
|
1206
|
+
manager.clearCompleted(true);
|
|
1207
|
+
scheduler.stop();
|
|
1208
|
+
});
|
|
1209
|
+
|
|
1210
|
+
// On quit, abort all agents immediately and clean up: the process is exiting.
|
|
1211
|
+
// On /new, /resume, /fork and /reload the process lives on, so running background
|
|
1212
|
+
// agents are detached instead and hand their results back when the user returns
|
|
1213
|
+
// to this session (see detached-results.ts). Only agents that cannot outlive the
|
|
1214
|
+
// session are aborted.
|
|
1215
|
+
pi.on("session_shutdown", async (event: { reason?: string }) => {
|
|
1216
|
+
const sessionFile = currentCtx?.sessionManager?.getSessionFile?.();
|
|
1217
|
+
unwatchDetached?.();
|
|
1218
|
+
unwatchDetached = undefined;
|
|
1219
|
+
rpcHandle?.unsubSpawn();
|
|
1220
|
+
rpcHandle?.unsubStop();
|
|
1221
|
+
rpcHandle?.unsubPing();
|
|
1222
|
+
rpcHandle?.unsubConsume();
|
|
1223
|
+
rpcHandle?.unsubSteer();
|
|
1224
|
+
rpcHandle = undefined;
|
|
1225
|
+
currentCtx = undefined;
|
|
1226
|
+
// Only release the global slot if this activation claimed it — a child
|
|
1227
|
+
// session's shutdown must not delete the root session's registry entry.
|
|
1228
|
+
if (ownsManagerRegistry && (globalThis as any)[MANAGER_KEY] === registryEntry) {
|
|
1229
|
+
delete (globalThis as any)[MANAGER_KEY];
|
|
1230
|
+
}
|
|
1231
|
+
scheduler.stop();
|
|
1232
|
+
// Before abortAll, and not folded into it: a workflow owns a worker thread
|
|
1233
|
+
// as well as its children, and only its own signal terminates that.
|
|
1234
|
+
for (const task of workflowTasks.values()) task.abortController.abort();
|
|
1235
|
+
workflowTasks.clear();
|
|
1236
|
+
const switching = event?.reason !== "quit" && sessionFile !== undefined;
|
|
1237
|
+
if (switching) {
|
|
1238
|
+
// A group may still be held, or not registered until the parent's turn
|
|
1239
|
+
// ends. Release both into the outbox before transferring its snapshot.
|
|
1240
|
+
groupJoin.flushPending();
|
|
1241
|
+
for (const id of turnGroupSpawns) {
|
|
1242
|
+
const record = manager.getRecord(id);
|
|
1243
|
+
if (record && !record.resultConsumed && record.status !== "running" && record.status !== "queued") {
|
|
1244
|
+
sendIndividualNudge(record);
|
|
1245
|
+
}
|
|
1246
|
+
}
|
|
1247
|
+
const pending = outbox.takePending();
|
|
1248
|
+
if (pending) {
|
|
1249
|
+
try { spoolDetached(sessionFile, { id: nanoid(8), ...pending }); }
|
|
1250
|
+
catch { console.error("[pi-subagents] Could not spool held completion notices; results remain in their transcripts."); }
|
|
1251
|
+
}
|
|
1252
|
+
}
|
|
1253
|
+
const detaching = switching && manager.detach() > 0;
|
|
1254
|
+
if (!detaching) manager.abortAll();
|
|
1255
|
+
// Activation-owned UI and group state (#256): `session_shutdown` also runs for
|
|
1256
|
+
// reload and session replacement, where the Node process survives, so a live
|
|
1257
|
+
// widget render interval or a pending group-join timeout from this activation
|
|
1258
|
+
// would outlive it and fire into the next session's UI and group state.
|
|
1259
|
+
// `session_before_switch` deliberately keeps both: a vetoed switch continues
|
|
1260
|
+
// this session, where the live UI and group state must survive.
|
|
1261
|
+
groupJoin.dispose();
|
|
1262
|
+
widget.dispose();
|
|
1263
|
+
outbox.dispose();
|
|
1264
|
+
fleet.dispose();
|
|
1265
|
+
unsubHandoffKey?.();
|
|
1266
|
+
unsubHandoffKey = undefined;
|
|
1267
|
+
handoffKeyUi = undefined;
|
|
1268
|
+
|
|
1269
|
+
if (detaching) {
|
|
1270
|
+
// Disposal waits for the survivors; it also releases their sessions.
|
|
1271
|
+
detachedSessionFile = sessionFile;
|
|
1272
|
+
// The reloaded instance has an empty manager; publish the survivors so
|
|
1273
|
+
// its fleet list shows them while they run.
|
|
1274
|
+
for (const record of manager.listAgents()) {
|
|
1275
|
+
if (record.status !== "running" || !isTopLevelAgent(record)) continue;
|
|
1276
|
+
registerRunningDetached(sessionFile, {
|
|
1277
|
+
record,
|
|
1278
|
+
activity: agentActivity.get(record.id),
|
|
1279
|
+
abort: () => manager.abort(record.id),
|
|
1280
|
+
steer: (message) => manager.steer(record.id, message),
|
|
1281
|
+
});
|
|
1282
|
+
}
|
|
1283
|
+
void manager.waitForAll().then(() => manager.dispose(), () => {});
|
|
1284
|
+
return;
|
|
1285
|
+
}
|
|
1286
|
+
// Awaited: it emits `session_shutdown` into every retained child session so
|
|
1287
|
+
// extensions bound there can release what they armed in `session_start` (#242).
|
|
1288
|
+
// pi awaits this handler, and the process exits right after — unawaited, those
|
|
1289
|
+
// handlers would never run. Internally bounded, so a hung one can't strand quit.
|
|
1290
|
+
await manager.dispose(pi);
|
|
1291
|
+
// This manager's agents and child sessions have shut down. Other detached
|
|
1292
|
+
// activations may still emit usage; this is not a process-global barrier.
|
|
1293
|
+
// A ledger's `session_shutdown` may have run before this handler aborted the agents
|
|
1294
|
+
// (handlers run in load order) and missed their last messages; this is the
|
|
1295
|
+
// point after which a synchronous final flush is complete. Synchronous:
|
|
1296
|
+
// `emit` does not await listeners, and pi exits once this handler returns.
|
|
1297
|
+
pi.events.emit("subagents:disposed", {});
|
|
1298
|
+
});
|
|
1299
|
+
|
|
1300
|
+
// Live widget: show running agents above editor.
|
|
1301
|
+
// widgetMode (default "background") selects what the widget shows: "all" =
|
|
1302
|
+
// every agent; "background" = hide foreground (they already render inline as
|
|
1303
|
+
// the Agent tool result, so showing them here too is a duplicate, #118), keep
|
|
1304
|
+
// everything else; "off" = hide the widget entirely. Read live at render time.
|
|
1305
|
+
let widgetMode: WidgetMode = "background";
|
|
1306
|
+
function getWidgetMode(): WidgetMode { return widgetMode; }
|
|
1307
|
+
const widget = new AgentWidget(manager, agentActivity, getWidgetMode, isShowCostEnabled, isShowModelEnabled, clock);
|
|
1308
|
+
function setWidgetMode(m: WidgetMode): void { widgetMode = m; widget.update(); }
|
|
1309
|
+
|
|
1310
|
+
/**
|
|
1311
|
+
* Foreground `Agent` calls whose run can be handed to the background, by
|
|
1312
|
+
* agent id. Each entry wakes its blocked tool call, which then returns as a
|
|
1313
|
+
* background spawn would. Only the Agent tool registers here, so a spawn with
|
|
1314
|
+
* no tool call to return — the agent generator, a workflow's agents — never
|
|
1315
|
+
* offers the handoff.
|
|
1316
|
+
*/
|
|
1317
|
+
const foregroundHandoffs = new Map<string, () => void>();
|
|
1318
|
+
|
|
1319
|
+
/** Hand these runs to the background and wake their tool calls; returns how many took. */
|
|
1320
|
+
function sendForegroundToBackground(ids: Iterable<string>): number {
|
|
1321
|
+
let sent = 0;
|
|
1322
|
+
for (const id of [...ids]) {
|
|
1323
|
+
const wake = foregroundHandoffs.get(id);
|
|
1324
|
+
if (!wake || !manager.sendToBackground(id)) continue;
|
|
1325
|
+
foregroundHandoffs.delete(id);
|
|
1326
|
+
wake();
|
|
1327
|
+
sent++;
|
|
1328
|
+
// It is a background agent from here on, so announce it as one — as a
|
|
1329
|
+
// detached resume re-announces an existing id.
|
|
1330
|
+
const record = manager.getRecord(id);
|
|
1331
|
+
if (record) {
|
|
1332
|
+
pi.events.emit("subagents:created", {
|
|
1333
|
+
id,
|
|
1334
|
+
type: record.type,
|
|
1335
|
+
description: record.description,
|
|
1336
|
+
isBackground: true,
|
|
1337
|
+
});
|
|
1338
|
+
}
|
|
1339
|
+
}
|
|
1340
|
+
if (sent > 0) {
|
|
1341
|
+
widget.ensureTimer();
|
|
1342
|
+
widget.update();
|
|
1343
|
+
fleet.ensureTimer();
|
|
1344
|
+
fleet.update();
|
|
1345
|
+
}
|
|
1346
|
+
return sent;
|
|
1347
|
+
}
|
|
1348
|
+
|
|
1349
|
+
/** The conversation viewer's `b`: hand just this run to the background. */
|
|
1350
|
+
function backgroundActionFor(record: AgentRecord, notify: (message: string) => void): ViewerBackgroundAction {
|
|
1351
|
+
return {
|
|
1352
|
+
// Registered while the run is live, removed the moment its call returns.
|
|
1353
|
+
available: () => foregroundHandoffs.has(record.id),
|
|
1354
|
+
send: () => {
|
|
1355
|
+
if (sendForegroundToBackground([record.id]) > 0) notify(`Sent "${record.description}" to the background.`);
|
|
1356
|
+
},
|
|
1357
|
+
};
|
|
1358
|
+
}
|
|
1359
|
+
|
|
1360
|
+
// Claude Code's ctrl+b (`task:background`): hand every running foreground
|
|
1361
|
+
// `Agent` call to the background. Claimed only while one is pending, so the
|
|
1362
|
+
// rest of the time — and whenever the prompt editor is not focused — the key
|
|
1363
|
+
// is still pi's cursor-left.
|
|
1364
|
+
let handoffKeyUi: ExtensionContext["ui"] | undefined;
|
|
1365
|
+
let unsubHandoffKey: (() => void) | undefined;
|
|
1366
|
+
function watchHandoffKey(ui: ExtensionContext["ui"]): void {
|
|
1367
|
+
if (ui === handoffKeyUi) return;
|
|
1368
|
+
unsubHandoffKey?.();
|
|
1369
|
+
handoffKeyUi = ui;
|
|
1370
|
+
unsubHandoffKey = ui.onTerminalInput(data => {
|
|
1371
|
+
// Kitty reports a release too, and matchesKey matches it — act on the
|
|
1372
|
+
// press only, or a refused handoff would be retried by its own release.
|
|
1373
|
+
if (isKeyRelease(data) || foregroundHandoffs.size === 0 || !matchesKey(data, "ctrl+b")) return undefined;
|
|
1374
|
+
// Listeners run before the focused component, so a dialog or the
|
|
1375
|
+
// viewer's composer would lose its ctrl+b (cursor-left). The viewer has
|
|
1376
|
+
// its own `b`.
|
|
1377
|
+
if (!fleet.editorHasFocus()) return undefined;
|
|
1378
|
+
const sent = sendForegroundToBackground(foregroundHandoffs.keys());
|
|
1379
|
+
if (sent > 0) ui.notify(`Sent ${sent === 1 ? "1 agent" : `${sent} agents`} to the background.`, "info");
|
|
1380
|
+
return { consume: true };
|
|
1381
|
+
});
|
|
1382
|
+
}
|
|
1383
|
+
|
|
1384
|
+
/**
|
|
1385
|
+
* pi's fullscreen scrollbar preference, read from the main session's live,
|
|
1386
|
+
* trust-aware settings and normalized as pi's own `getFullscreenScrollbar()` does.
|
|
1387
|
+
*/
|
|
1388
|
+
function getFullscreenScrollbar(): ScrollViewScrollbar {
|
|
1389
|
+
const mode = pi.getSettings().fullscreenScrollbar;
|
|
1390
|
+
return mode === "always" || mode === "hidden" ? mode : "auto";
|
|
1391
|
+
}
|
|
1392
|
+
|
|
1393
|
+
// Claude Code-style FleetView: navigable list of main + subagents below the editor.
|
|
1394
|
+
// The trailing arguments keep a conversation overlay opened here identical to
|
|
1395
|
+
// one opened from `/agents`: same settings on the way in, same persist out.
|
|
1396
|
+
const fleet = new FleetList(manager, agentActivity, isShowCostEnabled, getViewerMarkdown,
|
|
1397
|
+
(mode) => chooseViewerMarkdown(mode, currentCtx as unknown as ExtensionCommandContext | undefined),
|
|
1398
|
+
getFullscreenScrollbar, backgroundActionFor, isShowModelEnabled,
|
|
1399
|
+
// Agents the previous instance of this session left running (/reload, /new, ...).
|
|
1400
|
+
() => listRunningDetached(currentCtx?.sessionManager?.getSessionFile?.()), clock);
|
|
1401
|
+
/** What the Agent tool row reads at render time, so its summary stays live. */
|
|
1402
|
+
const agentRowLive: AgentRowLive = {
|
|
1403
|
+
getRecord: (id) => manager.getRecord(id),
|
|
1404
|
+
getActivity: (id) => agentActivity.get(id),
|
|
1405
|
+
showCost: isShowCostEnabled,
|
|
1406
|
+
};
|
|
1407
|
+
let fleetViewEnabled = true;
|
|
1408
|
+
function isFleetViewEnabled(): boolean { return fleetViewEnabled; }
|
|
1409
|
+
function setFleetViewEnabled(b: boolean): void { fleetViewEnabled = b; fleet.setEnabled(b); }
|
|
1410
|
+
|
|
1411
|
+
// Claude Code-style `@handle message` prompt mentions. Read live by both the
|
|
1412
|
+
// `input` hook and the stacked autocomplete provider, so the toggle applies
|
|
1413
|
+
// immediately — the provider itself can never be unregistered (pi's wrapper
|
|
1414
|
+
// list is append-only), it just delegates everything when this is off.
|
|
1415
|
+
let agentMentionMode: AgentMentionMode = "model";
|
|
1416
|
+
function getAgentMentionMode(): AgentMentionMode { return agentMentionMode; }
|
|
1417
|
+
function setAgentMentionMode(mode: AgentMentionMode): void { agentMentionMode = mode; }
|
|
1418
|
+
// `model` and `direct` differ only in who starts a not-yet-running agent, so
|
|
1419
|
+
// everything that just asks "are mentions live at all" — the suggestion list,
|
|
1420
|
+
// the steer and resume branches — reads this instead of the mode.
|
|
1421
|
+
function isAgentMentionsEnabled(): boolean { return agentMentionMode !== "off"; }
|
|
1422
|
+
|
|
1423
|
+
// Project/global default for writing the subagent .output transcript lives in
|
|
1424
|
+
// output-file.ts (both spawn paths read it). A custom agent's
|
|
1425
|
+
// `output_transcript` frontmatter overrides it per spawn; when the frontmatter
|
|
1426
|
+
// is silent, this default applies. Read live at spawn time.
|
|
1427
|
+
|
|
1428
|
+
// ---- Join mode configuration ----
|
|
1429
|
+
let defaultJoinMode: JoinMode = 'smart';
|
|
1430
|
+
function getDefaultJoinMode(): JoinMode { return defaultJoinMode; }
|
|
1431
|
+
function setDefaultJoinMode(mode: JoinMode) { defaultJoinMode = mode; }
|
|
1432
|
+
|
|
1433
|
+
// What an unqualified top-level spawn means. Defaults to background,
|
|
1434
|
+
// following Claude Code; `backgroundByDefault: false` restores the previous
|
|
1435
|
+
// foreground default. Nested spawns ignore this — see nested-tools.ts.
|
|
1436
|
+
let backgroundByDefault = true;
|
|
1437
|
+
function getBackgroundByDefault(): boolean { return backgroundByDefault; }
|
|
1438
|
+
function setBackgroundByDefault(b: boolean) { backgroundByDefault = b; }
|
|
1439
|
+
|
|
1440
|
+
// Master switch for the schedule subagent feature. Defaults to enabled.
|
|
1441
|
+
// Read once at extension init (before tool registration) so the Agent tool's
|
|
1442
|
+
// param schema reflects the persisted setting. Runtime toggles via /agents
|
|
1443
|
+
// → Settings short-circuit the menu entry + the execute-time addJob path
|
|
1444
|
+
// immediately, but the schema-level removal only takes effect on next
|
|
1445
|
+
// extension load (next pi session). Documented in CHANGELOG/README.
|
|
1446
|
+
let schedulingEnabled = true;
|
|
1447
|
+
function isSchedulingEnabled(): boolean { return schedulingEnabled; }
|
|
1448
|
+
function setSchedulingEnabled(b: boolean) { schedulingEnabled = b; }
|
|
1449
|
+
|
|
1450
|
+
// Master switch for scripted workflows. Defaults to ON. Off means the
|
|
1451
|
+
// `SubagentWorkflow` tool is never registered: the model is not told the
|
|
1452
|
+
// feature exists (zero context cost) and has nothing to call. The
|
|
1453
|
+
// `/agents → Workflows` view and `--subagents-workflow-file` are refused too, so
|
|
1454
|
+
// there is no second door into the same machinery.
|
|
1455
|
+
//
|
|
1456
|
+
// `workflowsPinned` records that the answer came from the user — a boolean in
|
|
1457
|
+
// subagents.json, or the settings toggle — rather than from this default. It
|
|
1458
|
+
// is what `resolveWorkflowCollisions` checks before yielding to another
|
|
1459
|
+
// extension's workflow tool: a default may be overridden by what else is
|
|
1460
|
+
// loaded, an explicit choice may not.
|
|
1461
|
+
let workflowsEnabled = true;
|
|
1462
|
+
let workflowsPinned = false;
|
|
1463
|
+
function isWorkflowsEnabled(): boolean { return workflowsEnabled; }
|
|
1464
|
+
function isWorkflowsPinned(): boolean { return workflowsPinned; }
|
|
1465
|
+
function setWorkflowsEnabled(b: boolean) {
|
|
1466
|
+
workflowsEnabled = b;
|
|
1467
|
+
workflowsPinned = true;
|
|
1468
|
+
}
|
|
1469
|
+
|
|
1470
|
+
// ---- Disable default agents configuration ----
|
|
1471
|
+
// When enabled, the three hardcoded default agents (general-purpose, Explore,
|
|
1472
|
+
// Plan) are not registered. User-defined agents from project/global custom
|
|
1473
|
+
// agent dirs are completely unaffected — only DEFAULT_AGENTS are suppressed.
|
|
1474
|
+
// Defaults to false; opt-in via `/agents → Settings` or subagents.json.
|
|
1475
|
+
// State lives in agent-types.ts (isDefaultsDisabled) because registerAgents
|
|
1476
|
+
// needs it; this wrapper just re-registers after flipping it.
|
|
1477
|
+
function setDisableDefaultAgents(b: boolean): void {
|
|
1478
|
+
setDefaultsDisabled(b);
|
|
1479
|
+
reloadCustomAgents(); // re-register with new setting
|
|
1480
|
+
}
|
|
1481
|
+
|
|
1482
|
+
// ---- Agent tool description mode ----
|
|
1483
|
+
// "full" (default) keeps the rich Claude Code-style description; "compact"
|
|
1484
|
+
// swaps in a ~75% smaller one for small/local models (#91). Read once at
|
|
1485
|
+
// tool registration — flipping it applies on the next pi session.
|
|
1486
|
+
let toolDescriptionMode: ToolDescriptionMode = "full";
|
|
1487
|
+
function getToolDescriptionMode(): ToolDescriptionMode { return toolDescriptionMode; }
|
|
1488
|
+
function setToolDescriptionMode(mode: ToolDescriptionMode): void { toolDescriptionMode = mode; }
|
|
1489
|
+
|
|
1490
|
+
// ---- Turn batches for `group` join mode ----
|
|
1491
|
+
// Background spawns the parent made in its current turn. The turn's end is
|
|
1492
|
+
// the batch boundary: every `Agent` call of the batch has returned by then,
|
|
1493
|
+
// so a spawn whose startup failed has already thrown and was never recorded,
|
|
1494
|
+
// and a slow worktree copy cannot split the batch.
|
|
1495
|
+
let turnGroupSpawns: string[] = [];
|
|
1496
|
+
let turnGroupCounter = 0;
|
|
1497
|
+
|
|
1498
|
+
/** Record a `group`-mode spawn made inside a parent turn. */
|
|
1499
|
+
function joinTurnGroup(id: string, joinMode: JoinMode | undefined) {
|
|
1500
|
+
// Outside a parent turn (an `@handle` mention) no turn end is coming, and a
|
|
1501
|
+
// group of one is delivered exactly like a single completion.
|
|
1502
|
+
if (joinMode === 'group' && currentCtx?.signal !== undefined) turnGroupSpawns.push(id);
|
|
1503
|
+
}
|
|
1504
|
+
|
|
1505
|
+
/** Register the ending turn's `group` spawns, two or more, as one group. */
|
|
1506
|
+
function registerTurnGroup() {
|
|
1507
|
+
const ids = turnGroupSpawns;
|
|
1508
|
+
turnGroupSpawns = [];
|
|
1509
|
+
// One spawn is no group: it reports like any single completion.
|
|
1510
|
+
if (ids.length < 2) {
|
|
1511
|
+
const record = ids.length === 1 ? manager.getRecord(ids[0]) : undefined;
|
|
1512
|
+
if (record?.completedAt != null && !record.resultConsumed) sendIndividualNudge(record);
|
|
1513
|
+
return;
|
|
1514
|
+
}
|
|
1515
|
+
const groupId = `turn-${++turnGroupCounter}`;
|
|
1516
|
+
groupJoin.registerGroup(groupId, ids);
|
|
1517
|
+
// Members that finished before their turn ended had their completion
|
|
1518
|
+
// deferred (see the manager's onComplete); feed them in now.
|
|
1519
|
+
for (const id of ids) {
|
|
1520
|
+
const record = manager.getRecord(id);
|
|
1521
|
+
if (!record) continue;
|
|
1522
|
+
record.groupId = groupId;
|
|
1523
|
+
if (record.completedAt != null && !record.resultConsumed) {
|
|
1524
|
+
groupJoin.onAgentComplete(record);
|
|
1525
|
+
}
|
|
1526
|
+
}
|
|
1527
|
+
}
|
|
1528
|
+
|
|
1529
|
+
/**
|
|
1530
|
+
* The turn budget for resuming `record`, from its OWN type — resume ignores
|
|
1531
|
+
* `subagent_type`. Same precedence as a spawn, frontmatter authoritative:
|
|
1532
|
+
* the agent file's `max_turns`, else the call's, else the limit the agent
|
|
1533
|
+
* was spawned with, else the default (#368).
|
|
1534
|
+
*/
|
|
1535
|
+
function resumeTurnLimit(record: AgentRecord, explicit?: number): number | undefined {
|
|
1536
|
+
// Preserve 0 until the runner resolves it: normalizing it here would turn
|
|
1537
|
+
// an explicit unlimited budget into "unspecified" and restore the spawn cap.
|
|
1538
|
+
return getAgentConfig(record.type)?.maxTurns ?? explicit ?? record.invocation?.maxTurns ?? getDefaultMaxTurns();
|
|
1539
|
+
}
|
|
1540
|
+
|
|
1541
|
+
/**
|
|
1542
|
+
* Launch a detached resume of an existing agent and wire everything a
|
|
1543
|
+
* re-running agent needs: transcript anchoring, activity tracking, join-mode
|
|
1544
|
+
* batching, the widget/fleet refresh, and the `subagents:created` event.
|
|
1545
|
+
*
|
|
1546
|
+
* Shared by the Agent tool's `resume` + `run_in_background` branch and the
|
|
1547
|
+
* `@handle message` prompt mention — they differ only in how they report the
|
|
1548
|
+
* outcome. Returns the record, or undefined when the manager refused because
|
|
1549
|
+
* the agent is still running (see AgentManager.resume).
|
|
1550
|
+
*
|
|
1551
|
+
* Callers must have already established that the record has a session.
|
|
1552
|
+
*/
|
|
1553
|
+
async function startBackgroundResume(
|
|
1554
|
+
ctx: ExtensionContext,
|
|
1555
|
+
existing: AgentRecord,
|
|
1556
|
+
prompt: string,
|
|
1557
|
+
opts: { outputTranscript: boolean; maxTurns?: number; toolCallId?: string },
|
|
1558
|
+
): Promise<AgentRecord | undefined> {
|
|
1559
|
+
const id = existing.id;
|
|
1560
|
+
const joinMode = resolveJoinMode(defaultJoinMode, true);
|
|
1561
|
+
// Assigned unconditionally: the completion notification carries this as
|
|
1562
|
+
// `<tool-use-id>`, so a mention-resume (which passes none) has to CLEAR the
|
|
1563
|
+
// id left by the spawn that created the record. Keeping it would point the
|
|
1564
|
+
// orchestrator's new result at a tool call that was answered runs ago.
|
|
1565
|
+
existing.toolCallId = opts.toolCallId;
|
|
1566
|
+
if (joinMode) existing.joinMode = joinMode;
|
|
1567
|
+
// Reuse the agent's transcript rather than starting a fresh one: the
|
|
1568
|
+
// path is deterministic per agent+session, so writing an initial entry
|
|
1569
|
+
// would truncate the previous run's turns (see ensureOutputFile).
|
|
1570
|
+
if (opts.outputTranscript) {
|
|
1571
|
+
existing.outputFile = createOutputFilePath(ctx.cwd, id, ctx.sessionManager.getSessionId());
|
|
1572
|
+
ensureOutputFile(existing.outputFile);
|
|
1573
|
+
}
|
|
1574
|
+
// Anchor streaming past the turns already on disk, captured BEFORE the
|
|
1575
|
+
// run starts. The resumed prompt lands as an ordinary user message at
|
|
1576
|
+
// this index, so it is written exactly once.
|
|
1577
|
+
const transcriptAnchor = existing.session?.messages.length ?? 0;
|
|
1578
|
+
|
|
1579
|
+
const { state: bgState, callbacks: bgCallbacks } = createActivityTracker(normalizeMaxTurns(opts.maxTurns), undefined, clock);
|
|
1580
|
+
// resumeAgent has no onSessionCreated — the session predates this run —
|
|
1581
|
+
// so seed it directly, or the widget shows no context % for the agent.
|
|
1582
|
+
bgState.session = existing.session;
|
|
1583
|
+
|
|
1584
|
+
// No `signal`: a background spawn deliberately omits it, and a detached
|
|
1585
|
+
// resume must behave the same. Passing it would abort this agent when
|
|
1586
|
+
// the parent turn is interrupted (user Esc), while agents started with
|
|
1587
|
+
// run_in_background in that same turn keep going.
|
|
1588
|
+
const record = await manager.resume(id, prompt, undefined, {
|
|
1589
|
+
isBackground: true,
|
|
1590
|
+
maxTurns: opts.maxTurns,
|
|
1591
|
+
onTurnEnd: bgCallbacks.onTurnEnd,
|
|
1592
|
+
onTextDelta: bgCallbacks.onTextDelta,
|
|
1593
|
+
onModelCall: bgCallbacks.onModelCall,
|
|
1594
|
+
onToolActivity: bgCallbacks.onToolActivity,
|
|
1595
|
+
onAssistantUsage: bgCallbacks.onAssistantUsage,
|
|
1596
|
+
// Fires when the run actually starts — immediately, or on queue
|
|
1597
|
+
// drain. Wiring it here (rather than after resume() returns) means a
|
|
1598
|
+
// resume stopped while still queued never started streaming, so
|
|
1599
|
+
// there is no subscription left behind for a later run to trip over.
|
|
1600
|
+
onStarted: () => {
|
|
1601
|
+
const rec = manager.getRecord(id);
|
|
1602
|
+
if (rec?.session && rec.outputFile) {
|
|
1603
|
+
rec.outputCleanup = streamToOutputFile(rec.session, rec.outputFile, id, ctx.cwd, transcriptAnchor);
|
|
1604
|
+
}
|
|
1605
|
+
},
|
|
1606
|
+
});
|
|
1607
|
+
if (!record) return undefined;
|
|
1608
|
+
|
|
1609
|
+
// Only a resume from an Agent call belongs to that call's turn.
|
|
1610
|
+
if (opts.toolCallId !== undefined) joinTurnGroup(id, joinMode);
|
|
1611
|
+
|
|
1612
|
+
agentActivity.set(id, bgState);
|
|
1613
|
+
// This agent already finished once, so the widget holds a finished-age
|
|
1614
|
+
// for it that is past the linger limit — without clearing it, the
|
|
1615
|
+
// resumed run's ✓/✗ line never renders and the agent just vanishes.
|
|
1616
|
+
widget.markRunning(id);
|
|
1617
|
+
widget.ensureTimer();
|
|
1618
|
+
widget.update();
|
|
1619
|
+
fleet.ensureTimer();
|
|
1620
|
+
fleet.update();
|
|
1621
|
+
|
|
1622
|
+
// Resume ignores subagent_type (the record keeps the type it was
|
|
1623
|
+
// spawned with), so report the record's own identity — a "created"
|
|
1624
|
+
// event carrying the caller's type would re-register the agent under
|
|
1625
|
+
// the wrong one in cross-extension mirrors keyed by id.
|
|
1626
|
+
pi.events.emit("subagents:created", {
|
|
1627
|
+
id,
|
|
1628
|
+
type: existing.type,
|
|
1629
|
+
description: existing.description,
|
|
1630
|
+
isBackground: true,
|
|
1631
|
+
});
|
|
1632
|
+
|
|
1633
|
+
return record;
|
|
1634
|
+
}
|
|
1635
|
+
|
|
1636
|
+
// Grab UI context from first tool execution + clear lingering widget on new turn
|
|
1637
|
+
pi.on("tool_execution_start", async (_event, ctx) => {
|
|
1638
|
+
widget.setUICtx(ctx.ui as UICtx);
|
|
1639
|
+
fleet.setUICtx(ctx.ui as unknown as FleetUICtx);
|
|
1640
|
+
watchHandoffKey(ctx.ui);
|
|
1641
|
+
widget.onTurnStart();
|
|
1642
|
+
});
|
|
1643
|
+
|
|
1644
|
+
// The parent's tool batch is done and its next model call not yet made: the
|
|
1645
|
+
// boundary where completions are delivered, as in Claude Code.
|
|
1646
|
+
pi.on("turn_end", (event) => {
|
|
1647
|
+
registerTurnGroup();
|
|
1648
|
+
outbox.onTurnEnd(event.message.role === "assistant" ? event.message.stopReason : undefined);
|
|
1649
|
+
});
|
|
1650
|
+
pi.on("agent_end", () => outbox.onAgentEnd());
|
|
1651
|
+
pi.on("agent_settled", () => outbox.onAgentEnd());
|
|
1652
|
+
|
|
1653
|
+
/** Build the full type list text dynamically from available agents only. */
|
|
1654
|
+
const buildTypeListText = () => {
|
|
1655
|
+
const available = getAvailableTypes();
|
|
1656
|
+
|
|
1657
|
+
return available.map((name) => {
|
|
1658
|
+
const cfg = getAgentConfig(name);
|
|
1659
|
+
const modelSuffix = cfg?.model ? ` (${getModelLabelFromConfig(cfg.model)})` : "";
|
|
1660
|
+
const toolsSuffix = ` (Tools: ${formatToolsSuffix(cfg)})`;
|
|
1661
|
+
return `- ${name}: ${cfg?.description ?? name}${modelSuffix}${toolsSuffix}`;
|
|
1662
|
+
}).join("\n");
|
|
1663
|
+
};
|
|
1664
|
+
|
|
1665
|
+
/** First sentence of an agent description — for the compact type list. */
|
|
1666
|
+
const firstSentence = (text: string): string => {
|
|
1667
|
+
const match = text.match(/^.*?[.!?](?=\s|$)/s);
|
|
1668
|
+
return (match ? match[0] : text).replace(/\s+/g, " ").trim();
|
|
1669
|
+
};
|
|
1670
|
+
|
|
1671
|
+
/** Compact type list: one line per agent, first sentence only. */
|
|
1672
|
+
const buildCompactTypeListText = () =>
|
|
1673
|
+
getAvailableTypes().map((name) => {
|
|
1674
|
+
const cfg = getAgentConfig(name);
|
|
1675
|
+
return `- ${name}: ${firstSentence(cfg?.description ?? name)} (Tools: ${formatToolsSuffix(cfg)})`;
|
|
1676
|
+
}).join("\n");
|
|
1677
|
+
|
|
1678
|
+
/** Derive a short model label from a model string. */
|
|
1679
|
+
function getModelLabelFromConfig(model: string): string {
|
|
1680
|
+
// Strip provider prefix (e.g. "anthropic/claude-sonnet-4-6" → "claude-sonnet-4-6")
|
|
1681
|
+
const name = model.includes("/") ? model.split("/").pop()! : model;
|
|
1682
|
+
// Strip trailing date suffix (e.g. "claude-haiku-4-5-20251001" → "claude-haiku-4-5")
|
|
1683
|
+
return name.replace(/-\d{8}$/, "");
|
|
1684
|
+
}
|
|
1685
|
+
|
|
1686
|
+
// Apply persisted settings on startup and emit `subagents:settings_loaded`.
|
|
1687
|
+
// Global + project merged; missing → defaults; corrupt file emits a warning
|
|
1688
|
+
// to stderr and falls back to defaults.
|
|
1689
|
+
applyAndEmitLoaded(
|
|
1690
|
+
{
|
|
1691
|
+
setMaxConcurrent: (n) => manager.setMaxConcurrent(n),
|
|
1692
|
+
setMaxConcurrentForeground: (n) => manager.setMaxConcurrentForeground(n),
|
|
1693
|
+
setDefaultMaxTurns,
|
|
1694
|
+
setGraceTurns,
|
|
1695
|
+
setDefaultJoinMode,
|
|
1696
|
+
setBackgroundByDefault,
|
|
1697
|
+
setSchedulingEnabled,
|
|
1698
|
+
setScopeModels: setScopeModelsEnabled,
|
|
1699
|
+
setStrictAgentFiles: (b) => { strictAgentFiles = b; },
|
|
1700
|
+
setDisableDefaultAgents: setDisableDefaultAgents,
|
|
1701
|
+
setToolDescriptionMode: setToolDescriptionMode,
|
|
1702
|
+
setFleetView: setFleetViewEnabled,
|
|
1703
|
+
setAgentMentions: setAgentMentionMode,
|
|
1704
|
+
setRememberAgents,
|
|
1705
|
+
setWidgetMode: setWidgetMode,
|
|
1706
|
+
setOutputTranscript: setOutputTranscriptDefault,
|
|
1707
|
+
setWorktreeIsolation: setWorktreeIsolationEnabled,
|
|
1708
|
+
setWorkflowsEnabled: setWorkflowsEnabled,
|
|
1709
|
+
setMaxSubagentDepth: setMaxSubagentDepth,
|
|
1710
|
+
setFallbackSubagent: setFallbackSubagent,
|
|
1711
|
+
setModelAliases,
|
|
1712
|
+
setAgentModels,
|
|
1713
|
+
setReportUsage,
|
|
1714
|
+
setShowCost,
|
|
1715
|
+
setShowModel,
|
|
1716
|
+
setViewerMarkdown,
|
|
1717
|
+
setPackageAgents: setPackageAgentsGate,
|
|
1718
|
+
setPackageWorkflows: setPackageWorkflowsGate,
|
|
1719
|
+
},
|
|
1720
|
+
(event, payload) => pi.events.emit(event, payload),
|
|
1721
|
+
);
|
|
1722
|
+
|
|
1723
|
+
// ---- Agent tool ----
|
|
1724
|
+
|
|
1725
|
+
// Schedule param + its guideline are gated on `schedulingEnabled` (read once
|
|
1726
|
+
// at registration; flipping the setting later requires next pi session for
|
|
1727
|
+
// the schema to update). Defining the shape once and spreading it via Partial
|
|
1728
|
+
// preserves Type.Object's inference when present and produces a
|
|
1729
|
+
// `schedule`-free schema when absent — zero LLM-context cost in disabled mode.
|
|
1730
|
+
const scheduleParamShape = {
|
|
1731
|
+
schedule: Type.Optional(
|
|
1732
|
+
Type.String({
|
|
1733
|
+
description:
|
|
1734
|
+
'Opt-in only — fire later instead of now. Omit to run immediately (the default, almost always correct). ' +
|
|
1735
|
+
'Formats: 6-field cron ("0 0 9 * * 1" = 9am Mon), interval ("5m"/"1h"), one-shot ("+10m" or ISO). ' +
|
|
1736
|
+
'Forces run_in_background; incompatible with inherit_context and resume. Returns job ID.',
|
|
1737
|
+
}),
|
|
1738
|
+
),
|
|
1739
|
+
};
|
|
1740
|
+
const scheduleParam: Partial<typeof scheduleParamShape> =
|
|
1741
|
+
isSchedulingEnabled() ? scheduleParamShape : {};
|
|
1742
|
+
|
|
1743
|
+
const scheduleGuideline = isSchedulingEnabled()
|
|
1744
|
+
? `\n- Use \`schedule\` only when the user explicitly asked for scheduled / recurring / delayed execution (e.g. "every Monday", "in an hour"). Don't auto-schedule from vague intent like "monitor X" — run once now or ask.`
|
|
1745
|
+
: "";
|
|
1746
|
+
|
|
1747
|
+
// Same trade as scheduleParam/scheduleGuideline above: `isolationParam` drops
|
|
1748
|
+
// the field from the schema when the project set `worktreeIsolation: false`,
|
|
1749
|
+
// so the prose has to go with it. Left in, it would teach the model to pass a
|
|
1750
|
+
// parameter that isn't declared — accepted (TypeBox sets no
|
|
1751
|
+
// `additionalProperties: false`) and then silently dropped by the resolver.
|
|
1752
|
+
// With no per-result note by design, the model would have every reason to go
|
|
1753
|
+
// on reporting a `pi-agent-*` branch that was never created.
|
|
1754
|
+
const isolationGuideline = isWorktreeIsolationEnabled()
|
|
1755
|
+
? `\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\`.`
|
|
1756
|
+
: "";
|
|
1757
|
+
|
|
1758
|
+
const isolationCompactGuideline = isWorktreeIsolationEnabled()
|
|
1759
|
+
? `\n- isolation: "worktree" gives the agent its own git worktree (removed on completion); changes land on a branch named in the result. Pass \`cwd\` to isolate a repo other than the session cwd.`
|
|
1760
|
+
: "";
|
|
1761
|
+
|
|
1762
|
+
// Compact Agent tool description (#91, `toolDescriptionMode: "compact"`) —
|
|
1763
|
+
// the same load-bearing facts as the full version at ~75% fewer tokens, for
|
|
1764
|
+
// small/local models. Per-option details live in the param descriptions.
|
|
1765
|
+
const compactAgentToolDescription = () => `Launch an autonomous agent for complex, multi-step tasks. Agent types:
|
|
1766
|
+
${buildCompactTypeListText()}
|
|
1767
|
+
|
|
1768
|
+
Custom agents: .pi/agents/<name>.md (project) or ${getAgentDir()}/agents/<name>.md (global).
|
|
1769
|
+
|
|
1770
|
+
Notes:
|
|
1771
|
+
- description: 3-5 words (shown in UI). Prompts must be self-contained — the agent has not seen this conversation.
|
|
1772
|
+
- Parallel work: one message, multiple Agent calls — they run concurrently.
|
|
1773
|
+
- 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.
|
|
1774
|
+
- The result is not shown to the user — summarize it for them. Verify an agent's claimed code changes before reporting work done.
|
|
1775
|
+
- resume continues a previous agent by ID; steer_subagent messages a running one.${isolationCompactGuideline}`;
|
|
1776
|
+
|
|
1777
|
+
const fullAgentToolDescription = () => `Launch a new agent to handle complex, multi-step tasks autonomously. Each agent type has specific capabilities and tools available to it.
|
|
1778
|
+
|
|
1779
|
+
Available agent types and the tools they have access to:
|
|
1780
|
+
${buildTypeListText()}
|
|
1781
|
+
|
|
1782
|
+
Custom agents can be defined in .pi/agents/<name>.md (project) or ${getAgentDir()}/agents/<name>.md (global) — they are picked up automatically. Project-level agents override global ones. Creating a .md file with the same name as a default agent overrides it.
|
|
1783
|
+
|
|
1784
|
+
When using the Agent tool, specify a subagent_type parameter to select which agent type to use.
|
|
1785
|
+
|
|
1786
|
+
## When not to use
|
|
1787
|
+
|
|
1788
|
+
If the target is already known, use a direct tool — \`read\` for a known path, \`grep\`/\`find\` for a specific symbol or string. Reserve this tool for open-ended questions that span the codebase, or tasks that match an available agent type.
|
|
1789
|
+
|
|
1790
|
+
## Usage notes
|
|
1791
|
+
|
|
1792
|
+
- Always include a short (3-5 word) description summarizing what the agent will do (shown in UI).
|
|
1793
|
+
- When you launch multiple agents for independent work, send them in a single message with multiple tool uses so they run concurrently. If the user specifies that they want you to run agents "in parallel", you MUST send a single message with multiple Agent tool use content blocks.
|
|
1794
|
+
- When the agent is done, it returns a single message back to you. The result is not visible to the user — to show the user, send a text message with a concise summary.
|
|
1795
|
+
- Trust but verify: an agent's summary describes what it intended to do, not necessarily what it did. When an agent writes or edits code, check the actual changes before reporting the work as done.
|
|
1796
|
+
- Agents run in the background by default. When an agent runs in the background, you will be automatically notified when it completes — do NOT sleep, poll, or proactively check on its progress. Continue with other work or respond to the user instead.
|
|
1797
|
+
- **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.
|
|
1798
|
+
- **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.
|
|
1799
|
+
- 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.
|
|
1800
|
+
- Use steer_subagent to send mid-run messages to a running background agent.
|
|
1801
|
+
- 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.
|
|
1802
|
+
- If an agent's description says it should be used proactively, try to use it without the user having to ask for it first.
|
|
1803
|
+
- Use model to specify a different model (as "provider/modelId", or fuzzy e.g. "haiku", "sonnet").
|
|
1804
|
+
- Use thinking to control extended thinking level.
|
|
1805
|
+
- Use inherit_context if the agent needs the parent conversation history.${isolationGuideline}${scheduleGuideline}
|
|
1806
|
+
|
|
1807
|
+
## Writing the prompt
|
|
1808
|
+
|
|
1809
|
+
Brief the agent like a smart colleague who just walked into the room — it hasn't seen this conversation, doesn't know what you've tried, doesn't understand why this task matters.
|
|
1810
|
+
- Explain what you're trying to accomplish and why.
|
|
1811
|
+
- Describe what you've already learned or ruled out.
|
|
1812
|
+
- Give enough context about the surrounding problem that the agent can make judgment calls rather than just following a narrow instruction.
|
|
1813
|
+
- If you need a short response, say so ("report in under 200 words").
|
|
1814
|
+
- Lookups: hand over the exact command. Investigations: hand over the question — prescribed steps become dead weight when the premise is wrong.
|
|
1815
|
+
|
|
1816
|
+
Terse command-style prompts produce shallow, generic work.
|
|
1817
|
+
|
|
1818
|
+
**Never delegate understanding.** Don't write "based on your findings, fix the bug" or "based on the research, implement it." Those phrases push synthesis onto the agent instead of doing it yourself. Write prompts that prove you understood: include file paths, line numbers, what specifically to change.`;
|
|
1819
|
+
|
|
1820
|
+
// `toolDescriptionMode: "custom"` — user-authored description with live
|
|
1821
|
+
// dynamic parts. Project file wins over global; missing/empty falls back to
|
|
1822
|
+
// "full" (a stale fallback beats a blank tool description). Only the prose
|
|
1823
|
+
// is customizable — the parameter schema stays code-owned.
|
|
1824
|
+
const renderToolDescriptionTemplate = (template: string): string => {
|
|
1825
|
+
const vars: Record<string, () => string> = {
|
|
1826
|
+
typeList: buildTypeListText,
|
|
1827
|
+
compactTypeList: buildCompactTypeListText,
|
|
1828
|
+
agentDir: getAgentDir,
|
|
1829
|
+
isolationGuideline: () => isolationGuideline,
|
|
1830
|
+
scheduleGuideline: () => scheduleGuideline,
|
|
1831
|
+
};
|
|
1832
|
+
// Replacement callback (not a string) — agent descriptions may contain `$&` etc.
|
|
1833
|
+
return template.replace(/\{\{(\w+)\}\}/g, (raw, name: string) => {
|
|
1834
|
+
if (vars[name]) return vars[name]();
|
|
1835
|
+
console.warn(`[pi-subagents] agent-tool-description.md: unknown placeholder ${raw} left as-is`);
|
|
1836
|
+
return raw;
|
|
1837
|
+
});
|
|
1838
|
+
};
|
|
1839
|
+
|
|
1840
|
+
const loadCustomToolDescription = (): string | undefined => {
|
|
1841
|
+
// The project file is project-supplied text for the model: same canonical
|
|
1842
|
+
// trust gate as project agents, or it could carry an untrusted agent list.
|
|
1843
|
+
const candidates = [join(getAgentDir(), "agent-tool-description.md")];
|
|
1844
|
+
if (isProjectTrusted()) candidates.unshift(join(process.cwd(), ".pi", "agent-tool-description.md"));
|
|
1845
|
+
for (const path of candidates) {
|
|
1846
|
+
try {
|
|
1847
|
+
if (!existsSync(path)) continue;
|
|
1848
|
+
const text = readFileSync(path, "utf-8").trim();
|
|
1849
|
+
if (text) return renderToolDescriptionTemplate(text);
|
|
1850
|
+
console.warn(`[pi-subagents] ${path} is empty — ignoring`);
|
|
1851
|
+
} catch (err) {
|
|
1852
|
+
console.warn(`[pi-subagents] failed to read ${path}: ${err instanceof Error ? err.message : String(err)}`);
|
|
1853
|
+
}
|
|
1854
|
+
}
|
|
1855
|
+
return undefined;
|
|
1856
|
+
};
|
|
1857
|
+
|
|
1858
|
+
// Mode is fixed at activation, as the description always was.
|
|
1859
|
+
const descriptionMode = getToolDescriptionMode();
|
|
1860
|
+
const buildAgentToolDescription = (): string => {
|
|
1861
|
+
if (descriptionMode === "compact") return compactAgentToolDescription();
|
|
1862
|
+
if (descriptionMode === "custom") {
|
|
1863
|
+
const custom = loadCustomToolDescription();
|
|
1864
|
+
if (custom) return custom;
|
|
1865
|
+
console.warn('[pi-subagents] toolDescriptionMode is "custom" but no agent-tool-description.md found — using "full"');
|
|
1866
|
+
}
|
|
1867
|
+
return fullAgentToolDescription();
|
|
1868
|
+
};
|
|
1869
|
+
const buildSubagentTypeDescription = (): string =>
|
|
1870
|
+
`The type of specialized agent to use. Available types: ${getAvailableTypes().join(", ")}. Custom agents from .pi/agents/*.md (project) or ${getAgentDir()}/agents/*.md (global) are also available.`;
|
|
1871
|
+
|
|
1872
|
+
// Held rather than registered inline: the mention clone reuses this exact
|
|
1873
|
+
// definition, so the agent it starts is an ordinary top-level spawn instead
|
|
1874
|
+
// of a second implementation that has to be kept in step with this one.
|
|
1875
|
+
const agentTool = defineTool({
|
|
1876
|
+
name: SUBAGENT_TOOL_NAMES.AGENT,
|
|
1877
|
+
label: "Agent",
|
|
1878
|
+
description: buildAgentToolDescription(),
|
|
1879
|
+
promptSnippet: "Launch autonomous sub-agents for complex multi-step tasks",
|
|
1880
|
+
promptGuidelines: [
|
|
1881
|
+
"Use Agent with specialized agents when the task matches an agent type's description. Subagents are valuable for parallelizing independent queries or for protecting the main context window from excessive results, but should not be used excessively when not needed. Importantly, avoid duplicating work that subagents are already doing — if you delegate research to a subagent, do not also perform the same searches yourself.",
|
|
1882
|
+
"For broad codebase exploration or research, spawn Agent with an appropriate subagent_type (e.g. Explore). Otherwise use direct tools (read, grep, find) when the target is already known.",
|
|
1883
|
+
"When an agent runs in the background, you will be notified on completion — do not poll or sleep waiting for it. Continue with other work instead.",
|
|
1884
|
+
"Trust but verify: an agent's summary describes intent, not outcome. When an agent writes or edits code, check the actual changes before reporting work as done.",
|
|
1885
|
+
],
|
|
1886
|
+
parameters: Type.Object({
|
|
1887
|
+
prompt: Type.String({
|
|
1888
|
+
description: "The task for the agent to perform.",
|
|
1889
|
+
}),
|
|
1890
|
+
description: Type.String({
|
|
1891
|
+
description: "A short (3-5 word) description of the task (shown in UI).",
|
|
1892
|
+
}),
|
|
1893
|
+
name: Type.Optional(
|
|
1894
|
+
Type.String({
|
|
1895
|
+
description:
|
|
1896
|
+
'Optional memorable name for this agent, e.g. "auth-audit", so it can be addressed as `@name` at the prompt and by steer_subagent / get_subagent_result. Letters, digits, `_` and `-`. Worth setting when several agents of the same type run at once; omit for one-off work. The agent stays reachable by its type either way.',
|
|
1897
|
+
}),
|
|
1898
|
+
),
|
|
1899
|
+
subagent_type: Type.String({
|
|
1900
|
+
description: buildSubagentTypeDescription(),
|
|
1901
|
+
}),
|
|
1902
|
+
model: Type.Optional(
|
|
1903
|
+
Type.String({
|
|
1904
|
+
description:
|
|
1905
|
+
'Optional model override. Accepts "provider/modelId" or fuzzy name (e.g. "haiku", "sonnet"). Omit to use the agent type\'s default.',
|
|
1906
|
+
}),
|
|
1907
|
+
),
|
|
1908
|
+
thinking: Type.Optional(
|
|
1909
|
+
Type.String({
|
|
1910
|
+
description: `Thinking level: ${THINKING_LEVELS.join(", ")}. Overrides agent default.`,
|
|
1911
|
+
}),
|
|
1912
|
+
),
|
|
1913
|
+
max_turns: Type.Optional(
|
|
1914
|
+
Type.Number({
|
|
1915
|
+
description: "Maximum number of agentic turns before stopping. Omit for unlimited (default).",
|
|
1916
|
+
minimum: 1,
|
|
1917
|
+
}),
|
|
1918
|
+
),
|
|
1919
|
+
run_in_background: Type.Optional(
|
|
1920
|
+
Type.Boolean({
|
|
1921
|
+
description: "Defaults to true — the agent runs detached, returning its ID immediately, and you are notified on completion. Set false only when your very next action depends on the result; the call then blocks and returns the agent's full output inline.",
|
|
1922
|
+
}),
|
|
1923
|
+
),
|
|
1924
|
+
resume: Type.Optional(
|
|
1925
|
+
Type.String({
|
|
1926
|
+
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.",
|
|
1927
|
+
}),
|
|
1928
|
+
),
|
|
1929
|
+
isolated: Type.Optional(
|
|
1930
|
+
Type.Boolean({
|
|
1931
|
+
description: "If true, agent gets no extension/MCP tools — only built-in tools.",
|
|
1932
|
+
}),
|
|
1933
|
+
),
|
|
1934
|
+
inherit_context: Type.Optional(
|
|
1935
|
+
Type.Boolean({
|
|
1936
|
+
description: "If true, fork parent conversation into the agent. Default: false (fresh context).",
|
|
1937
|
+
}),
|
|
1938
|
+
),
|
|
1939
|
+
...isolationParam(isWorktreeIsolationEnabled()),
|
|
1940
|
+
...cwdParam,
|
|
1941
|
+
...scheduleParam,
|
|
1942
|
+
}),
|
|
1943
|
+
|
|
1944
|
+
// ---- Custom rendering: Claude Code style ----
|
|
1945
|
+
|
|
1946
|
+
renderCall(args, theme, context) {
|
|
1947
|
+
// A badge closes its own background, which would clear the tool block's row tint
|
|
1948
|
+
// for the rest of the line, so the badge restores it. The tint is opened here too:
|
|
1949
|
+
// the TUI's Box paints it, but HTML export takes it from CSS, and restoring a
|
|
1950
|
+
// background the line never opened is what banded the export before. The line is
|
|
1951
|
+
// deliberately left open — Box.applyBackgroundToLine pads to width and *then*
|
|
1952
|
+
// wraps, so closing here would leave that padding untinted, and HTML export closes
|
|
1953
|
+
// any open span per line anyway. No badge means no tint, so an uncolored agent
|
|
1954
|
+
// renders exactly the line it always did.
|
|
1955
|
+
const rowBackground = hasAgentBadge(args.subagent_type)
|
|
1956
|
+
? theme.getBgAnsi(context.isPartial ? "toolPendingBg" : context.isError ? "toolErrorBg" : "toolSuccessBg")
|
|
1957
|
+
: "";
|
|
1958
|
+
const desc = args.description ?? "";
|
|
1959
|
+
const name = renderAgentName(args.subagent_type, theme, {
|
|
1960
|
+
fallbackColor: "toolTitle",
|
|
1961
|
+
restoreBackground: rowBackground,
|
|
1962
|
+
bold: true,
|
|
1963
|
+
});
|
|
1964
|
+
// No ▸ here: core draws the disclosure marker on a settled tool row and
|
|
1965
|
+
// folds the collapsed row to `<this line> · <first result line>`.
|
|
1966
|
+
return new AgentCallView(
|
|
1967
|
+
rowBackground + name + (desc ? " " + theme.fg("muted", desc) : ""),
|
|
1968
|
+
context.expanded ? args.prompt : undefined,
|
|
1969
|
+
theme,
|
|
1970
|
+
);
|
|
1971
|
+
},
|
|
1972
|
+
|
|
1973
|
+
renderResult(result, { expanded }, theme, renderContext) {
|
|
1974
|
+
const details = result.details as AgentDetails | undefined;
|
|
1975
|
+
const text = result.content[0]?.type === "text" ? result.content[0].text : "";
|
|
1976
|
+
// Pi reports pre-execution failures (extension block, abort, argument
|
|
1977
|
+
// validation) as `{ content: [reason], details: {} }` with isError set —
|
|
1978
|
+
// no status to render, so show the reason instead of inventing one (#199).
|
|
1979
|
+
if (renderContext.isError || !details?.status) {
|
|
1980
|
+
return new Text(text, 0, 0);
|
|
1981
|
+
}
|
|
1982
|
+
// Anything else ("queued", or a status added later) has no rendering of
|
|
1983
|
+
// its own — raw text beats guessing at one.
|
|
1984
|
+
if (!AGENT_ROW_STATUSES.has(details.status)) return new Text(text, 0, 0);
|
|
1985
|
+
|
|
1986
|
+
keepRowTicking(renderContext, details, agentRowLive, clock);
|
|
1987
|
+
return new AgentResultView(details, text, expanded, agentRowLive, theme, clock);
|
|
1988
|
+
},
|
|
1989
|
+
|
|
1990
|
+
// ---- Execute ----
|
|
1991
|
+
|
|
1992
|
+
execute: async (toolCallId, params, signal, onUpdate, ctx) => {
|
|
1993
|
+
// Ensure we have UI context for widget rendering
|
|
1994
|
+
widget.setUICtx(ctx.ui as UICtx);
|
|
1995
|
+
|
|
1996
|
+
// Reload custom agents so new project/global .md files are picked up without restart
|
|
1997
|
+
reloadCustomAgents();
|
|
1998
|
+
|
|
1999
|
+
const rawType = params.subagent_type as SubagentType;
|
|
2000
|
+
// Single decision point for dispatch (#183): unknown, disabled and
|
|
2001
|
+
// case-ambiguous types are refused here, BEFORE anything spawns, so a
|
|
2002
|
+
// background or scheduled call can't start running the wrong agent while
|
|
2003
|
+
// the caller is still unaware. `fallbackSubagent` decides whether an
|
|
2004
|
+
// unresolvable type falls back or fails closed.
|
|
2005
|
+
const dispatch = resolveSpawnType(rawType);
|
|
2006
|
+
// `resume` replays a stored session and ignores `subagent_type` entirely,
|
|
2007
|
+
// but the parameter is required by the schema — so gating it here would
|
|
2008
|
+
// make a live agent unresumable the moment its type is deleted, disabled,
|
|
2009
|
+
// or gains a case-clashing sibling. Only a real spawn is gated.
|
|
2010
|
+
if (!dispatch.ok && !params.resume) return textResult(dispatch.message);
|
|
2011
|
+
const subagentType = dispatch.ok ? dispatch.type : rawType;
|
|
2012
|
+
// What the caller actually asked for, named once: `fellBackFrom` is "" for
|
|
2013
|
+
// a blank request, so reading it inline invites the `??`-vs-`||` slip that
|
|
2014
|
+
// once persisted an empty type into a scheduled job.
|
|
2015
|
+
const requestedType = (dispatch.ok && dispatch.fellBackFrom) || subagentType;
|
|
2016
|
+
// Computed at resolution rather than after the run, so the background and
|
|
2017
|
+
// schedule branches carry it too — previously it existed only on the
|
|
2018
|
+
// foreground path. Resume deliberately doesn't: it replays the stored
|
|
2019
|
+
// session and ignores `subagent_type` entirely, so a note about type
|
|
2020
|
+
// substitution would be describing something that didn't happen.
|
|
2021
|
+
const fallbackNote = dispatch.ok && dispatch.fellBackFrom !== undefined
|
|
2022
|
+
? `Note: Unknown agent type "${dispatch.fellBackFrom}" — using ${resolveType(subagentType) ? subagentType : "the fallback agent config"}.\n\n`
|
|
2023
|
+
: "";
|
|
2024
|
+
|
|
2025
|
+
const displayName = getDisplayName(subagentType);
|
|
2026
|
+
|
|
2027
|
+
// Get agent config (if any)
|
|
2028
|
+
const customConfig = getAgentConfig(subagentType);
|
|
2029
|
+
|
|
2030
|
+
const resolvedConfig = resolveAgentInvocationConfig(customConfig, params, {
|
|
2031
|
+
agentType: subagentType,
|
|
2032
|
+
worktreeAllowed: isWorktreeIsolationEnabled(),
|
|
2033
|
+
defaultRunInBackground: getBackgroundByDefault(),
|
|
2034
|
+
});
|
|
2035
|
+
|
|
2036
|
+
// Resolve an explicit tool-call model first; agent config supplies only a default.
|
|
2037
|
+
let model = ctx.model;
|
|
2038
|
+
if (resolvedConfig.modelInput) {
|
|
2039
|
+
const resolved = resolveModel(resolvedConfig.modelInput, ctx.modelRegistry);
|
|
2040
|
+
if (typeof resolved === "string") {
|
|
2041
|
+
if (resolvedConfig.modelFromParams) return textResult(resolved);
|
|
2042
|
+
// config-specified: silent fallback to parent
|
|
2043
|
+
} else {
|
|
2044
|
+
model = resolved;
|
|
2045
|
+
}
|
|
2046
|
+
}
|
|
2047
|
+
|
|
2048
|
+
// Scope validation: the effective resolved model is checked against the
|
|
2049
|
+
// user's enabledModels list. Policy (hard error vs warn-and-proceed) lives
|
|
2050
|
+
// in model-scope.ts so the nested delegation tools apply the same rule.
|
|
2051
|
+
const scopeVerdict = checkModelScope({
|
|
2052
|
+
model,
|
|
2053
|
+
cwd: ctx.cwd,
|
|
2054
|
+
modelRegistry: ctx.modelRegistry,
|
|
2055
|
+
callerSupplied: resolvedConfig.modelFromParams,
|
|
2056
|
+
agentLabel: customConfig?.displayName ?? subagentType,
|
|
2057
|
+
modelInput: resolvedConfig.modelInput,
|
|
2058
|
+
});
|
|
2059
|
+
if (scopeVerdict.kind === "error") return textResult(scopeVerdict.message);
|
|
2060
|
+
if (scopeVerdict.kind === "warn") ctx.ui.notify(scopeVerdict.message, "warning");
|
|
2061
|
+
|
|
2062
|
+
const thinking = resolvedConfig.thinking;
|
|
2063
|
+
const inheritContext = resolvedConfig.inheritContext;
|
|
2064
|
+
const runInBackground = resolvedConfig.runInBackground;
|
|
2065
|
+
const isolated = resolvedConfig.isolated;
|
|
2066
|
+
const isolation = resolvedConfig.isolation;
|
|
2067
|
+
// Undefined = the session cwd. The manager validates it (absolute,
|
|
2068
|
+
// existing directory) at spawn and again at start.
|
|
2069
|
+
const cwd = resolveCwdParam(params.cwd, ctx.cwd);
|
|
2070
|
+
// Whether this spawn writes its .output transcript. Per-agent
|
|
2071
|
+
// frontmatter (`output_transcript`) wins; otherwise the project/global
|
|
2072
|
+
// default applies. `attachTranscript` below is the SOLE gate — every
|
|
2073
|
+
// downstream consumer keys off record.outputFile being set, so no spawn
|
|
2074
|
+
// path can re-enable the transcript by accident.
|
|
2075
|
+
const outputTranscript = customConfig?.outputTranscript ?? getOutputTranscriptDefault();
|
|
2076
|
+
const attachTranscript = (rec: AgentRecord | undefined, agentId: string): void => {
|
|
2077
|
+
if (!rec || !outputTranscript) return;
|
|
2078
|
+
rec.outputFile = createOutputFilePath(ctx.cwd, agentId, ctx.sessionManager.getSessionId());
|
|
2079
|
+
writeInitialEntry(rec.outputFile, agentId, params.prompt, ctx.cwd);
|
|
2080
|
+
};
|
|
2081
|
+
|
|
2082
|
+
// Unconditional, not "only when it differs from the parent": a thinking
|
|
2083
|
+
// level reads as a property of a model, and an agent that inherited the
|
|
2084
|
+
// parent's model used to show the level with nothing to attach it to.
|
|
2085
|
+
// This is the pre-session snapshot — agent-manager overwrites it with the
|
|
2086
|
+
// effective values the moment a session reports them.
|
|
2087
|
+
const { modelName, modelId } = model
|
|
2088
|
+
? isVirtualModel(model) ? describeVirtualModel(model) : describeModel(model)
|
|
2089
|
+
: { modelName: undefined, modelId: undefined };
|
|
2090
|
+
const effectiveMaxTurns = normalizeMaxTurns(resolvedConfig.maxTurns ?? getDefaultMaxTurns());
|
|
2091
|
+
const agentInvocation: AgentInvocation = {
|
|
2092
|
+
modelName,
|
|
2093
|
+
modelId,
|
|
2094
|
+
thinking,
|
|
2095
|
+
// Only set where the agent file outranked the caller, so the surfaces can
|
|
2096
|
+
// disclose a parameter that was accepted but could not take effect (#182).
|
|
2097
|
+
requestedThinking: resolvedConfig.overridden?.thinking,
|
|
2098
|
+
// Explicit value only — the default fallback would just add noise.
|
|
2099
|
+
// Normalize so `0` (unlimited) doesn't surface as a misleading "max turns: 0".
|
|
2100
|
+
maxTurns: normalizeMaxTurns(resolvedConfig.maxTurns),
|
|
2101
|
+
isolated,
|
|
2102
|
+
inheritContext,
|
|
2103
|
+
runInBackground,
|
|
2104
|
+
isolation,
|
|
2105
|
+
};
|
|
2106
|
+
// Tool-result render shows the mode label too; viewer's header already does.
|
|
2107
|
+
const modeLabel = getPromptModeLabel(subagentType);
|
|
2108
|
+
const { tags: invocationTags } = buildInvocationTags(agentInvocation);
|
|
2109
|
+
const agentTags = modeLabel ? [modeLabel, ...invocationTags] : invocationTags;
|
|
2110
|
+
const detailBase = {
|
|
2111
|
+
displayName,
|
|
2112
|
+
description: params.description,
|
|
2113
|
+
subagentType,
|
|
2114
|
+
modelName,
|
|
2115
|
+
tags: agentTags.length > 0 ? agentTags : undefined,
|
|
2116
|
+
};
|
|
2117
|
+
|
|
2118
|
+
/**
|
|
2119
|
+
* `detailBase` for a record that exists, which outranks it: the base is a
|
|
2120
|
+
* snapshot of what this call REQUESTED, and pi may have resolved a
|
|
2121
|
+
* different model or clamped the thinking level (agent-manager writes the
|
|
2122
|
+
* effective values back when the session reports them). Resume goes
|
|
2123
|
+
* further and ignores the model/thinking parameters outright — it runs on
|
|
2124
|
+
* the session it is reopening — so rendering the base there advertises
|
|
2125
|
+
* settings the run never used.
|
|
2126
|
+
*
|
|
2127
|
+
* The mode label is rebuilt rather than carried over: it hangs off the
|
|
2128
|
+
* agent TYPE, not the invocation, so tags taken straight from
|
|
2129
|
+
* buildInvocationTags would silently drop `twin`.
|
|
2130
|
+
*/
|
|
2131
|
+
const detailBaseFor = (rec: AgentRecord | undefined): typeof detailBase => {
|
|
2132
|
+
if (!rec?.invocation) return detailBase;
|
|
2133
|
+
const type = rec.type;
|
|
2134
|
+
const { modelName: recModelName, tags } = buildInvocationTags(rec.invocation);
|
|
2135
|
+
const recModeLabel = getPromptModeLabel(type);
|
|
2136
|
+
const recTags = recModeLabel ? [recModeLabel, ...tags] : tags;
|
|
2137
|
+
return {
|
|
2138
|
+
displayName: getDisplayName(type),
|
|
2139
|
+
description: rec.description,
|
|
2140
|
+
subagentType: type,
|
|
2141
|
+
modelName: recModelName,
|
|
2142
|
+
tags: recTags.length > 0 ? recTags : undefined,
|
|
2143
|
+
};
|
|
2144
|
+
};
|
|
2145
|
+
|
|
2146
|
+
// ---- Schedule: register a job, don't spawn now ----
|
|
2147
|
+
if (params.schedule) {
|
|
2148
|
+
if (!isSchedulingEnabled()) {
|
|
2149
|
+
return textResult("Scheduling is disabled in this project. Enable via /agents → Settings → Scheduling.");
|
|
2150
|
+
}
|
|
2151
|
+
if (params.resume) {
|
|
2152
|
+
return textResult("Cannot combine `schedule` with `resume` — schedules create fresh agents.");
|
|
2153
|
+
}
|
|
2154
|
+
if (params.inherit_context) {
|
|
2155
|
+
return textResult("Cannot combine `schedule` with `inherit_context` — there is no parent conversation at fire time.");
|
|
2156
|
+
}
|
|
2157
|
+
if (params.run_in_background === false) {
|
|
2158
|
+
return textResult("Cannot combine `schedule` with `run_in_background: false` — scheduled jobs always run in background.");
|
|
2159
|
+
}
|
|
2160
|
+
if (cwd !== undefined) {
|
|
2161
|
+
return textResult("Cannot combine `schedule` with `cwd` — scheduled jobs run in the session cwd.");
|
|
2162
|
+
}
|
|
2163
|
+
if (!scheduler.isActive()) {
|
|
2164
|
+
return textResult("Scheduler is not active in this session yet. Try again after the session has fully started.");
|
|
2165
|
+
}
|
|
2166
|
+
try {
|
|
2167
|
+
const job = scheduler.addJob({
|
|
2168
|
+
name: params.description as string,
|
|
2169
|
+
description: params.description as string,
|
|
2170
|
+
schedule: params.schedule as string,
|
|
2171
|
+
// The caller's own name, not the substitute — the scheduler re-resolves
|
|
2172
|
+
// at fire time, and the original is what a user edits.
|
|
2173
|
+
subagent_type: requestedType,
|
|
2174
|
+
prompt: params.prompt as string,
|
|
2175
|
+
model: params.model as string | undefined,
|
|
2176
|
+
thinking: thinking,
|
|
2177
|
+
max_turns: effectiveMaxTurns,
|
|
2178
|
+
isolated: isolated,
|
|
2179
|
+
isolation: isolation,
|
|
2180
|
+
});
|
|
2181
|
+
const next = scheduler.getNextRun(job.id);
|
|
2182
|
+
return textResult(
|
|
2183
|
+
`${fallbackNote}Scheduled "${job.name}" (id: ${job.id}, type: ${job.scheduleType}). ` +
|
|
2184
|
+
`Next run: ${next ?? "(unknown)"}. ` +
|
|
2185
|
+
`Manage via /agents → Scheduled jobs.`,
|
|
2186
|
+
);
|
|
2187
|
+
} catch (err) {
|
|
2188
|
+
return textResult(err instanceof Error ? err.message : String(err));
|
|
2189
|
+
}
|
|
2190
|
+
}
|
|
2191
|
+
|
|
2192
|
+
// Resume existing agent
|
|
2193
|
+
if (params.resume) {
|
|
2194
|
+
const existing = manager.getRecord(params.resume);
|
|
2195
|
+
if (!existing || !isTopLevelAgent(existing)) {
|
|
2196
|
+
return textResult(`Agent not found: "${params.resume}". It may have been cleaned up.`);
|
|
2197
|
+
}
|
|
2198
|
+
if (!existing.session) {
|
|
2199
|
+
return textResult(`Agent "${params.resume}" has no active session to resume.`);
|
|
2200
|
+
}
|
|
2201
|
+
// Not `effectiveMaxTurns`: that was resolved for `subagent_type`, which
|
|
2202
|
+
// a resume ignores, so it may describe another agent.
|
|
2203
|
+
const resumeMaxTurns = resumeTurnLimit(existing, params.max_turns);
|
|
2204
|
+
|
|
2205
|
+
// Background resume: detached run that notifies on completion, mirroring
|
|
2206
|
+
// a background spawn. Previously run_in_background was silently ignored
|
|
2207
|
+
// on resume (this branch returned before the background branch below),
|
|
2208
|
+
// so a resumed agent always blocked the main loop until it finished.
|
|
2209
|
+
if (runInBackground) {
|
|
2210
|
+
const id = existing.id;
|
|
2211
|
+
// A detached resume hands control back while the record stays
|
|
2212
|
+
// "running", so nothing stops the model from resuming the same agent
|
|
2213
|
+
// again mid-run. manager.resume() refuses that (it would orphan the
|
|
2214
|
+
// live run's abort controller); say why here, where the model can act
|
|
2215
|
+
// on it, instead of letting it read as a generic failure.
|
|
2216
|
+
if (existing.status === "running" || existing.status === "queued") {
|
|
2217
|
+
return textResult(
|
|
2218
|
+
`Agent "${params.resume}" is still ${existing.status} — it can only be resumed once its current run finishes.\n` +
|
|
2219
|
+
`Use steer_subagent to send it a message mid-run, or get_subagent_result to wait for it.`,
|
|
2220
|
+
);
|
|
2221
|
+
}
|
|
2222
|
+
|
|
2223
|
+
const record = await startBackgroundResume(ctx, existing, params.prompt, {
|
|
2224
|
+
outputTranscript,
|
|
2225
|
+
maxTurns: resumeMaxTurns,
|
|
2226
|
+
toolCallId,
|
|
2227
|
+
});
|
|
2228
|
+
if (!record) {
|
|
2229
|
+
return textResult(`Failed to resume agent "${params.resume}".`);
|
|
2230
|
+
}
|
|
2231
|
+
|
|
2232
|
+
const isQueued = record.status === "queued";
|
|
2233
|
+
return textResult(
|
|
2234
|
+
`Agent ${isQueued ? "queued" : "resumed"} in background.\n` +
|
|
2235
|
+
`Agent ID: ${id}\n` +
|
|
2236
|
+
`Type: ${existing.type}\n` +
|
|
2237
|
+
(record.outputFile ? `Output file: ${record.outputFile}\n` : "") +
|
|
2238
|
+
(isQueued ? `Position: queued (max ${manager.getMaxConcurrent()} concurrent)\n` : "") +
|
|
2239
|
+
`\nYou will be notified when this agent completes.\n` +
|
|
2240
|
+
`Use get_subagent_result to retrieve full results, or steer_subagent to send it messages.`,
|
|
2241
|
+
{ ...detailBaseFor(record), toolUses: record.toolUses, tokens: "", durationMs: 0, status: "background" as const, agentId: id },
|
|
2242
|
+
);
|
|
2243
|
+
}
|
|
2244
|
+
|
|
2245
|
+
// Tracked like a foreground spawn, so the widget row and the result
|
|
2246
|
+
// card show THIS run's `↻N≤M` and tool activity rather than the
|
|
2247
|
+
// previous run's. The session predates this run, so it is seeded here.
|
|
2248
|
+
const { state: resumeState, callbacks: resumeCallbacks } = createActivityTracker(normalizeMaxTurns(resumeMaxTurns), undefined, clock);
|
|
2249
|
+
resumeState.session = existing.session;
|
|
2250
|
+
agentActivity.set(existing.id, resumeState);
|
|
2251
|
+
widget.markRunning(existing.id);
|
|
2252
|
+
widget.ensureTimer();
|
|
2253
|
+
fleet.ensureTimer();
|
|
2254
|
+
fleet.update();
|
|
2255
|
+
let record: AgentRecord | undefined;
|
|
2256
|
+
try {
|
|
2257
|
+
record = await manager.resume(params.resume, params.prompt, signal, {
|
|
2258
|
+
maxTurns: resumeMaxTurns,
|
|
2259
|
+
onTurnEnd: resumeCallbacks.onTurnEnd,
|
|
2260
|
+
onTextDelta: resumeCallbacks.onTextDelta,
|
|
2261
|
+
onModelCall: resumeCallbacks.onModelCall,
|
|
2262
|
+
onToolActivity: resumeCallbacks.onToolActivity,
|
|
2263
|
+
onAssistantUsage: resumeCallbacks.onAssistantUsage,
|
|
2264
|
+
});
|
|
2265
|
+
} finally {
|
|
2266
|
+
agentActivity.delete(existing.id);
|
|
2267
|
+
widget.markFinished(existing.id);
|
|
2268
|
+
fleet.onAgentFinished(existing.id);
|
|
2269
|
+
}
|
|
2270
|
+
if (!record) {
|
|
2271
|
+
return textResult(`Failed to resume agent "${params.resume}".`);
|
|
2272
|
+
}
|
|
2273
|
+
// A failed resume surfaces the error, plus any partial output THIS
|
|
2274
|
+
// resume produced (never the previous turn's answer, #144).
|
|
2275
|
+
if (record.status === "error") {
|
|
2276
|
+
return textResult(`Agent failed: ${record.error}${partialOutputSuffix(record)}`, buildDetails(detailBaseFor(record), record, resumeState));
|
|
2277
|
+
}
|
|
2278
|
+
return textResult(
|
|
2279
|
+
record.result?.trim() || "No output.",
|
|
2280
|
+
buildDetails(detailBaseFor(record), record, resumeState),
|
|
2281
|
+
);
|
|
2282
|
+
}
|
|
2283
|
+
|
|
2284
|
+
// What the model sees for a run it no longer waits on — spawned in the
|
|
2285
|
+
// background, or handed there mid-run (ctrl+b / viewer `b`). One text for
|
|
2286
|
+
// both, as in Claude Code: from here on they are the same kind of agent.
|
|
2287
|
+
const backgroundResult = (id: string, record: AgentRecord | undefined) => {
|
|
2288
|
+
const isQueued = record?.status === "queued";
|
|
2289
|
+
return textResult(
|
|
2290
|
+
`${fallbackNote}Agent ${isQueued ? "queued" : "started"} in background.\n` +
|
|
2291
|
+
`Agent ID: ${id}\n` +
|
|
2292
|
+
`Type: ${displayName}\n` +
|
|
2293
|
+
`Description: ${params.description}\n` +
|
|
2294
|
+
(record?.outputFile ? `Output file: ${record.outputFile}\n` : "") +
|
|
2295
|
+
(isQueued ? `Position: queued (max ${manager.getMaxConcurrent()} concurrent)\n` : "") +
|
|
2296
|
+
`\nYou will be notified when this agent completes.\n` +
|
|
2297
|
+
`Use get_subagent_result to retrieve full results, or steer_subagent to send it messages.\n` +
|
|
2298
|
+
`Do not duplicate this agent's work.`,
|
|
2299
|
+
{ ...detailBaseFor(record), toolUses: 0, tokens: "", durationMs: 0, status: "background" as const, agentId: id },
|
|
2300
|
+
);
|
|
2301
|
+
};
|
|
2302
|
+
|
|
2303
|
+
// Quota admission for fresh spawns; a resume keeps its session's model.
|
|
2304
|
+
// A background dispatch parks as a one-shot scheduled job at the reset,
|
|
2305
|
+
// which the schedule store persists across restarts, unless the router's
|
|
2306
|
+
// exhaustion policy says fail now (`wait: false`). Anything else fails
|
|
2307
|
+
// now, with the reset, rather than starting on an exhausted provider.
|
|
2308
|
+
const dispatchModel = model ?? ctx.model;
|
|
2309
|
+
const quotaBlock = dispatchModel ? await checkAdmission(pi.events, modelRef(dispatchModel), undefined, clock) : undefined;
|
|
2310
|
+
if (quotaBlock) {
|
|
2311
|
+
// A parked job is a scheduled job, which has no `cwd` to carry.
|
|
2312
|
+
const canPark = runInBackground && !inheritContext && cwd === undefined && mayPark(quotaBlock)
|
|
2313
|
+
&& isSchedulingEnabled() && scheduler.isActive();
|
|
2314
|
+
if (!canPark || !mayPark(quotaBlock)) {
|
|
2315
|
+
throw new Error(`${describeBlock(quotaBlock)} The agent was not started.`);
|
|
2316
|
+
}
|
|
2317
|
+
const job = scheduler.addJob({
|
|
2318
|
+
name: `${params.description} (quota ${nanoid(4)})`,
|
|
2319
|
+
description: params.description as string,
|
|
2320
|
+
schedule: new Date(quotaBlock.resetAt + QUOTA_RESTART_GRACE_MS).toISOString(),
|
|
2321
|
+
subagent_type: requestedType,
|
|
2322
|
+
prompt: params.prompt as string,
|
|
2323
|
+
model: dispatchModel ? modelRef(dispatchModel) : undefined,
|
|
2324
|
+
thinking,
|
|
2325
|
+
max_turns: effectiveMaxTurns,
|
|
2326
|
+
isolated,
|
|
2327
|
+
isolation,
|
|
2328
|
+
quotaParked: true,
|
|
2329
|
+
});
|
|
2330
|
+
return textResult(
|
|
2331
|
+
`${fallbackNote}${describeBlock(quotaBlock)} Queued "${job.name}" (id: ${job.id}) to start at ` +
|
|
2332
|
+
`${scheduler.getNextRun(job.id) ?? job.schedule}. Manage via /agents → Scheduled jobs.`,
|
|
2333
|
+
);
|
|
2334
|
+
}
|
|
2335
|
+
|
|
2336
|
+
// Background execution
|
|
2337
|
+
if (runInBackground) {
|
|
2338
|
+
const { state: bgState, callbacks: bgCallbacks } = createActivityTracker(effectiveMaxTurns, undefined, clock);
|
|
2339
|
+
|
|
2340
|
+
// Wrap onSessionCreated to wire output file streaming.
|
|
2341
|
+
// The callback lazily reads record.outputFile (set right after spawn)
|
|
2342
|
+
// rather than closing over a value that doesn't exist yet.
|
|
2343
|
+
let id: string;
|
|
2344
|
+
const origBgOnSession = bgCallbacks.onSessionCreated;
|
|
2345
|
+
bgCallbacks.onSessionCreated = (session: any) => {
|
|
2346
|
+
origBgOnSession(session);
|
|
2347
|
+
const rec = manager.getRecord(id);
|
|
2348
|
+
if (rec?.outputFile) {
|
|
2349
|
+
rec.outputCleanup = streamToOutputFile(session, rec.outputFile, id, ctx.cwd);
|
|
2350
|
+
}
|
|
2351
|
+
};
|
|
2352
|
+
|
|
2353
|
+
// A throw here means the agent never started. Let it out: pi marks the
|
|
2354
|
+
// tool call failed, whereas a returned message reads to the model as a
|
|
2355
|
+
// subagent that ran and reported this (#179).
|
|
2356
|
+
id = manager.spawn(pi, ctx, subagentType, params.prompt, {
|
|
2357
|
+
description: params.description,
|
|
2358
|
+
name: params.name as string | undefined,
|
|
2359
|
+
model,
|
|
2360
|
+
maxTurns: effectiveMaxTurns,
|
|
2361
|
+
isolated,
|
|
2362
|
+
inheritContext,
|
|
2363
|
+
thinkingLevel: thinking,
|
|
2364
|
+
isBackground: true,
|
|
2365
|
+
isolation,
|
|
2366
|
+
cwd,
|
|
2367
|
+
invocation: agentInvocation,
|
|
2368
|
+
rootSessionId: ctx.sessionManager.getSessionId(),
|
|
2369
|
+
...bgCallbacks,
|
|
2370
|
+
});
|
|
2371
|
+
|
|
2372
|
+
// Set output file + join mode synchronously after spawn, before the
|
|
2373
|
+
// event loop yields — onSessionCreated is async so this is safe.
|
|
2374
|
+
const joinMode = resolveJoinMode(defaultJoinMode, true);
|
|
2375
|
+
const record = manager.getRecord(id);
|
|
2376
|
+
if (record && joinMode) {
|
|
2377
|
+
record.joinMode = joinMode;
|
|
2378
|
+
record.toolCallId = toolCallId;
|
|
2379
|
+
attachTranscript(record, id);
|
|
2380
|
+
}
|
|
2381
|
+
|
|
2382
|
+
// With isolation: "worktree" the agent isn't running yet — the repo
|
|
2383
|
+
// copy is an awaited git call. Wait for it here, after the synchronous
|
|
2384
|
+
// wiring above, so a strict-isolation failure still fails THIS tool
|
|
2385
|
+
// call instead of being reported as a subagent that ran (#179).
|
|
2386
|
+
await manager.awaitStartup(id);
|
|
2387
|
+
|
|
2388
|
+
joinTurnGroup(id, joinMode);
|
|
2389
|
+
|
|
2390
|
+
agentActivity.set(id, bgState);
|
|
2391
|
+
widget.ensureTimer();
|
|
2392
|
+
widget.update();
|
|
2393
|
+
fleet.ensureTimer();
|
|
2394
|
+
fleet.update();
|
|
2395
|
+
|
|
2396
|
+
// Emit created event
|
|
2397
|
+
pi.events.emit("subagents:created", {
|
|
2398
|
+
id,
|
|
2399
|
+
type: subagentType,
|
|
2400
|
+
description: params.description,
|
|
2401
|
+
isBackground: true,
|
|
2402
|
+
});
|
|
2403
|
+
|
|
2404
|
+
return backgroundResult(id, record);
|
|
2405
|
+
}
|
|
2406
|
+
|
|
2407
|
+
// Foreground (synchronous) execution — stream progress via onUpdate
|
|
2408
|
+
let spinnerFrame = 0;
|
|
2409
|
+
const startedAt = Date.now();
|
|
2410
|
+
let fgId: string | undefined;
|
|
2411
|
+
// Set once the run is live and can be handed to the background — see
|
|
2412
|
+
// `foregroundHandoffs`. Unset while queued: there is no run to hand over.
|
|
2413
|
+
let handoffId: string | undefined;
|
|
2414
|
+
// Set by the handoff. From then on `onUpdate` is a dead handle (pi drops
|
|
2415
|
+
// updates once `execute` has returned), so `streamUpdate` stops building
|
|
2416
|
+
// them; the tracker keeps `fgState` live for the widget and FleetView.
|
|
2417
|
+
let backgrounded = false;
|
|
2418
|
+
// Set only while the spawn is parked on a foreground concurrency slot
|
|
2419
|
+
// (maxConcurrentForeground); undefined the rest of the time, including
|
|
2420
|
+
// always when the limit is unset.
|
|
2421
|
+
let queuedAhead: number | undefined;
|
|
2422
|
+
|
|
2423
|
+
const streamUpdate = () => {
|
|
2424
|
+
if (backgrounded) return;
|
|
2425
|
+
// Spend from the record, everything else from the live tracker. `fgId`
|
|
2426
|
+
// is set in onSessionCreated below, which fires before the first
|
|
2427
|
+
// assistant message — so nothing is spent while this reads zero.
|
|
2428
|
+
const fgRecord = fgId ? manager.getRecord(fgId) : undefined;
|
|
2429
|
+
const details: AgentDetails = {
|
|
2430
|
+
...detailBaseFor(fgRecord),
|
|
2431
|
+
toolUses: fgState.toolUses,
|
|
2432
|
+
tokens: fgRecord ? formatLifetimeTokens(fgRecord) : "",
|
|
2433
|
+
cost: fgRecord ? getLifetimeCost(fgRecord.lifetimeUsage) : 0,
|
|
2434
|
+
turnCount: fgState.turnCount,
|
|
2435
|
+
maxTurns: fgState.maxTurns,
|
|
2436
|
+
durationMs: Date.now() - startedAt,
|
|
2437
|
+
// Deliberately still "running" while queued: the renderer routes any
|
|
2438
|
+
// status it doesn't know to raw text (see the catch-all below), which
|
|
2439
|
+
// would drop the spinner and read as hung. Only the activity line
|
|
2440
|
+
// changes — "thinking…" would be a lie for an agent that has not
|
|
2441
|
+
// started and may not for minutes.
|
|
2442
|
+
status: "running",
|
|
2443
|
+
activity: queuedAhead === undefined
|
|
2444
|
+
? describeActivity(fgState.activeTools, fgState.responseText, fgState.modelCall)
|
|
2445
|
+
: `queued — waiting for a foreground slot${queuedAhead > 0 ? ` (${queuedAhead} ahead)` : ""}`,
|
|
2446
|
+
spinnerFrame: spinnerFrame % SPINNER.length,
|
|
2447
|
+
// One hint per batch, as in Claude Code: on the earliest call still
|
|
2448
|
+
// pending (a Map iterates in insertion order). ctrl+b takes them all.
|
|
2449
|
+
backgroundHint: handoffId !== undefined && foregroundHandoffs.keys().next().value === handoffId,
|
|
2450
|
+
};
|
|
2451
|
+
onUpdate?.({
|
|
2452
|
+
content: [{ type: "text", text: `${fgState.toolUses} tool uses...` }],
|
|
2453
|
+
details: details as any,
|
|
2454
|
+
});
|
|
2455
|
+
};
|
|
2456
|
+
|
|
2457
|
+
const { state: fgState, callbacks: fgCallbacks } = createActivityTracker(effectiveMaxTurns, streamUpdate, clock);
|
|
2458
|
+
|
|
2459
|
+
// Wire session creation: register in widget + stream to output file.
|
|
2460
|
+
// The output file path is set synchronously after spawn (below),
|
|
2461
|
+
// before onSessionCreated fires — same pattern as background agents.
|
|
2462
|
+
const origOnSession = fgCallbacks.onSessionCreated;
|
|
2463
|
+
fgCallbacks.onSessionCreated = (session: any) => {
|
|
2464
|
+
origOnSession(session);
|
|
2465
|
+
// It really started — stop reporting it as queued, and repaint now
|
|
2466
|
+
// rather than leaving the stale line up for the next spinner tick.
|
|
2467
|
+
// Guarded, so a spawn that never queued emits no extra update.
|
|
2468
|
+
if (queuedAhead !== undefined) {
|
|
2469
|
+
queuedAhead = undefined;
|
|
2470
|
+
streamUpdate();
|
|
2471
|
+
}
|
|
2472
|
+
for (const a of manager.listAgents()) {
|
|
2473
|
+
if (a.session === session) {
|
|
2474
|
+
fgId = a.id;
|
|
2475
|
+
agentActivity.set(a.id, fgState);
|
|
2476
|
+
widget.ensureTimer();
|
|
2477
|
+
fleet.ensureTimer();
|
|
2478
|
+
fleet.update();
|
|
2479
|
+
break;
|
|
2480
|
+
}
|
|
2481
|
+
}
|
|
2482
|
+
// Stream conversation to output file (foreground agent logging)
|
|
2483
|
+
if (fgId) {
|
|
2484
|
+
const rec = manager.getRecord(fgId);
|
|
2485
|
+
if (rec?.outputFile) {
|
|
2486
|
+
rec.outputCleanup = streamToOutputFile(session, rec.outputFile, fgId, ctx.cwd);
|
|
2487
|
+
}
|
|
2488
|
+
}
|
|
2489
|
+
};
|
|
2490
|
+
|
|
2491
|
+
// Animate spinner at ~80ms (smooth rotation through 10 braille frames)
|
|
2492
|
+
const spinnerInterval = clock.setInterval(() => {
|
|
2493
|
+
spinnerFrame++;
|
|
2494
|
+
streamUpdate();
|
|
2495
|
+
}, 80);
|
|
2496
|
+
|
|
2497
|
+
streamUpdate();
|
|
2498
|
+
|
|
2499
|
+
// ctrl+b or the viewer's `b` wakes this with the run already handed over
|
|
2500
|
+
// (`sendForegroundToBackground`), raced against the run below. The
|
|
2501
|
+
// manager only accepts a handoff of a run that has not settled, and the
|
|
2502
|
+
// wake-up resolves synchronously with it, so whichever promise wins is
|
|
2503
|
+
// the truth: the result is never also delivered as a notification.
|
|
2504
|
+
let wakeBackgrounded!: () => void;
|
|
2505
|
+
const handedOff = new Promise<undefined>(resolve => { wakeBackgrounded = () => resolve(undefined); });
|
|
2506
|
+
|
|
2507
|
+
let fgResult: { record: AgentRecord } | undefined;
|
|
2508
|
+
try {
|
|
2509
|
+
fgResult = await Promise.race([manager.spawnAndWait(pi, ctx, subagentType, params.prompt, {
|
|
2510
|
+
description: params.description,
|
|
2511
|
+
name: params.name as string | undefined,
|
|
2512
|
+
model,
|
|
2513
|
+
maxTurns: effectiveMaxTurns,
|
|
2514
|
+
isolated,
|
|
2515
|
+
inheritContext,
|
|
2516
|
+
thinkingLevel: thinking,
|
|
2517
|
+
isolation,
|
|
2518
|
+
cwd,
|
|
2519
|
+
invocation: agentInvocation,
|
|
2520
|
+
signal,
|
|
2521
|
+
rootSessionId: ctx.sessionManager.getSessionId(),
|
|
2522
|
+
// Deliberately does NOT set fgId: that drives agentActivity, the
|
|
2523
|
+
// widget and the `finally` cleanup below, none of which should see an
|
|
2524
|
+
// agent that has no session and may never get one.
|
|
2525
|
+
onQueued: (_id, ahead) => { queuedAhead = ahead; streamUpdate(); },
|
|
2526
|
+
...fgCallbacks,
|
|
2527
|
+
}, (fgAgentId) => {
|
|
2528
|
+
// onSpawned: called synchronously after spawn, before onSessionCreated fires.
|
|
2529
|
+
// Set up the output file so streamToOutputFile can pick it up.
|
|
2530
|
+
const fgRec = manager.getRecord(fgAgentId);
|
|
2531
|
+
attachTranscript(fgRec, fgAgentId);
|
|
2532
|
+
// Fires once the run promise exists, i.e. the agent is running. From
|
|
2533
|
+
// here until this call returns, the run can be handed off.
|
|
2534
|
+
handoffId = fgAgentId;
|
|
2535
|
+
foregroundHandoffs.set(fgAgentId, () => {
|
|
2536
|
+
// What a background spawn sets up front: the notification names
|
|
2537
|
+
// this tool call, and it reports alone — its spawn batch is long
|
|
2538
|
+
// closed.
|
|
2539
|
+
if (fgRec) {
|
|
2540
|
+
fgRec.toolCallId = toolCallId;
|
|
2541
|
+
fgRec.joinMode = "async";
|
|
2542
|
+
}
|
|
2543
|
+
backgrounded = true;
|
|
2544
|
+
wakeBackgrounded();
|
|
2545
|
+
});
|
|
2546
|
+
}), handedOff]);
|
|
2547
|
+
} finally {
|
|
2548
|
+
// Runs on both paths, so a startup throw — which now propagates, see
|
|
2549
|
+
// the background spawn above (#179) — no longer leaves the spinner
|
|
2550
|
+
// ticking or a finished agent on the widget.
|
|
2551
|
+
spinnerInterval.cancel();
|
|
2552
|
+
if (handoffId !== undefined) foregroundHandoffs.delete(handoffId);
|
|
2553
|
+
// A handed-off run is still working: its row stays, and the
|
|
2554
|
+
// background settle in `onComplete` tears it down when it really ends.
|
|
2555
|
+
if (fgId && !backgrounded) {
|
|
2556
|
+
agentActivity.delete(fgId);
|
|
2557
|
+
widget.markFinished(fgId);
|
|
2558
|
+
fleet.onAgentFinished(fgId);
|
|
2559
|
+
}
|
|
2560
|
+
}
|
|
2561
|
+
|
|
2562
|
+
if (fgResult === undefined) return backgroundResult(handoffId!, manager.getRecord(handoffId!));
|
|
2563
|
+
const record = fgResult.record;
|
|
2564
|
+
|
|
2565
|
+
// Get final token count — from the record, like the cost below it, so the
|
|
2566
|
+
// two describe the same work when the agent delegated to nested children.
|
|
2567
|
+
const tokenText = formatLifetimeTokens(record);
|
|
2568
|
+
|
|
2569
|
+
const details = buildDetails(detailBaseFor(record), record, fgState, { tokens: tokenText });
|
|
2570
|
+
|
|
2571
|
+
if (record.status === "error") {
|
|
2572
|
+
// Error headline + any partial output the run produced before failing.
|
|
2573
|
+
return textResult(`${fallbackNote}Agent failed: ${record.error}${partialOutputSuffix(record)}`, details);
|
|
2574
|
+
}
|
|
2575
|
+
|
|
2576
|
+
const durationMs = (record.completedAt ?? Date.now()) - record.startedAt;
|
|
2577
|
+
const statsParts = [`${record.toolUses} tool uses`];
|
|
2578
|
+
if (tokenText) statsParts.push(tokenText);
|
|
2579
|
+
if (showCost) {
|
|
2580
|
+
const costText = formatCost(getLifetimeCost(record.lifetimeUsage));
|
|
2581
|
+
if (costText) statsParts.push(costText);
|
|
2582
|
+
}
|
|
2583
|
+
return textResult(
|
|
2584
|
+
`${fallbackNote}Agent completed in ${formatMs(durationMs)} (${statsParts.join(", ")})${getForegroundOutcomeNote(record.status)}.\n\n` +
|
|
2585
|
+
(record.result?.trim() || "No output."),
|
|
2586
|
+
details,
|
|
2587
|
+
);
|
|
2588
|
+
},
|
|
2589
|
+
});
|
|
2590
|
+
/**
|
|
2591
|
+
* Wrap a tool so its results carry back whatever subagent spend the parent
|
|
2592
|
+
* session has not been told about yet (see `PendingUsagePool`).
|
|
2593
|
+
*
|
|
2594
|
+
* Pi copies `AgentToolResult.usage` onto the persisted tool-result message and
|
|
2595
|
+
* folds it into `getSessionStats()`, which is what the footer, the statusline
|
|
2596
|
+
* and `/cost` read — so this is the whole of "report usage to the parent".
|
|
2597
|
+
*
|
|
2598
|
+
* Nothing is attached to a call with no tool-call id. That is the `@handle`
|
|
2599
|
+
* mention path (`mention-clone.ts`), which invokes this tool from a fork of the
|
|
2600
|
+
* conversation that is discarded moments later: the result never becomes a
|
|
2601
|
+
* message in the real session, so usage hung on it would be spend the user paid
|
|
2602
|
+
* for and nobody counted. Skipping leaves it pending for the next real result.
|
|
2603
|
+
*/
|
|
2604
|
+
function withUsageReporting<T extends { execute: (...args: any[]) => any }>(tool: T): T {
|
|
2605
|
+
return {
|
|
2606
|
+
...tool,
|
|
2607
|
+
execute: async (toolCallId: string | undefined, ...rest: any[]) => {
|
|
2608
|
+
const result = await tool.execute(toolCallId, ...rest);
|
|
2609
|
+
if (!reportUsage || !toolCallId) return result;
|
|
2610
|
+
const usage = pendingUsage.drain();
|
|
2611
|
+
return usage ? { ...result, usage } : result;
|
|
2612
|
+
},
|
|
2613
|
+
};
|
|
2614
|
+
}
|
|
2615
|
+
function registerToolReportingUsage(tool: any): void {
|
|
2616
|
+
pi.registerTool(withUsageReporting(tool));
|
|
2617
|
+
}
|
|
2618
|
+
|
|
2619
|
+
// The mention path is handed THIS object, not the bare `agentTool` — see the
|
|
2620
|
+
// mention-clone header on why the clone must call the registered tool.
|
|
2621
|
+
let registeredAgentTool = withUsageReporting(agentTool);
|
|
2622
|
+
let shownDescription = agentTool.description;
|
|
2623
|
+
let shownTypeDescription = buildSubagentTypeDescription();
|
|
2624
|
+
pi.registerTool(registeredAgentTool);
|
|
2625
|
+
/**
|
|
2626
|
+
* Re-register `Agent` with a description and `subagent_type` text rebuilt from
|
|
2627
|
+
* the current registry. Wraps the unwrapped `agentTool` again, never the
|
|
2628
|
+
* previous wrapper, so usage is reported once. A no-op when nothing the model
|
|
2629
|
+
* sees changed. `registeredAgentTool` is what the mention path reads, so it
|
|
2630
|
+
* always points at the current definition.
|
|
2631
|
+
*/
|
|
2632
|
+
function refreshAgentTool(): void {
|
|
2633
|
+
const description = buildAgentToolDescription();
|
|
2634
|
+
const typeDescription = buildSubagentTypeDescription();
|
|
2635
|
+
if (description === shownDescription && typeDescription === shownTypeDescription) return;
|
|
2636
|
+
shownDescription = description;
|
|
2637
|
+
shownTypeDescription = typeDescription;
|
|
2638
|
+
const properties = { ...agentTool.parameters.properties, subagent_type: Type.String({ description: typeDescription }) };
|
|
2639
|
+
registeredAgentTool = withUsageReporting({ ...agentTool, description, parameters: { ...agentTool.parameters, properties } });
|
|
2640
|
+
pi.registerTool(registeredAgentTool);
|
|
2641
|
+
}
|
|
2642
|
+
|
|
2643
|
+
// ---- Workflow tool ----
|
|
2644
|
+
|
|
2645
|
+
/**
|
|
2646
|
+
* Live runs, by task id. The tool returns before the run finishes, so its
|
|
2647
|
+
* result card looks the task up here on every render rather than freezing a
|
|
2648
|
+
* snapshot into `details` — that is what makes the inline card follow a
|
|
2649
|
+
* background run.
|
|
2650
|
+
*/
|
|
2651
|
+
const workflowTasks = new Map<string, WorkflowTask>();
|
|
2652
|
+
|
|
2653
|
+
/**
|
|
2654
|
+
* Workflow runs as the fleet list wants them.
|
|
2655
|
+
*
|
|
2656
|
+
* Mapped here rather than handing `WorkflowTask` over the seam: the list is
|
|
2657
|
+
* deliberately ignorant of the workflow engine, and a run's counters live in
|
|
2658
|
+
* the progress log rather than on the record, so they are derived per call
|
|
2659
|
+
* the same way the card derives them.
|
|
2660
|
+
*/
|
|
2661
|
+
function fleetWorkflows(): FleetWorkflow[] {
|
|
2662
|
+
// Cached counters only, no derivation: the fleet list calls this on a
|
|
2663
|
+
// 200ms tick and reads the roster several times per update, so walking a
|
|
2664
|
+
// run's progress log here would put O(log) work in the render loop.
|
|
2665
|
+
return [...workflowTasks.values()].map(task => ({
|
|
2666
|
+
id: task.id,
|
|
2667
|
+
name: task.meta?.name ?? task.workflowName ?? task.id,
|
|
2668
|
+
status: task.status,
|
|
2669
|
+
doneCount: task.doneCount,
|
|
2670
|
+
totalCount: task.agentCount,
|
|
2671
|
+
startedAt: task.startTime,
|
|
2672
|
+
...(task.endTime !== undefined ? { completedAt: task.endTime } : {}),
|
|
2673
|
+
tokens: task.totalTokens,
|
|
2674
|
+
}));
|
|
2675
|
+
}
|
|
2676
|
+
|
|
2677
|
+
/**
|
|
2678
|
+
* Run a task to completion against the real manager, settling the record
|
|
2679
|
+
* either way. Never rejects: a run that cannot start (bad `meta`, oversized
|
|
2680
|
+
* source, non-JSON `args`) is a failed workflow, and both callers here are
|
|
2681
|
+
* detached — a rejection would surface as an unhandled one.
|
|
2682
|
+
*/
|
|
2683
|
+
async function runWorkflowTask(ctx: ExtensionContext, task: WorkflowTask): Promise<void> {
|
|
2684
|
+
try {
|
|
2685
|
+
const result = await runWorkflow({
|
|
2686
|
+
script: task.script,
|
|
2687
|
+
args: task.args,
|
|
2688
|
+
signal: task.abortController.signal,
|
|
2689
|
+
host: createWorkflowHost({
|
|
2690
|
+
pi,
|
|
2691
|
+
ctx,
|
|
2692
|
+
manager,
|
|
2693
|
+
signal: task.abortController.signal,
|
|
2694
|
+
rootSessionId: ctx.sessionManager.getSessionId(),
|
|
2695
|
+
workflowId: task.id,
|
|
2696
|
+
}),
|
|
2697
|
+
onProgress: entries => updateWorkflowProgressBatch(task, entries),
|
|
2698
|
+
// The dialog's pause / skip / retry keys run through this; it is dropped
|
|
2699
|
+
// again when the task settles.
|
|
2700
|
+
onControl: control => { task.control = control; },
|
|
2701
|
+
journal: {
|
|
2702
|
+
...(task.replay !== undefined ? { entries: task.replay } : {}),
|
|
2703
|
+
...(task.journalPath !== undefined
|
|
2704
|
+
? { append: (entry: WorkflowJournalEntry) => appendJournal(task.journalPath!, entry) }
|
|
2705
|
+
: {}),
|
|
2706
|
+
},
|
|
2707
|
+
});
|
|
2708
|
+
completeWorkflowTask(task, result);
|
|
2709
|
+
} catch (err) {
|
|
2710
|
+
failWorkflowTask(task, err instanceof Error ? err.message : String(err));
|
|
2711
|
+
}
|
|
2712
|
+
}
|
|
2713
|
+
|
|
2714
|
+
/**
|
|
2715
|
+
* Hand a finished run back to the model through the SAME channel a background
|
|
2716
|
+
* agent uses — the completion outbox, which delivers at the parent's next
|
|
2717
|
+
* boundary — rendered by the existing `subagent-notification` renderer.
|
|
2718
|
+
*/
|
|
2719
|
+
function notifyWorkflowFinished(task: WorkflowTask) {
|
|
2720
|
+
widget.update();
|
|
2721
|
+
fleet.update();
|
|
2722
|
+
const result = workflowResultText(task);
|
|
2723
|
+
const message = {
|
|
2724
|
+
content: formatWorkflowNotification(task),
|
|
2725
|
+
details: {
|
|
2726
|
+
id: task.id,
|
|
2727
|
+
description: `Workflow ${task.workflowName ?? task.id}`,
|
|
2728
|
+
status: task.status === "completed" ? "completed" : task.status === "killed" ? "stopped" : "error",
|
|
2729
|
+
toolUses: task.totalToolCalls,
|
|
2730
|
+
// A workflow has agents, not turns; rendering "↻0" would be noise.
|
|
2731
|
+
turnCount: 0,
|
|
2732
|
+
totalTokens: task.totalTokens,
|
|
2733
|
+
durationMs: elapsedMs(task, Date.now()),
|
|
2734
|
+
error: task.error,
|
|
2735
|
+
resultPreview: result.length > 500 ? `${result.slice(0, 500)}…` : result,
|
|
2736
|
+
},
|
|
2737
|
+
};
|
|
2738
|
+
outbox.add({ ids: [task.id], build: () => message });
|
|
2739
|
+
}
|
|
2740
|
+
|
|
2741
|
+
// Defined unconditionally, registered only when the feature is on — the same
|
|
2742
|
+
// shape the Agent tool uses. Keeping the definition out of the `if` means the
|
|
2743
|
+
// switch changes exactly one thing: whether pi is ever told about the tool.
|
|
2744
|
+
const workflowTool = defineTool({
|
|
2745
|
+
name: SUBAGENT_TOOL_NAMES.WORKFLOW,
|
|
2746
|
+
label: "SubagentWorkflow",
|
|
2747
|
+
description: renderToolDescriptionTemplate(fullWorkflowToolDescription),
|
|
2748
|
+
promptSnippet: "Run a deterministic script that orchestrates many subagents",
|
|
2749
|
+
promptGuidelines: [
|
|
2750
|
+
"Use SubagentWorkflow when the number of agents depends on something discovered at runtime, when work flows through stages, or when findings should be independently verified. Use Agent for one delegated task or a handful you can name up front.",
|
|
2751
|
+
"Prefer `pipeline` over `parallel` — a barrier costs wall-clock whenever the stages are unevenly sized.",
|
|
2752
|
+
"A workflow runs in the background and notifies you when it finishes — do not poll or sleep waiting for it.",
|
|
2753
|
+
],
|
|
2754
|
+
parameters: Type.Object({
|
|
2755
|
+
script: Type.Optional(
|
|
2756
|
+
Type.String({
|
|
2757
|
+
maxLength: 524288,
|
|
2758
|
+
description: "Inline workflow source. Must begin with `export const meta = { name, description }`.",
|
|
2759
|
+
}),
|
|
2760
|
+
),
|
|
2761
|
+
scriptPath: Type.Optional(
|
|
2762
|
+
Type.String({
|
|
2763
|
+
description:
|
|
2764
|
+
"Path to a workflow script file, absolute or relative to the project. Takes precedence over `script` — this is how you re-run an edited workflow.",
|
|
2765
|
+
}),
|
|
2766
|
+
),
|
|
2767
|
+
name: Type.Optional(
|
|
2768
|
+
Type.String({
|
|
2769
|
+
description:
|
|
2770
|
+
"Name of a saved workflow — `<name>.js` in .pi/workflows/, .agents/workflows/ or the user's agent dir. Lowest precedence: `scriptPath` and `script` both win over it.",
|
|
2771
|
+
}),
|
|
2772
|
+
),
|
|
2773
|
+
args: Type.Optional(
|
|
2774
|
+
Type.Any({
|
|
2775
|
+
description: "Exposed to the script as the global `args`, verbatim. Must be JSON-shaped.",
|
|
2776
|
+
}),
|
|
2777
|
+
),
|
|
2778
|
+
resumeFromRunId: Type.Optional(
|
|
2779
|
+
Type.String({
|
|
2780
|
+
pattern: "^wf_[a-z0-9-]{6,}$",
|
|
2781
|
+
description:
|
|
2782
|
+
"Run id of an earlier workflow in this session. Its unchanged leading agent() calls return their recorded results instantly; the first changed or failed call, and everything after it, runs live. Same script and args means nothing re-runs.",
|
|
2783
|
+
}),
|
|
2784
|
+
),
|
|
2785
|
+
// Accepted and ignored, as in Claude Code. Models reach for them because
|
|
2786
|
+
// every other tool has them, and a hard schema rejection would cost a
|
|
2787
|
+
// whole turn to re-emit a script that was already correct. The `meta`
|
|
2788
|
+
// block is the one place a workflow is named.
|
|
2789
|
+
title: Type.Optional(
|
|
2790
|
+
Type.String({ description: "Ignored — set the workflow title in the script's `meta` block." }),
|
|
2791
|
+
),
|
|
2792
|
+
description: Type.Optional(
|
|
2793
|
+
Type.String({ description: "Ignored — set the workflow description in the script's `meta` block." }),
|
|
2794
|
+
),
|
|
2795
|
+
}),
|
|
2796
|
+
|
|
2797
|
+
// No ▸: core draws the disclosure marker on a settled tool row.
|
|
2798
|
+
renderCall(args, theme) {
|
|
2799
|
+
return new Text(
|
|
2800
|
+
`${theme.bold(theme.fg("toolTitle", "SubagentWorkflow"))} ${theme.fg("muted", workflowCallName(args))}`,
|
|
2801
|
+
0,
|
|
2802
|
+
0,
|
|
2803
|
+
);
|
|
2804
|
+
},
|
|
2805
|
+
|
|
2806
|
+
renderResult(result, _options, theme, renderContext) {
|
|
2807
|
+
const text = result.content[0]?.type === "text" ? result.content[0].text : "";
|
|
2808
|
+
const taskId = (result.details as { taskId?: string } | undefined)?.taskId;
|
|
2809
|
+
const task = taskId !== undefined ? workflowTasks.get(taskId) : undefined;
|
|
2810
|
+
// No task means the run predates this session (a reloaded transcript) or
|
|
2811
|
+
// the call never started one — show what `execute` said instead.
|
|
2812
|
+
if (renderContext.isError || !task) return new Text(text, 0, 0);
|
|
2813
|
+
// The card reads the live task; a background run settles its tool call
|
|
2814
|
+
// while still going, so redraw until it stops.
|
|
2815
|
+
keepTicking(renderContext, () => task.status === "running" || task.status === "paused", clock);
|
|
2816
|
+
return renderWorkflowCard(
|
|
2817
|
+
{
|
|
2818
|
+
progress: task.workflowProgress,
|
|
2819
|
+
task: {
|
|
2820
|
+
status: task.status,
|
|
2821
|
+
workflowName: task.workflowName,
|
|
2822
|
+
startTime: task.startTime,
|
|
2823
|
+
endTime: task.endTime,
|
|
2824
|
+
totalPausedMs: task.totalPausedMs,
|
|
2825
|
+
},
|
|
2826
|
+
meta: task.meta,
|
|
2827
|
+
agentCount: task.agentCount,
|
|
2828
|
+
totalTokens: task.totalTokens,
|
|
2829
|
+
},
|
|
2830
|
+
theme,
|
|
2831
|
+
);
|
|
2832
|
+
},
|
|
2833
|
+
|
|
2834
|
+
execute: async (toolCallId, params, _signal, _onUpdate, ctx) => {
|
|
2835
|
+
const resumeFrom = resolveResumeTarget(params.resumeFromRunId, workflowTasks);
|
|
2836
|
+
if (resumeFrom !== undefined && !resumeFrom.ok) return textResult(resumeFrom.message);
|
|
2837
|
+
|
|
2838
|
+
// A resume with no source of its own re-runs what that run ran. The
|
|
2839
|
+
// common case is an edited script, but "run that again, cheaply" should
|
|
2840
|
+
// not require repeating a path the run already knows.
|
|
2841
|
+
const resolved = resolveWorkflowScript(
|
|
2842
|
+
params.script === undefined && params.scriptPath === undefined && params.name === undefined
|
|
2843
|
+
&& resumeFrom !== undefined
|
|
2844
|
+
? { scriptPath: resumeFrom.scriptPath }
|
|
2845
|
+
: params,
|
|
2846
|
+
ctx.cwd,
|
|
2847
|
+
);
|
|
2848
|
+
if (!resolved.ok) return textResult(resolved.message);
|
|
2849
|
+
|
|
2850
|
+
// Parsed before anything is scheduled: a bad `meta` is an authoring error
|
|
2851
|
+
// the model can fix immediately, and reporting it as a background run
|
|
2852
|
+
// that failed a second later would just cost a turn.
|
|
2853
|
+
let meta: WorkflowMeta;
|
|
2854
|
+
try {
|
|
2855
|
+
meta = extractMeta(resolved.script).meta;
|
|
2856
|
+
} catch (err) {
|
|
2857
|
+
return textResult(err instanceof Error ? err.message : String(err));
|
|
2858
|
+
}
|
|
2859
|
+
|
|
2860
|
+
const runId = workflowRunId();
|
|
2861
|
+
// Every invocation lands on disk next to the agent transcripts, so
|
|
2862
|
+
// iterating is edit-the-file-then-rerun-with-scriptPath rather than
|
|
2863
|
+
// re-emitting the whole source. The journal sits beside it under the same
|
|
2864
|
+
// id, which is what makes a run id enough to resume from.
|
|
2865
|
+
let savedPath: string | undefined;
|
|
2866
|
+
let journalPath: string | undefined;
|
|
2867
|
+
try {
|
|
2868
|
+
const dir = sessionTaskDir(ctx.cwd, ctx.sessionManager.getSessionId());
|
|
2869
|
+
savedPath = join(dir, `${runId}.workflow.js`);
|
|
2870
|
+
writeFileSync(savedPath, resolved.script, "utf-8");
|
|
2871
|
+
journalPath = join(dir, `${runId}.workflow.jsonl`);
|
|
2872
|
+
} catch (err) {
|
|
2873
|
+
savedPath = undefined;
|
|
2874
|
+
journalPath = undefined;
|
|
2875
|
+
console.warn(`[pi-subagents] could not persist workflow script: ${err instanceof Error ? err.message : String(err)}`);
|
|
2876
|
+
}
|
|
2877
|
+
|
|
2878
|
+
const replay = resumeFrom !== undefined ? readJournal(resumeFrom.journalPath) : undefined;
|
|
2879
|
+
|
|
2880
|
+
const task = createWorkflowTask({
|
|
2881
|
+
id: runId,
|
|
2882
|
+
script: resolved.script,
|
|
2883
|
+
scriptPath: resolved.scriptPath ?? savedPath,
|
|
2884
|
+
args: params.args,
|
|
2885
|
+
meta,
|
|
2886
|
+
toolCallId,
|
|
2887
|
+
...(journalPath !== undefined ? { journalPath } : {}),
|
|
2888
|
+
...(replay !== undefined && replay.length > 0 ? { replay, resumedFrom: resumeFrom!.runId } : {}),
|
|
2889
|
+
});
|
|
2890
|
+
workflowTasks.set(runId, task);
|
|
2891
|
+
// The run's own row has to appear now, not when it settles. Its agents
|
|
2892
|
+
// are owned by it, so their lifecycle callbacks no longer refresh these
|
|
2893
|
+
// surfaces — nothing else would register the widget for a run whose
|
|
2894
|
+
// first agent has not started yet.
|
|
2895
|
+
widget.update();
|
|
2896
|
+
fleet.update();
|
|
2897
|
+
|
|
2898
|
+
// Background, like Claude Code: the id comes back now and the run keeps
|
|
2899
|
+
// going without the tool call.
|
|
2900
|
+
void runWorkflowTask(ctx, task).then(() => notifyWorkflowFinished(task));
|
|
2901
|
+
|
|
2902
|
+
return {
|
|
2903
|
+
content: [{
|
|
2904
|
+
type: "text" as const,
|
|
2905
|
+
text:
|
|
2906
|
+
`Workflow "${meta.name}" started in the background.\n` +
|
|
2907
|
+
`Task ID: ${runId}\n` +
|
|
2908
|
+
(task.scriptPath ? `Script: ${task.scriptPath}\n` : "") +
|
|
2909
|
+
(task.resumedFrom !== undefined
|
|
2910
|
+
? `Resuming ${task.resumedFrom}: ${task.replay?.length ?? 0} recorded call(s) available to replay.\n`
|
|
2911
|
+
: params.resumeFromRunId !== undefined
|
|
2912
|
+
? `Nothing to replay from ${params.resumeFromRunId} — every agent runs live.\n`
|
|
2913
|
+
: "") +
|
|
2914
|
+
`\nYou will be notified when it finishes — do NOT poll or sleep waiting for it.\n` +
|
|
2915
|
+
`To iterate, edit the script file and call SubagentWorkflow again with scriptPath.`,
|
|
2916
|
+
}],
|
|
2917
|
+
details: { taskId: runId },
|
|
2918
|
+
};
|
|
2919
|
+
},
|
|
2920
|
+
});
|
|
2921
|
+
|
|
2922
|
+
if (isWorkflowsEnabled()) pi.registerTool(workflowTool);
|
|
2923
|
+
|
|
2924
|
+
/**
|
|
2925
|
+
* Act on {@link decideWorkflowCollision} — the half that needs the host.
|
|
2926
|
+
*
|
|
2927
|
+
* The policy (what counts as a conflict, what a pin changes, whether there is
|
|
2928
|
+
* anything left to withdraw) lives in `workflow/collisions.ts`; this is the
|
|
2929
|
+
* host-facing shell around it: read the registry, warn, and take our tool out
|
|
2930
|
+
* of the active set.
|
|
2931
|
+
*
|
|
2932
|
+
* ## Why this can only happen at session_start
|
|
2933
|
+
*
|
|
2934
|
+
* `getAllTools` throws during extension loading ("Action methods cannot be
|
|
2935
|
+
* called during extension loading"), and load order means a check at
|
|
2936
|
+
* registration time could not see an extension that has not loaded yet. So
|
|
2937
|
+
* the decision cannot gate `registerTool`; it has to undo it. `setActiveTools`
|
|
2938
|
+
* is what makes that real rather than cosmetic — pi rebuilds the system
|
|
2939
|
+
* prompt from the new set, and `session_start` runs before any turn, so the
|
|
2940
|
+
* model never sees a spec we withdrew. A later `_refreshToolRegistry` keeps
|
|
2941
|
+
* the active set it had and only adds names new to the registry, so ours does
|
|
2942
|
+
* not creep back.
|
|
2943
|
+
*
|
|
2944
|
+
* Best-effort and swallowed. A diagnostic that took the session down would be
|
|
2945
|
+
* worse than the collision it reports.
|
|
2946
|
+
*/
|
|
2947
|
+
let collisionsChecked = false;
|
|
2948
|
+
function resolveWorkflowCollisions(ctx: ExtensionContext): void {
|
|
2949
|
+
if (collisionsChecked) return;
|
|
2950
|
+
collisionsChecked = true;
|
|
2951
|
+
|
|
2952
|
+
const warn = (message: string) => {
|
|
2953
|
+
if (ctx.hasUI) ctx.ui.notify(message, "warning");
|
|
2954
|
+
else console.warn(`[pi-subagents] ${message}`);
|
|
2955
|
+
};
|
|
2956
|
+
|
|
2957
|
+
try {
|
|
2958
|
+
if (!isWorkflowsEnabled()) return;
|
|
2959
|
+
|
|
2960
|
+
const verdict = decideWorkflowCollision({
|
|
2961
|
+
tools: pi.getAllTools(),
|
|
2962
|
+
// Identifies our own registration: this extension does not know its
|
|
2963
|
+
// install path, and the description is the one field certainly ours.
|
|
2964
|
+
ownDescription: workflowTool.description,
|
|
2965
|
+
pinned: isWorkflowsPinned(),
|
|
2966
|
+
});
|
|
2967
|
+
if (verdict.kind === "none") return;
|
|
2968
|
+
if (verdict.kind === "report") {
|
|
2969
|
+
warn(verdict.message);
|
|
2970
|
+
return;
|
|
2971
|
+
}
|
|
2972
|
+
|
|
2973
|
+
workflowsEnabled = false; // not setWorkflowsEnabled: this is not the user pinning it
|
|
2974
|
+
widget.update();
|
|
2975
|
+
fleet.update();
|
|
2976
|
+
warn(verdict.message);
|
|
2977
|
+
|
|
2978
|
+
if (!verdict.withdraw) return;
|
|
2979
|
+
const active = pi.getActiveTools();
|
|
2980
|
+
if (active.includes(SUBAGENT_TOOL_NAMES.WORKFLOW)) {
|
|
2981
|
+
pi.setActiveTools(active.filter(name => name !== SUBAGENT_TOOL_NAMES.WORKFLOW));
|
|
2982
|
+
}
|
|
2983
|
+
} catch {
|
|
2984
|
+
// getAllTools/setActiveTools are unavailable in some hosts (print mode,
|
|
2985
|
+
// RPC). Not being able to check is not a reason to fail the session.
|
|
2986
|
+
}
|
|
2987
|
+
}
|
|
2988
|
+
|
|
2989
|
+
/**
|
|
2990
|
+
* `--subagents-workflow-file=<path>` — run a script at startup, with no LLM
|
|
2991
|
+
* round-trip deciding whether to call the tool.
|
|
2992
|
+
*
|
|
2993
|
+
* Read here rather than at activation because that is the only place the real
|
|
2994
|
+
* value exists: the host activates extensions first and applies collected CLI
|
|
2995
|
+
* flags second, so `getFlag` during activation returns the registered default
|
|
2996
|
+
* and nothing else. `examples/extensions/ssh.ts` reads its flag from
|
|
2997
|
+
* session_start for exactly this reason.
|
|
2998
|
+
*/
|
|
2999
|
+
let workflowFlagHandled = false;
|
|
3000
|
+
function runWorkflowFlag(ctx: ExtensionContext): void {
|
|
3001
|
+
if (workflowFlagHandled) return;
|
|
3002
|
+
const flag = pi.getFlag(WORKFLOW_FILE_FLAG);
|
|
3003
|
+
if (flag === undefined || flag === false) return;
|
|
3004
|
+
workflowFlagHandled = true;
|
|
3005
|
+
|
|
3006
|
+
const report = (message: string, level: "info" | "warning") => {
|
|
3007
|
+
if (ctx.hasUI) ctx.ui.notify(message, level);
|
|
3008
|
+
else console.warn(`[pi-subagents] ${message}`);
|
|
3009
|
+
};
|
|
3010
|
+
|
|
3011
|
+
// The flag is the same machinery by another door, so the master switch has
|
|
3012
|
+
// to close it too — silently ignoring a flag the user typed would be worse
|
|
3013
|
+
// than saying why nothing ran.
|
|
3014
|
+
if (!isWorkflowsEnabled()) {
|
|
3015
|
+
report(
|
|
3016
|
+
`--${WORKFLOW_FILE_FLAG} ignored: workflows are off. Turn them on in /agents → Settings → Workflows, ` +
|
|
3017
|
+
'or set `"workflowsEnabled": true` in .pi/subagents.json.',
|
|
3018
|
+
"warning",
|
|
3019
|
+
);
|
|
3020
|
+
return;
|
|
3021
|
+
}
|
|
3022
|
+
|
|
3023
|
+
// A bare `--subagents-workflow-file` parses to boolean `true`. Say what was
|
|
3024
|
+
// missing rather than reading a file called "true".
|
|
3025
|
+
if (typeof flag !== "string" || flag.trim() === "") {
|
|
3026
|
+
report(`--${WORKFLOW_FILE_FLAG} needs a path: --${WORKFLOW_FILE_FLAG}=<path>`, "warning");
|
|
3027
|
+
return;
|
|
3028
|
+
}
|
|
3029
|
+
|
|
3030
|
+
const path = isAbsolute(flag.trim()) ? flag.trim() : join(ctx.cwd, flag.trim());
|
|
3031
|
+
let script: string;
|
|
3032
|
+
try {
|
|
3033
|
+
script = readFileSync(path, "utf-8");
|
|
3034
|
+
} catch (err) {
|
|
3035
|
+
report(`Could not read ${path}: ${err instanceof Error ? err.message : String(err)}`, "warning");
|
|
3036
|
+
return;
|
|
3037
|
+
}
|
|
3038
|
+
|
|
3039
|
+
let meta: WorkflowMeta | undefined;
|
|
3040
|
+
try {
|
|
3041
|
+
meta = extractMeta(script).meta;
|
|
3042
|
+
} catch (err) {
|
|
3043
|
+
report(err instanceof Error ? err.message : String(err), "warning");
|
|
3044
|
+
return;
|
|
3045
|
+
}
|
|
3046
|
+
|
|
3047
|
+
const task = createWorkflowTask({ id: workflowRunId(), script, scriptPath: path, meta });
|
|
3048
|
+
workflowTasks.set(task.id, task);
|
|
3049
|
+
widget.update();
|
|
3050
|
+
fleet.update();
|
|
3051
|
+
report(`Running workflow ${meta.name}…`, "info");
|
|
3052
|
+
|
|
3053
|
+
// Detached: session_start is awaited by the host, and a workflow can run for
|
|
3054
|
+
// minutes — blocking here would hold the whole session's startup.
|
|
3055
|
+
void runWorkflowTask(ctx, task).then(() => {
|
|
3056
|
+
// No tool call to attach a result card to, so the card becomes a session
|
|
3057
|
+
// entry (same layout), and the outcome is handed to the model as context
|
|
3058
|
+
// for its next turn rather than forcing one.
|
|
3059
|
+
pi.appendEntry<WorkflowEntryData>(WORKFLOW_ENTRY_TYPE, workflowEntryData(task));
|
|
3060
|
+
pi.sendMessage({
|
|
3061
|
+
customType: "workflow-result",
|
|
3062
|
+
content: formatWorkflowNotification(task),
|
|
3063
|
+
display: false,
|
|
3064
|
+
}, { deliverAs: "nextTurn" });
|
|
3065
|
+
widget.update();
|
|
3066
|
+
fleet.update();
|
|
3067
|
+
});
|
|
3068
|
+
}
|
|
3069
|
+
|
|
3070
|
+
/** "Not found", or what became of the agent when its record was evicted. */
|
|
3071
|
+
function agentNotFound(ref: string): string {
|
|
3072
|
+
const note = manager.getEvictedNote(ref);
|
|
3073
|
+
return note ? describeEvicted(note) : `Agent not found: "${ref}". It may have been cleaned up.`;
|
|
3074
|
+
}
|
|
3075
|
+
|
|
3076
|
+
// ---- get_subagent_result tool ----
|
|
3077
|
+
|
|
3078
|
+
registerToolReportingUsage(defineTool({
|
|
3079
|
+
name: SUBAGENT_TOOL_NAMES.GET_RESULT,
|
|
3080
|
+
label: "Get Agent Result",
|
|
3081
|
+
description:
|
|
3082
|
+
"Check status and retrieve a background agent's full result — its completion notification carries only a preview. Use the agent ID returned by Agent.",
|
|
3083
|
+
promptSnippet: "Check status and retrieve results from a background agent",
|
|
3084
|
+
parameters: Type.Object({
|
|
3085
|
+
agent_id: Type.String({
|
|
3086
|
+
description: "The agent ID to check. The agent's handle also works — its `name` if you gave it one, otherwise its type (`explore`, `explore-2`).",
|
|
3087
|
+
}),
|
|
3088
|
+
wait: Type.Optional(
|
|
3089
|
+
Type.Boolean({
|
|
3090
|
+
description: "If true, wait for the agent to complete before returning. Default: false.",
|
|
3091
|
+
}),
|
|
3092
|
+
),
|
|
3093
|
+
verbose: Type.Optional(
|
|
3094
|
+
Type.Boolean({
|
|
3095
|
+
description: "If true, include the agent's full conversation (messages + tool calls). Default: false.",
|
|
3096
|
+
}),
|
|
3097
|
+
),
|
|
3098
|
+
}),
|
|
3099
|
+
execute: async (_toolCallId, params, signal, _onUpdate, _ctx) => {
|
|
3100
|
+
const record = resolveAgentRef(params.agent_id);
|
|
3101
|
+
if (!record || !isTopLevelAgent(record)) return textResult(agentNotFound(params.agent_id));
|
|
3102
|
+
|
|
3103
|
+
// Wait for completion if requested. Cancellation stops only this tool
|
|
3104
|
+
// call; the background agent keeps running and remains unconsumed so its
|
|
3105
|
+
// completion notification can still be delivered.
|
|
3106
|
+
// Queued agents have no promise yet (it's created when the queue starts
|
|
3107
|
+
// them), so poll until they leave the queue, then await like a running one.
|
|
3108
|
+
if (params.wait && (record.status === "running" || record.status === "queued")) {
|
|
3109
|
+
while (record.status === "queued") {
|
|
3110
|
+
await abortable(record.startGate ?? new Promise<void>((resolve) => clock.setTimeout(resolve, QUEUE_WAIT_POLL_MS)), signal);
|
|
3111
|
+
}
|
|
3112
|
+
if (record.promise) await abortable(record.promise, signal);
|
|
3113
|
+
}
|
|
3114
|
+
|
|
3115
|
+
const displayName = getDisplayName(record.type);
|
|
3116
|
+
const duration = formatDuration(record.startedAt, record.completedAt, clock.now());
|
|
3117
|
+
const tokens = formatLifetimeTokens(record);
|
|
3118
|
+
const contextPercent = getSessionContextPercent(record.session);
|
|
3119
|
+
const statsParts = [`Tool uses: ${record.toolUses}`];
|
|
3120
|
+
if (tokens) statsParts.push(tokens);
|
|
3121
|
+
if (showCost) {
|
|
3122
|
+
const costText = formatCost(getLifetimeCost(record.lifetimeUsage));
|
|
3123
|
+
if (costText) statsParts.push(`Cost: ${costText}`);
|
|
3124
|
+
}
|
|
3125
|
+
if (contextPercent !== null) statsParts.push(`Context: ${Math.round(contextPercent)}%`);
|
|
3126
|
+
if (record.compactionCount) statsParts.push(`Compactions: ${record.compactionCount}`);
|
|
3127
|
+
statsParts.push(`Duration: ${duration}`);
|
|
3128
|
+
|
|
3129
|
+
let output =
|
|
3130
|
+
`Agent: ${record.id}\n` +
|
|
3131
|
+
`Type: ${displayName} | Status: ${record.status}${getStatusNote(record.status)} | ${statsParts.join(" | ")}\n` +
|
|
3132
|
+
`Description: ${record.description}\n\n`;
|
|
3133
|
+
|
|
3134
|
+
if (record.status === "running") {
|
|
3135
|
+
output += "Agent is still running. Use wait: true or check back later.";
|
|
3136
|
+
} else if (record.status === "error") {
|
|
3137
|
+
output += `Error: ${record.error}${partialOutputSuffix(record)}`;
|
|
3138
|
+
} else {
|
|
3139
|
+
output += record.result?.trim() || "No output.";
|
|
3140
|
+
}
|
|
3141
|
+
|
|
3142
|
+
// Mark result as consumed — suppresses the completion notification
|
|
3143
|
+
if (record.status !== "running" && record.status !== "queued") {
|
|
3144
|
+
markConsumed(record, clock.now());
|
|
3145
|
+
outbox.cancel(record.id);
|
|
3146
|
+
}
|
|
3147
|
+
|
|
3148
|
+
// Verbose: include full conversation
|
|
3149
|
+
if (params.verbose && record.session) {
|
|
3150
|
+
const conversation = getAgentConversation(record.session);
|
|
3151
|
+
if (conversation) {
|
|
3152
|
+
output += `\n\n--- Agent Conversation ---\n${conversation}`;
|
|
3153
|
+
}
|
|
3154
|
+
}
|
|
3155
|
+
|
|
3156
|
+
return textResult(output);
|
|
3157
|
+
},
|
|
3158
|
+
}));
|
|
3159
|
+
|
|
3160
|
+
// ---- steer_subagent tool ----
|
|
3161
|
+
|
|
3162
|
+
registerToolReportingUsage(defineTool({
|
|
3163
|
+
name: SUBAGENT_TOOL_NAMES.STEER,
|
|
3164
|
+
label: "Steer Agent",
|
|
3165
|
+
description:
|
|
3166
|
+
"Send a steering message to a running agent. The message will interrupt the agent after its current tool execution " +
|
|
3167
|
+
"and be injected into its conversation, allowing you to redirect its work mid-run. A queued agent gets the message when it starts.",
|
|
3168
|
+
promptSnippet: "Send a steering message to redirect a running background agent",
|
|
3169
|
+
parameters: Type.Object({
|
|
3170
|
+
agent_id: Type.String({
|
|
3171
|
+
description: "The agent ID to steer (must be running or queued). The agent's handle also works — its `name` if you gave it one, otherwise its type (`explore`, `explore-2`).",
|
|
3172
|
+
}),
|
|
3173
|
+
message: Type.String({
|
|
3174
|
+
description: "The steering message to send. This will appear as a user message in the agent's conversation.",
|
|
3175
|
+
}),
|
|
3176
|
+
}),
|
|
3177
|
+
execute: async (_toolCallId, params, _signal, _onUpdate, _ctx) => {
|
|
3178
|
+
const record = resolveAgentRef(params.agent_id);
|
|
3179
|
+
if (!record || !isTopLevelAgent(record)) return textResult(agentNotFound(params.agent_id));
|
|
3180
|
+
if (record.status !== "running" && record.status !== "queued") {
|
|
3181
|
+
return textResult(`Agent "${params.agent_id}" is not running (status: ${record.status}). Cannot steer a finished agent.`);
|
|
3182
|
+
}
|
|
3183
|
+
if (!record.session) {
|
|
3184
|
+
// Queued for a concurrency slot, or running with its session still
|
|
3185
|
+
// being built: either way the manager flushes `pendingSteers` into the
|
|
3186
|
+
// session the moment it exists, ahead of the first turn.
|
|
3187
|
+
if (!record.pendingSteers) record.pendingSteers = [];
|
|
3188
|
+
record.pendingSteers.push(params.message);
|
|
3189
|
+
pi.events.emit("subagents:steered", { id: record.id, message: params.message });
|
|
3190
|
+
return textResult(record.status === "queued"
|
|
3191
|
+
? `Steering message queued for agent ${record.id} (status: queued). It will be delivered when the agent starts.`
|
|
3192
|
+
: `Steering message queued for agent ${record.id}. It will be delivered once the session initializes.`);
|
|
3193
|
+
}
|
|
3194
|
+
|
|
3195
|
+
try {
|
|
3196
|
+
await steerAgent(record.session, params.message);
|
|
3197
|
+
pi.events.emit("subagents:steered", { id: record.id, message: params.message });
|
|
3198
|
+
const tokens = formatLifetimeTokens(record);
|
|
3199
|
+
const contextPercent = getSessionContextPercent(record.session);
|
|
3200
|
+
const stateParts: string[] = [];
|
|
3201
|
+
if (tokens) stateParts.push(tokens);
|
|
3202
|
+
if (showCost) {
|
|
3203
|
+
const costText = formatCost(getLifetimeCost(record.lifetimeUsage));
|
|
3204
|
+
if (costText) stateParts.push(costText);
|
|
3205
|
+
}
|
|
3206
|
+
stateParts.push(`${record.toolUses} tool ${record.toolUses === 1 ? "use" : "uses"}`);
|
|
3207
|
+
if (contextPercent !== null) stateParts.push(`context ${Math.round(contextPercent)}% full`);
|
|
3208
|
+
if (record.compactionCount) stateParts.push(`${record.compactionCount} compaction${record.compactionCount === 1 ? "" : "s"}`);
|
|
3209
|
+
return textResult(
|
|
3210
|
+
`Steering message sent to agent ${record.id}. The agent will process it after its current tool execution.\n` +
|
|
3211
|
+
`Current state: ${stateParts.join(" · ")}`,
|
|
3212
|
+
);
|
|
3213
|
+
} catch (err) {
|
|
3214
|
+
return textResult(`Failed to steer agent: ${err instanceof Error ? err.message : String(err)}`);
|
|
3215
|
+
}
|
|
3216
|
+
},
|
|
3217
|
+
}));
|
|
3218
|
+
|
|
3219
|
+
// ---- /agents interactive menu ----
|
|
3220
|
+
|
|
3221
|
+
// Directory resolution and the frontmatter edits live in agent-file-toggle.ts
|
|
3222
|
+
// so they are reachable from tests — this command handler is only registered
|
|
3223
|
+
// through `registerCommand`, which every test mocks.
|
|
3224
|
+
|
|
3225
|
+
function getModelLabel(type: string, registry?: ModelRegistry): string {
|
|
3226
|
+
const cfg = getAgentConfig(type);
|
|
3227
|
+
if (!cfg?.model) return "inherit"; // no model configured → really inherits parent
|
|
3228
|
+
const label = getModelLabelFromConfig(cfg.model);
|
|
3229
|
+
if (!registry) return label;
|
|
3230
|
+
const resolved = resolveModel(cfg.model, registry);
|
|
3231
|
+
// Configured but unresolvable: the runtime silently falls back to the parent
|
|
3232
|
+
// model, so flag it (and the fallback) rather than hiding the config.
|
|
3233
|
+
if (typeof resolved === "string") return `${label} (unavailable, fallback: inherit)`;
|
|
3234
|
+
// Surface what it actually resolved to when that differs from the config —
|
|
3235
|
+
// e.g. a provider fallback or a looser version pin. Cosmetic separator/date
|
|
3236
|
+
// differences are normalized away so an effectively-identical match stays quiet.
|
|
3237
|
+
const resolvedFull = `${resolved.provider}/${resolved.id}`;
|
|
3238
|
+
const norm = (s: string) => s.toLowerCase().replace(/\./g, "-").replace(/-\d{8}$/, "");
|
|
3239
|
+
if (norm(cfg.model) === norm(resolvedFull)) return label;
|
|
3240
|
+
return `${label} (→ ${resolvedFull.replace(/-\d{8}$/, "")})`;
|
|
3241
|
+
}
|
|
3242
|
+
|
|
3243
|
+
async function showAgentsMenu(ctx: ExtensionCommandContext) {
|
|
3244
|
+
reloadCustomAgents();
|
|
3245
|
+
const allNames = getAllTypes();
|
|
3246
|
+
|
|
3247
|
+
// Build select options
|
|
3248
|
+
const options: string[] = [];
|
|
3249
|
+
|
|
3250
|
+
// Running agents entry (only if there are active agents)
|
|
3251
|
+
const agents = manager.listAgents().filter(isTopLevelAgent);
|
|
3252
|
+
if (agents.length > 0) {
|
|
3253
|
+
const running = agents.filter(a => a.status === "running" || a.status === "queued").length;
|
|
3254
|
+
const done = agents.filter(a => a.status === "completed" || a.status === "steered").length;
|
|
3255
|
+
options.push(`Running agents (${agents.length}) — ${running} running, ${done} done`);
|
|
3256
|
+
}
|
|
3257
|
+
|
|
3258
|
+
// Agent types list
|
|
3259
|
+
if (allNames.length > 0) {
|
|
3260
|
+
options.push(`Agent types (${allNames.length})`);
|
|
3261
|
+
}
|
|
3262
|
+
|
|
3263
|
+
// Scheduled jobs entry (always present when scheduler is active)
|
|
3264
|
+
if (scheduler.isActive()) {
|
|
3265
|
+
const jobCount = scheduler.list().length;
|
|
3266
|
+
options.push(`Scheduled jobs (${jobCount})`);
|
|
3267
|
+
}
|
|
3268
|
+
|
|
3269
|
+
// Workflow runs, on the same terms as scheduled jobs: shown only when the
|
|
3270
|
+
// feature is on, so the menu never advertises something switched off.
|
|
3271
|
+
if (isWorkflowsEnabled()) {
|
|
3272
|
+
options.push(`Workflows (${workflowTasks.size})`);
|
|
3273
|
+
}
|
|
3274
|
+
|
|
3275
|
+
// Actions
|
|
3276
|
+
options.push("Create new agent");
|
|
3277
|
+
options.push("Settings");
|
|
3278
|
+
|
|
3279
|
+
const noAgentsMsg = allNames.length === 0 && agents.length === 0
|
|
3280
|
+
? "No agents found. Create specialized subagents that can be delegated to.\n\n" +
|
|
3281
|
+
"Each subagent has its own context window, custom system prompt, and specific tools.\n\n" +
|
|
3282
|
+
"Try creating: Code Reviewer, Security Auditor, Test Writer, or Documentation Writer.\n\n"
|
|
3283
|
+
: "";
|
|
3284
|
+
|
|
3285
|
+
if (noAgentsMsg) {
|
|
3286
|
+
ctx.ui.notify(noAgentsMsg, "info");
|
|
3287
|
+
}
|
|
3288
|
+
|
|
3289
|
+
const choice = await ctx.ui.select("Agents", options);
|
|
3290
|
+
if (!choice) return;
|
|
3291
|
+
|
|
3292
|
+
if (choice.startsWith("Running agents (")) {
|
|
3293
|
+
await showRunningAgents(ctx);
|
|
3294
|
+
await showAgentsMenu(ctx);
|
|
3295
|
+
} else if (choice.startsWith("Agent types (")) {
|
|
3296
|
+
await showAllAgentsList(ctx);
|
|
3297
|
+
await showAgentsMenu(ctx);
|
|
3298
|
+
} else if (choice.startsWith("Scheduled jobs (")) {
|
|
3299
|
+
await showSchedulesMenu(ctx, scheduler);
|
|
3300
|
+
await showAgentsMenu(ctx);
|
|
3301
|
+
} else if (choice.startsWith("Workflows (")) {
|
|
3302
|
+
await showWorkflowsMenu(ctx, workflowMenuDeps);
|
|
3303
|
+
await showAgentsMenu(ctx);
|
|
3304
|
+
} else if (choice === "Create new agent") {
|
|
3305
|
+
await showCreateWizard(ctx);
|
|
3306
|
+
} else if (choice === "Settings") {
|
|
3307
|
+
await showSettings(ctx);
|
|
3308
|
+
await showAgentsMenu(ctx);
|
|
3309
|
+
}
|
|
3310
|
+
}
|
|
3311
|
+
|
|
3312
|
+
/**
|
|
3313
|
+
* The package that provided an agent, for display only. Undefined for every
|
|
3314
|
+
* other source, and for a package path no currently-admitted package owns —
|
|
3315
|
+
* which is what a stale `sourcePath` looks like after the gate narrows.
|
|
3316
|
+
*/
|
|
3317
|
+
function packageNameOf(cfg: AgentConfig | undefined): string | undefined {
|
|
3318
|
+
if (cfg?.source !== "package" || !cfg.sourcePath) return undefined;
|
|
3319
|
+
return packageNameForPath(cfg.sourcePath, process.cwd());
|
|
3320
|
+
}
|
|
3321
|
+
|
|
3322
|
+
async function showAllAgentsList(ctx: ExtensionCommandContext) {
|
|
3323
|
+
const allNames = getAllTypes();
|
|
3324
|
+
if (allNames.length === 0) {
|
|
3325
|
+
ctx.ui.notify("No agents.", "info");
|
|
3326
|
+
return;
|
|
3327
|
+
}
|
|
3328
|
+
|
|
3329
|
+
const configs = allNames.map(name => getAgentConfig(name));
|
|
3330
|
+
|
|
3331
|
+
// One row per agent (name in the left column, model on the right); the
|
|
3332
|
+
// full description renders below the highlighted row via SettingsList,
|
|
3333
|
+
// exactly like the Settings menu — so long descriptions never wrap the list.
|
|
3334
|
+
// A package agent's description is prefixed with the package that provided
|
|
3335
|
+
// it: the `▪` badge says a package won, not which one.
|
|
3336
|
+
const items: SettingItem[] = allNames.map((name, i) => {
|
|
3337
|
+
const cfg = configs[i];
|
|
3338
|
+
const model = getModelLabel(name, ctx.modelRegistry);
|
|
3339
|
+
return {
|
|
3340
|
+
id: name,
|
|
3341
|
+
label: `${sourceIndicator(cfg)}${name}`,
|
|
3342
|
+
currentValue: model,
|
|
3343
|
+
description: rowDescription(name, cfg, packageNameOf(cfg)),
|
|
3344
|
+
// Single-value list so Enter "activates" the row (fires onChange with the
|
|
3345
|
+
// agent's id) without offering anything to actually cycle.
|
|
3346
|
+
values: [model],
|
|
3347
|
+
};
|
|
3348
|
+
});
|
|
3349
|
+
|
|
3350
|
+
const legend = sourceLegend(configs);
|
|
3351
|
+
|
|
3352
|
+
const selected = await ctx.ui.custom<string | undefined>((_tui, _theme, _kb, done) => {
|
|
3353
|
+
const slTheme = getSettingsListTheme();
|
|
3354
|
+
const list = new SettingsList(
|
|
3355
|
+
items,
|
|
3356
|
+
Math.min(items.length, 12),
|
|
3357
|
+
slTheme,
|
|
3358
|
+
id => done(id), // Enter/Space on a row → return that agent's name
|
|
3359
|
+
() => done(undefined), // Esc → cancel
|
|
3360
|
+
);
|
|
3361
|
+
const container = new Container();
|
|
3362
|
+
container.addChild(new Text("Agent types", 0, 0));
|
|
3363
|
+
if (legend) container.addChild(new Text(slTheme.hint(legend), 0, 0));
|
|
3364
|
+
container.addChild(new Spacer(1));
|
|
3365
|
+
container.addChild(list);
|
|
3366
|
+
return {
|
|
3367
|
+
render: (w: number) => container.render(w),
|
|
3368
|
+
invalidate: () => container.invalidate(),
|
|
3369
|
+
handleInput: (data: string) => list.handleInput?.(data),
|
|
3370
|
+
};
|
|
3371
|
+
});
|
|
3372
|
+
|
|
3373
|
+
if (selected && getAgentConfig(selected)) {
|
|
3374
|
+
await showAgentDetail(ctx, selected);
|
|
3375
|
+
await showAllAgentsList(ctx);
|
|
3376
|
+
}
|
|
3377
|
+
}
|
|
3378
|
+
|
|
3379
|
+
async function showRunningAgents(ctx: ExtensionCommandContext) {
|
|
3380
|
+
const agents = manager.listAgents().filter(isTopLevelAgent);
|
|
3381
|
+
if (agents.length === 0) {
|
|
3382
|
+
ctx.ui.notify("No agents.", "info");
|
|
3383
|
+
return;
|
|
3384
|
+
}
|
|
3385
|
+
|
|
3386
|
+
// Numbered + item-paired. Two same-type agents spawned together with the
|
|
3387
|
+
// same description render identically here, and resolving the choice by
|
|
3388
|
+
// string match would open whichever came first.
|
|
3389
|
+
const record = await selectItem(ctx.ui, "Running agents", agents, a => {
|
|
3390
|
+
const dn = getDisplayName(a.type);
|
|
3391
|
+
const dur = formatDuration(a.startedAt, a.completedAt, clock.now());
|
|
3392
|
+
return `${dn} (${a.description}) · ${a.toolUses} tools · ${a.status} · ${dur}`;
|
|
3393
|
+
});
|
|
3394
|
+
if (!record) return;
|
|
3395
|
+
|
|
3396
|
+
await viewAgentConversation(ctx, record);
|
|
3397
|
+
// Back-navigation: re-show the list
|
|
3398
|
+
await showRunningAgents(ctx);
|
|
3399
|
+
}
|
|
3400
|
+
|
|
3401
|
+
async function viewAgentConversation(ctx: ExtensionCommandContext, record: AgentRecord) {
|
|
3402
|
+
if (!record.session) {
|
|
3403
|
+
ctx.ui.notify(`Agent is ${record.status === "queued" ? "queued" : "expired"} — no session available.`, "info");
|
|
3404
|
+
return;
|
|
3405
|
+
}
|
|
3406
|
+
|
|
3407
|
+
const session = record.session;
|
|
3408
|
+
const activity = agentActivity.get(record.id);
|
|
3409
|
+
let overlayOptions = getConversationOverlayOptions({ mode: "regular" });
|
|
3410
|
+
|
|
3411
|
+
await ctx.ui.custom<undefined>(
|
|
3412
|
+
(tui, theme, keybindings, done) => {
|
|
3413
|
+
overlayOptions = getConversationOverlayOptions(tui);
|
|
3414
|
+
return new ConversationViewer(tui, session, record, activity, theme, done, () => {
|
|
3415
|
+
if (manager.abort(record.id)) {
|
|
3416
|
+
ctx.ui.notify(`Stopped "${record.description}".`, "info");
|
|
3417
|
+
}
|
|
3418
|
+
}, keybindings, (message: string) => manager.steer(record.id, message), showCost, getViewerMarkdown, (mode) => chooseViewerMarkdown(mode, ctx), getFullscreenScrollbar(),
|
|
3419
|
+
backgroundActionFor(record, message => ctx.ui.notify(message, "info")), clock);
|
|
3420
|
+
},
|
|
3421
|
+
{
|
|
3422
|
+
overlay: true,
|
|
3423
|
+
overlayOptions: () => overlayOptions,
|
|
3424
|
+
},
|
|
3425
|
+
);
|
|
3426
|
+
}
|
|
3427
|
+
|
|
3428
|
+
async function showAgentDetail(ctx: ExtensionCommandContext, name: string) {
|
|
3429
|
+
const cfg = getAgentConfig(name);
|
|
3430
|
+
if (!cfg) {
|
|
3431
|
+
ctx.ui.notify(`Agent config not found for "${name}".`, "warning");
|
|
3432
|
+
return;
|
|
3433
|
+
}
|
|
3434
|
+
|
|
3435
|
+
const isPackage = cfg.source === "package";
|
|
3436
|
+
const file = locateAgentFile(name, cfg.sourcePath, undefined, cfg.source);
|
|
3437
|
+
const isDefault = cfg.isDefault === true;
|
|
3438
|
+
const disabled = cfg.enabled === false;
|
|
3439
|
+
|
|
3440
|
+
let menuOptions: string[];
|
|
3441
|
+
if (isPackage) {
|
|
3442
|
+
// Read-only: nothing here may write inside pi's install root. `Disable`
|
|
3443
|
+
// still appears because it writes a *local* stub that shadows the package
|
|
3444
|
+
// agent, and `Enable` never does — a package agent that reads as disabled
|
|
3445
|
+
// was disabled by its own author's `enabled: false`, which only the
|
|
3446
|
+
// package can take back. Eject is the way out of that: the copy it writes
|
|
3447
|
+
// carries no `enabled:` line, so the ejected agent is on.
|
|
3448
|
+
menuOptions = disabled
|
|
3449
|
+
? ["Eject (export as .md)", "Back"]
|
|
3450
|
+
: ["Eject (export as .md)", "Disable", "Back"];
|
|
3451
|
+
} else if (disabled && file) {
|
|
3452
|
+
// Disabled agent with a file — offer Enable
|
|
3453
|
+
menuOptions = isDefault
|
|
3454
|
+
? ["Enable", "Edit", "Reset to default", "Delete", "Back"]
|
|
3455
|
+
: ["Enable", "Edit", "Delete", "Back"];
|
|
3456
|
+
} else if (isDefault && !file) {
|
|
3457
|
+
// Default agent with no .md override
|
|
3458
|
+
menuOptions = ["Eject (export as .md)", "Disable", "Back"];
|
|
3459
|
+
} else if (isDefault && file) {
|
|
3460
|
+
// Default agent with .md override (ejected)
|
|
3461
|
+
menuOptions = ["Edit", "Disable", "Reset to default", "Delete", "Back"];
|
|
3462
|
+
} else {
|
|
3463
|
+
// User-defined agent
|
|
3464
|
+
menuOptions = ["Edit", "Disable", "Delete", "Back"];
|
|
3465
|
+
}
|
|
3466
|
+
|
|
3467
|
+
// Name the package in the title: this menu's options are the read-only set,
|
|
3468
|
+
// and "which package do I have to uninstall or allowlist away" is the
|
|
3469
|
+
// question they raise.
|
|
3470
|
+
const pkgName = packageNameOf(cfg);
|
|
3471
|
+
const choice = await ctx.ui.select(pkgName ? `${name} — ${pkgName}` : name, menuOptions);
|
|
3472
|
+
if (!choice || choice === "Back") return;
|
|
3473
|
+
|
|
3474
|
+
if (choice === "Edit" && file) {
|
|
3475
|
+
const content = readFileSync(file.path, "utf-8");
|
|
3476
|
+
const edited = await ctx.ui.editor(`Edit ${name}`, content);
|
|
3477
|
+
if (edited !== undefined && edited !== content) {
|
|
3478
|
+
const { writeFileSync } = await import("node:fs");
|
|
3479
|
+
writeFileSync(file.path, edited, "utf-8");
|
|
3480
|
+
reloadCustomAgents();
|
|
3481
|
+
ctx.ui.notify(`Updated ${file.path}`, "info");
|
|
3482
|
+
}
|
|
3483
|
+
} else if (choice === "Delete") {
|
|
3484
|
+
if (file) {
|
|
3485
|
+
const confirmed = await ctx.ui.confirm("Delete agent", `Delete ${name} from ${file.location} (${file.path})?`);
|
|
3486
|
+
if (confirmed) {
|
|
3487
|
+
unlinkSync(file.path);
|
|
3488
|
+
reloadCustomAgents();
|
|
3489
|
+
ctx.ui.notify(`Deleted ${file.path}`, "info");
|
|
3490
|
+
}
|
|
3491
|
+
}
|
|
3492
|
+
} else if (choice === "Reset to default" && file) {
|
|
3493
|
+
const confirmed = await ctx.ui.confirm("Reset to default", `Delete override ${file.path} and restore embedded default?`);
|
|
3494
|
+
if (confirmed) {
|
|
3495
|
+
unlinkSync(file.path);
|
|
3496
|
+
reloadCustomAgents();
|
|
3497
|
+
ctx.ui.notify(`Restored default ${name}`, "info");
|
|
3498
|
+
}
|
|
3499
|
+
} else if (choice.startsWith("Eject")) {
|
|
3500
|
+
await ejectAgent(ctx, name, cfg);
|
|
3501
|
+
} else if (choice === "Disable") {
|
|
3502
|
+
await disableAgent(ctx, name);
|
|
3503
|
+
} else if (choice === "Enable") {
|
|
3504
|
+
await enableAgent(ctx, name);
|
|
3505
|
+
}
|
|
3506
|
+
}
|
|
3507
|
+
|
|
3508
|
+
/** Eject a default agent: write its embedded config as a .md file. */
|
|
3509
|
+
async function ejectAgent(ctx: ExtensionCommandContext, name: string, cfg: AgentConfig) {
|
|
3510
|
+
const location = await ctx.ui.select("Choose location", [
|
|
3511
|
+
"Project (.pi/agents/)",
|
|
3512
|
+
`Personal (${personalAgentsDir()})`,
|
|
3513
|
+
]);
|
|
3514
|
+
if (!location) return;
|
|
3515
|
+
|
|
3516
|
+
const targetDir = location.startsWith("Project") ? projectAgentsDir() : personalAgentsDir();
|
|
3517
|
+
mkdirSync(targetDir, { recursive: true });
|
|
3518
|
+
|
|
3519
|
+
const targetPath = join(targetDir, `${name}.md`);
|
|
3520
|
+
if (existsSync(targetPath)) {
|
|
3521
|
+
const overwrite = await ctx.ui.confirm("Overwrite", `${targetPath} already exists. Overwrite?`);
|
|
3522
|
+
if (!overwrite) return;
|
|
3523
|
+
}
|
|
3524
|
+
|
|
3525
|
+
const content = serializeAgentFile(cfg);
|
|
3526
|
+
|
|
3527
|
+
const { writeFileSync } = await import("node:fs");
|
|
3528
|
+
writeFileSync(targetPath, content, "utf-8");
|
|
3529
|
+
reloadCustomAgents();
|
|
3530
|
+
ctx.ui.notify(`Ejected ${name} to ${targetPath}`, "info");
|
|
3531
|
+
}
|
|
3532
|
+
|
|
3533
|
+
/** Disable an agent: set enabled: false in its .md file, or create a stub for built-in defaults. */
|
|
3534
|
+
async function disableAgent(ctx: ExtensionCommandContext, name: string) {
|
|
3535
|
+
const cfg = getAgentConfig(name);
|
|
3536
|
+
// `locateAgentFile` refuses a package agent's own path, so this is either the
|
|
3537
|
+
// local stub already shadowing it or nothing — never a file inside pi's
|
|
3538
|
+
// install root. An unshadowed package agent therefore falls to the stub
|
|
3539
|
+
// branch below, which writes `enabled: false` at project or personal scope.
|
|
3540
|
+
const file = locateAgentFile(name, cfg?.sourcePath, undefined, cfg?.source);
|
|
3541
|
+
if (file) {
|
|
3542
|
+
// Existing file — set enabled: false in frontmatter (idempotent)
|
|
3543
|
+
const content = readFileSync(file.path, "utf-8");
|
|
3544
|
+
const { content: updated, outcome } = disableInContent(content);
|
|
3545
|
+
if (outcome === "already-disabled") {
|
|
3546
|
+
ctx.ui.notify(`${name} is already disabled.`, "info");
|
|
3547
|
+
return;
|
|
3548
|
+
}
|
|
3549
|
+
if (outcome === "no-frontmatter") {
|
|
3550
|
+
// Nothing to edit — say so rather than rewriting the file unchanged and
|
|
3551
|
+
// reporting success for a change that never happened.
|
|
3552
|
+
ctx.ui.notify(`Cannot disable ${name}: ${file.path} has no frontmatter block.`, "error");
|
|
3553
|
+
return;
|
|
3554
|
+
}
|
|
3555
|
+
const { writeFileSync } = await import("node:fs");
|
|
3556
|
+
writeFileSync(file.path, updated, "utf-8");
|
|
3557
|
+
reloadCustomAgents();
|
|
3558
|
+
ctx.ui.notify(`Disabled ${name} (${file.path})`, "info");
|
|
3559
|
+
return;
|
|
3560
|
+
}
|
|
3561
|
+
|
|
3562
|
+
// No editable file (a built-in default, or a package-provided agent) —
|
|
3563
|
+
// create a stub that shadows it.
|
|
3564
|
+
const location = await ctx.ui.select("Choose location", [
|
|
3565
|
+
"Project (.pi/agents/)",
|
|
3566
|
+
`Personal (${personalAgentsDir()})`,
|
|
3567
|
+
]);
|
|
3568
|
+
if (!location) return;
|
|
3569
|
+
|
|
3570
|
+
const targetDir = location.startsWith("Project") ? projectAgentsDir() : personalAgentsDir();
|
|
3571
|
+
mkdirSync(targetDir, { recursive: true });
|
|
3572
|
+
|
|
3573
|
+
const targetPath = join(targetDir, `${name}.md`);
|
|
3574
|
+
const { writeFileSync } = await import("node:fs");
|
|
3575
|
+
writeFileSync(targetPath, "---\nenabled: false\n---\n", "utf-8");
|
|
3576
|
+
reloadCustomAgents();
|
|
3577
|
+
ctx.ui.notify(`Disabled ${name} (${targetPath})`, "info");
|
|
3578
|
+
}
|
|
3579
|
+
|
|
3580
|
+
/** Enable a disabled agent by removing enabled: false from its frontmatter. */
|
|
3581
|
+
async function enableAgent(ctx: ExtensionCommandContext, name: string) {
|
|
3582
|
+
const cfg = getAgentConfig(name);
|
|
3583
|
+
const file = locateAgentFile(name, cfg?.sourcePath, undefined, cfg?.source);
|
|
3584
|
+
if (!file) return;
|
|
3585
|
+
|
|
3586
|
+
const content = readFileSync(file.path, "utf-8");
|
|
3587
|
+
const { content: updated, changed } = enableInContent(content);
|
|
3588
|
+
if (!changed && !isEmptyStub(updated)) {
|
|
3589
|
+
// The file carries no `enabled: false` to remove, so it was never disabled
|
|
3590
|
+
// by us — reporting success here would hide a no-op.
|
|
3591
|
+
ctx.ui.notify(`${name} is not disabled in ${file.path}.`, "info");
|
|
3592
|
+
return;
|
|
3593
|
+
}
|
|
3594
|
+
const { writeFileSync } = await import("node:fs");
|
|
3595
|
+
|
|
3596
|
+
// If the file was just a stub ("---\n---\n"), delete it to restore the built-in default
|
|
3597
|
+
if (isEmptyStub(updated)) {
|
|
3598
|
+
unlinkSync(file.path);
|
|
3599
|
+
reloadCustomAgents();
|
|
3600
|
+
ctx.ui.notify(`Enabled ${name} (removed ${file.path})`, "info");
|
|
3601
|
+
} else {
|
|
3602
|
+
writeFileSync(file.path, updated, "utf-8");
|
|
3603
|
+
reloadCustomAgents();
|
|
3604
|
+
ctx.ui.notify(`Enabled ${name} (${file.path})`, "info");
|
|
3605
|
+
}
|
|
3606
|
+
}
|
|
3607
|
+
|
|
3608
|
+
async function showCreateWizard(ctx: ExtensionCommandContext) {
|
|
3609
|
+
const location = await ctx.ui.select("Choose location", [
|
|
3610
|
+
"Project (.pi/agents/)",
|
|
3611
|
+
`Personal (${personalAgentsDir()})`,
|
|
3612
|
+
]);
|
|
3613
|
+
if (!location) return;
|
|
3614
|
+
|
|
3615
|
+
const targetDir = location.startsWith("Project") ? projectAgentsDir() : personalAgentsDir();
|
|
3616
|
+
|
|
3617
|
+
const method = await ctx.ui.select("Creation method", [
|
|
3618
|
+
"Generate with Claude (recommended)",
|
|
3619
|
+
"Manual configuration",
|
|
3620
|
+
]);
|
|
3621
|
+
if (!method) return;
|
|
3622
|
+
|
|
3623
|
+
if (method.startsWith("Generate")) {
|
|
3624
|
+
await showGenerateWizard(ctx, targetDir);
|
|
3625
|
+
} else {
|
|
3626
|
+
await showManualWizard(ctx, targetDir);
|
|
3627
|
+
}
|
|
3628
|
+
}
|
|
3629
|
+
|
|
3630
|
+
async function showGenerateWizard(ctx: ExtensionCommandContext, targetDir: string) {
|
|
3631
|
+
const description = await ctx.ui.input("Describe what this agent should do");
|
|
3632
|
+
if (!description) return;
|
|
3633
|
+
|
|
3634
|
+
const name = await ctx.ui.input("Agent name (filename, no spaces)");
|
|
3635
|
+
if (!name) return;
|
|
3636
|
+
|
|
3637
|
+
mkdirSync(targetDir, { recursive: true });
|
|
3638
|
+
|
|
3639
|
+
const targetPath = join(targetDir, `${name}.md`);
|
|
3640
|
+
if (existsSync(targetPath)) {
|
|
3641
|
+
const overwrite = await ctx.ui.confirm("Overwrite", `${targetPath} already exists. Overwrite?`);
|
|
3642
|
+
if (!overwrite) return;
|
|
3643
|
+
}
|
|
3644
|
+
|
|
3645
|
+
ctx.ui.notify("Generating agent definition...", "info");
|
|
3646
|
+
|
|
3647
|
+
const generatePrompt = `Create a custom pi sub-agent definition file based on this description: "${description}"
|
|
3648
|
+
|
|
3649
|
+
Write a markdown file to: ${targetPath}
|
|
3650
|
+
|
|
3651
|
+
The file format is a markdown file with YAML frontmatter and a system prompt body:
|
|
3652
|
+
|
|
3653
|
+
\`\`\`markdown
|
|
3654
|
+
---
|
|
3655
|
+
description: <one-line description shown in UI>
|
|
3656
|
+
color: <optional agent name badge color: red, blue, green, yellow, purple, orange, pink, cyan, an Agency Agents alias, or quoted "#RRGGBB">
|
|
3657
|
+
tools: <comma-separated built-in tools: read, bash, edit, write, grep, find, ls. Use "none" for no tools. Omit for all tools>
|
|
3658
|
+
model: <optional model as "provider/modelId", e.g. "anthropic/claude-haiku-4-5". Omit to inherit parent model>
|
|
3659
|
+
thinking: <optional thinking level: ${THINKING_LEVELS.join(", ")}. Omit to inherit>
|
|
3660
|
+
max_turns: <optional max agentic turns. 0 or omit for unlimited (default)>
|
|
3661
|
+
prompt_mode: <"replace" (body IS the full system prompt) or "append" (body is appended to default prompt). Default: replace>
|
|
3662
|
+
extensions: <true (inherit all MCP/extension tools), false (none), or comma-separated names. Default: true>
|
|
3663
|
+
skills: <true (inherit all), false (none), or comma-separated skill names to preload into prompt. Default: true>
|
|
3664
|
+
disallowed_tools: <comma-separated tool names to block, even if otherwise available. Omit for none>
|
|
3665
|
+
inherit_context: <true to fork parent conversation into agent so it sees chat history. Default: false>
|
|
3666
|
+
run_in_background: <pin this agent to background (true) or foreground (false). Omit to follow the backgroundByDefault setting, which is background>
|
|
3667
|
+
output_transcript: <false to write no transcript file or path for this agent. Independent of persist_session. Default: true>
|
|
3668
|
+
isolated: <true for no extension/MCP tools, only built-in tools. Default: false>
|
|
3669
|
+
memory: <"user" (global), "project" (per-project), or "local" (gitignored per-project) for persistent memory. Omit for none>${
|
|
3670
|
+
// Offering the field on a project that turned worktrees off would bake a
|
|
3671
|
+
// request that is refused at spawn time into a file that outlives the
|
|
3672
|
+
// session — the #231 pathology (models fill the fields they are shown)
|
|
3673
|
+
// one layer up. Built per invocation, so this read is live.
|
|
3674
|
+
isWorktreeIsolationEnabled()
|
|
3675
|
+
? `\nisolation: <"worktree" to run in isolated git worktree; "off" to refuse one even when the caller asks. Omit for normal>`
|
|
3676
|
+
: ""
|
|
3677
|
+
}
|
|
3678
|
+
---
|
|
3679
|
+
|
|
3680
|
+
<system prompt body — instructions for the agent>
|
|
3681
|
+
\`\`\`
|
|
3682
|
+
|
|
3683
|
+
Guidelines for choosing settings:
|
|
3684
|
+
- For read-only tasks (review, analysis): tools: read, bash, grep, find, ls
|
|
3685
|
+
- For code modification tasks: include edit, write
|
|
3686
|
+
- Use prompt_mode: append if the agent should keep the default system prompt and add specialization on top
|
|
3687
|
+
- Use prompt_mode: replace for fully custom agents with their own personality/instructions
|
|
3688
|
+
- Set inherit_context: true if the agent needs to know what was discussed in the parent conversation
|
|
3689
|
+
- Set isolated: true if the agent should NOT have access to MCP servers or other extensions
|
|
3690
|
+
- Set output_transcript: false to skip writing this agent's transcript; this alone doesn't keep the run off disk (persist_session, isolation: worktree commits, and memory still write) — set those too if that's the goal
|
|
3691
|
+
- Only include frontmatter fields that differ from defaults — omit fields where the default is fine
|
|
3692
|
+
|
|
3693
|
+
Write the file using the write tool. Only write the file, nothing else.`;
|
|
3694
|
+
|
|
3695
|
+
const { record } = await manager.spawnAndWait(pi, ctx, "general-purpose", generatePrompt, {
|
|
3696
|
+
description: `Generate ${name} agent`,
|
|
3697
|
+
maxTurns: 5,
|
|
3698
|
+
// Exempt from maxConcurrentForeground. This runs from a modal wizard, not
|
|
3699
|
+
// a tool call: it passes no signal, and Esc in `ctx.ui` never reaches the
|
|
3700
|
+
// manager — so a user waiting behind a full pool would have no way to
|
|
3701
|
+
// cancel at all. It is also one human action that cannot fan out, which
|
|
3702
|
+
// is what the limit exists to bound. It still counts once started.
|
|
3703
|
+
bypassQueue: true,
|
|
3704
|
+
});
|
|
3705
|
+
|
|
3706
|
+
if (record.status === "error") {
|
|
3707
|
+
ctx.ui.notify(`Generation failed: ${record.error}`, "warning");
|
|
3708
|
+
return;
|
|
3709
|
+
}
|
|
3710
|
+
|
|
3711
|
+
reloadCustomAgents();
|
|
3712
|
+
|
|
3713
|
+
if (existsSync(targetPath)) {
|
|
3714
|
+
ctx.ui.notify(`Created ${targetPath}`, "info");
|
|
3715
|
+
} else {
|
|
3716
|
+
ctx.ui.notify("Agent generation completed but file was not created. Check the agent output.", "warning");
|
|
3717
|
+
}
|
|
3718
|
+
}
|
|
3719
|
+
|
|
3720
|
+
async function showManualWizard(ctx: ExtensionCommandContext, targetDir: string) {
|
|
3721
|
+
// 1. Name
|
|
3722
|
+
const name = await ctx.ui.input("Agent name (filename, no spaces)");
|
|
3723
|
+
if (!name) return;
|
|
3724
|
+
|
|
3725
|
+
// 2. Description
|
|
3726
|
+
const description = await ctx.ui.input("Description (one line)");
|
|
3727
|
+
if (!description) return;
|
|
3728
|
+
|
|
3729
|
+
// 3. Tools
|
|
3730
|
+
const toolChoice = await ctx.ui.select("Tools", ["all", "none", "read-only (read, bash, grep, find, ls)", "custom..."]);
|
|
3731
|
+
if (!toolChoice) return;
|
|
3732
|
+
|
|
3733
|
+
let tools: string;
|
|
3734
|
+
if (toolChoice === "all") {
|
|
3735
|
+
tools = BUILTIN_TOOL_NAMES.join(", ");
|
|
3736
|
+
} else if (toolChoice === "none") {
|
|
3737
|
+
tools = "none";
|
|
3738
|
+
} else if (toolChoice.startsWith("read-only")) {
|
|
3739
|
+
tools = "read, bash, grep, find, ls";
|
|
3740
|
+
} else {
|
|
3741
|
+
const customTools = await ctx.ui.input("Tools (comma-separated)", BUILTIN_TOOL_NAMES.join(", "));
|
|
3742
|
+
if (!customTools) return;
|
|
3743
|
+
tools = customTools;
|
|
3744
|
+
}
|
|
3745
|
+
|
|
3746
|
+
// 4. Model
|
|
3747
|
+
const modelChoice = await ctx.ui.select("Model", [
|
|
3748
|
+
"inherit (parent model)",
|
|
3749
|
+
"haiku",
|
|
3750
|
+
"sonnet",
|
|
3751
|
+
"opus",
|
|
3752
|
+
"custom...",
|
|
3753
|
+
]);
|
|
3754
|
+
if (!modelChoice) return;
|
|
3755
|
+
|
|
3756
|
+
let model: string | undefined;
|
|
3757
|
+
if (modelChoice === "haiku") model = "anthropic/claude-haiku-4-5";
|
|
3758
|
+
else if (modelChoice === "sonnet") model = "anthropic/claude-sonnet-4-6";
|
|
3759
|
+
else if (modelChoice === "opus") model = "anthropic/claude-opus-4-6";
|
|
3760
|
+
else if (modelChoice === "custom...") {
|
|
3761
|
+
model = (await ctx.ui.input("Model (provider/modelId)")) || undefined;
|
|
3762
|
+
}
|
|
3763
|
+
|
|
3764
|
+
// 5. Thinking
|
|
3765
|
+
// "inherit" is a UI-only pseudo-choice (omit the field); the rest mirror pi.
|
|
3766
|
+
const thinkingChoice = await ctx.ui.select("Thinking level", ["inherit", ...THINKING_LEVELS]);
|
|
3767
|
+
if (!thinkingChoice) return;
|
|
3768
|
+
|
|
3769
|
+
// 6. System prompt
|
|
3770
|
+
const systemPrompt = await ctx.ui.editor("System prompt", "");
|
|
3771
|
+
if (systemPrompt === undefined) return;
|
|
3772
|
+
|
|
3773
|
+
const content = buildNewAgentFile({
|
|
3774
|
+
description,
|
|
3775
|
+
tools,
|
|
3776
|
+
model,
|
|
3777
|
+
thinking: thinkingChoice === "inherit" ? undefined : thinkingChoice,
|
|
3778
|
+
systemPrompt,
|
|
3779
|
+
});
|
|
3780
|
+
|
|
3781
|
+
mkdirSync(targetDir, { recursive: true });
|
|
3782
|
+
const targetPath = join(targetDir, `${name}.md`);
|
|
3783
|
+
|
|
3784
|
+
if (existsSync(targetPath)) {
|
|
3785
|
+
const overwrite = await ctx.ui.confirm("Overwrite", `${targetPath} already exists. Overwrite?`);
|
|
3786
|
+
if (!overwrite) return;
|
|
3787
|
+
}
|
|
3788
|
+
|
|
3789
|
+
const { writeFileSync } = await import("node:fs");
|
|
3790
|
+
writeFileSync(targetPath, content, "utf-8");
|
|
3791
|
+
reloadCustomAgents();
|
|
3792
|
+
ctx.ui.notify(`Created ${targetPath}`, "info");
|
|
3793
|
+
}
|
|
3794
|
+
|
|
3795
|
+
/**
|
|
3796
|
+
* Every settings mutation writes this WHOLE object back to disk, so a field
|
|
3797
|
+
* missing here is erased from the user's subagents.json the next time they
|
|
3798
|
+
* toggle something unrelated. `SubagentsSettings` has every field optional,
|
|
3799
|
+
* so a `: SubagentsSettings` return annotation would let a newly-added setting
|
|
3800
|
+
* be forgotten here and still type-check. `satisfies` instead: it still checks
|
|
3801
|
+
* each value's type and rejects a mistyped key, but leaves the return type
|
|
3802
|
+
* inferred so `_NoMissingSettingsKeys` below can check completeness.
|
|
3803
|
+
*/
|
|
3804
|
+
function snapshotSettings() {
|
|
3805
|
+
return {
|
|
3806
|
+
maxConcurrent: manager.getMaxConcurrent(),
|
|
3807
|
+
// 0 = unlimited, and the default — see SubagentsSettings.
|
|
3808
|
+
maxConcurrentForeground: manager.getMaxConcurrentForeground(),
|
|
3809
|
+
// 0 = unlimited — per SubagentsSettings.defaultMaxTurns docstring and
|
|
3810
|
+
// normalizeMaxTurns() in agent-runner.ts (which maps 0 → undefined).
|
|
3811
|
+
defaultMaxTurns: getDefaultMaxTurns() ?? 0,
|
|
3812
|
+
graceTurns: getGraceTurns(),
|
|
3813
|
+
defaultJoinMode: getDefaultJoinMode(),
|
|
3814
|
+
backgroundByDefault: getBackgroundByDefault(),
|
|
3815
|
+
schedulingEnabled: isSchedulingEnabled(),
|
|
3816
|
+
scopeModels: isScopeModelsEnabled(),
|
|
3817
|
+
strictAgentFiles,
|
|
3818
|
+
disableDefaultAgents: isDefaultsDisabled(),
|
|
3819
|
+
toolDescriptionMode: getToolDescriptionMode(),
|
|
3820
|
+
fleetView: isFleetViewEnabled(),
|
|
3821
|
+
agentMentions: getAgentMentionMode(),
|
|
3822
|
+
rememberAgents: getRememberAgents(),
|
|
3823
|
+
widgetMode: getWidgetMode(),
|
|
3824
|
+
outputTranscript: getOutputTranscriptDefault(),
|
|
3825
|
+
worktreeIsolation: isWorktreeIsolationEnabled(),
|
|
3826
|
+
// The user's answer, not the effective one. A stand-down for another
|
|
3827
|
+
// extension's workflow tool is scoped to the session it was detected in;
|
|
3828
|
+
// writing it here would let an unrelated settings change three menus away
|
|
3829
|
+
// freeze it into the file as an explicit `false`, which then survives
|
|
3830
|
+
// uninstalling the extension it was deferring to. undefined is dropped by
|
|
3831
|
+
// JSON.stringify, so unset stays unset — same reasoning as
|
|
3832
|
+
// `fallbackSubagent` below.
|
|
3833
|
+
workflowsEnabled: isWorkflowsPinned() ? isWorkflowsEnabled() : undefined,
|
|
3834
|
+
maxSubagentDepth: getMaxSubagentDepth(),
|
|
3835
|
+
// Deliberately NOT `?? "general-purpose"`: every settings change writes the
|
|
3836
|
+
// whole snapshot, and materializing the implicit default would turn it into
|
|
3837
|
+
// explicit configuration — which then fails loudly if general-purpose later
|
|
3838
|
+
// goes away. undefined is dropped by JSON.stringify.
|
|
3839
|
+
fallbackSubagent: getFallbackSubagent(),
|
|
3840
|
+
// Global-only (see loadSettings): never written into the project file.
|
|
3841
|
+
modelAliases: undefined,
|
|
3842
|
+
agentModels: undefined,
|
|
3843
|
+
reportUsage: isReportUsageEnabled(),
|
|
3844
|
+
showCost: isShowCostEnabled(),
|
|
3845
|
+
showModel: isShowModelEnabled(),
|
|
3846
|
+
viewerMarkdown: getViewerMarkdown(),
|
|
3847
|
+
packageAgents: getPackageAgentsGate(),
|
|
3848
|
+
packageWorkflows: getPackageWorkflowsGate(),
|
|
3849
|
+
} satisfies SubagentsSettings;
|
|
3850
|
+
}
|
|
3851
|
+
|
|
3852
|
+
// Compile-time completeness guard for snapshotSettings(). If a field is added
|
|
3853
|
+
// to SubagentsSettings and not mirrored above, this Exclude is non-empty and
|
|
3854
|
+
// fails to satisfy `never` — turning a silent settings-erasure bug into a
|
|
3855
|
+
// typecheck error. `npm run typecheck` runs in CI.
|
|
3856
|
+
type _NoMissingSettingsKeys =
|
|
3857
|
+
Exclude<keyof SubagentsSettings, keyof ReturnType<typeof snapshotSettings>> extends never
|
|
3858
|
+
? true
|
|
3859
|
+
: ["snapshotSettings() is missing a SubagentsSettings key"];
|
|
3860
|
+
const _settingsSnapshotIsComplete: _NoMissingSettingsKeys = true;
|
|
3861
|
+
void _settingsSnapshotIsComplete;
|
|
3862
|
+
|
|
3863
|
+
/**
|
|
3864
|
+
* Whether a package gate lets anything through. `undefined` is the unset
|
|
3865
|
+
* default and reads as `true`; an empty allowlist matches nothing, like
|
|
3866
|
+
* `false`.
|
|
3867
|
+
*/
|
|
3868
|
+
function gateAdmitsAnything(gate: ReturnType<typeof getPackageAgentsGate>): boolean {
|
|
3869
|
+
if (gate === undefined || gate === true) return true;
|
|
3870
|
+
if (gate === false) return false;
|
|
3871
|
+
return gate.length > 0;
|
|
3872
|
+
}
|
|
3873
|
+
|
|
3874
|
+
/**
|
|
3875
|
+
* Settings-row description for a package gate, naming the configured allowlist
|
|
3876
|
+
* when there is one. The row itself is on/off — an allowlist is hand-written in
|
|
3877
|
+
* `subagents.json` — so the description is the only place it is visible, and a
|
|
3878
|
+
* user about to toggle the row can see what they are about to replace.
|
|
3879
|
+
*/
|
|
3880
|
+
function describeGateSetting(gate: ReturnType<typeof getPackageAgentsGate>, base: string): string {
|
|
3881
|
+
if (!Array.isArray(gate)) return base;
|
|
3882
|
+
return gate.length > 0
|
|
3883
|
+
? `${base} Currently limited to: ${gate.join(", ")}.`
|
|
3884
|
+
: `${base} Currently limited to an empty list, so nothing loads.`;
|
|
3885
|
+
}
|
|
3886
|
+
|
|
3887
|
+
const NUMERIC_IDS = new Set([
|
|
3888
|
+
"maxConcurrent", "maxConcurrentForeground", "defaultMaxTurns", "graceTurns", "maxSubagentDepth",
|
|
3889
|
+
]);
|
|
3890
|
+
|
|
3891
|
+
async function showSettings(ctx: ExtensionCommandContext) {
|
|
3892
|
+
function buildItems(): SettingItem[] {
|
|
3893
|
+
const mc = manager.getMaxConcurrent();
|
|
3894
|
+
const mcf = manager.getMaxConcurrentForeground();
|
|
3895
|
+
const dmt = getDefaultMaxTurns() ?? 0;
|
|
3896
|
+
const gt = getGraceTurns();
|
|
3897
|
+
const msd = getMaxSubagentDepth();
|
|
3898
|
+
// Label what unset actually does — it targets general-purpose even when
|
|
3899
|
+
// that is unregistered (the permissive hardcoded tier), so showing "none"
|
|
3900
|
+
// there would advertise strict dispatch for the most permissive state.
|
|
3901
|
+
// `values` still offers only resolvable targets, so the user cannot
|
|
3902
|
+
// persist a fallback that would hard-error on every dispatch.
|
|
3903
|
+
const fallbackValue = getFallbackSubagent() ?? "general-purpose";
|
|
3904
|
+
const fallbackValues = [...new Set([...getAvailableTypes(), NO_FALLBACK])];
|
|
3905
|
+
|
|
3906
|
+
return [
|
|
3907
|
+
{
|
|
3908
|
+
id: "maxConcurrent",
|
|
3909
|
+
label: "Max concurrency",
|
|
3910
|
+
description: "Max concurrent background agents (Enter to type)",
|
|
3911
|
+
currentValue: String(mc),
|
|
3912
|
+
values: [String(mc)],
|
|
3913
|
+
},
|
|
3914
|
+
{
|
|
3915
|
+
id: "maxConcurrentForeground",
|
|
3916
|
+
label: "Max foreground concurrency",
|
|
3917
|
+
description: "Max concurrent foreground (blocking) agents (0 = unlimited, Enter to type)",
|
|
3918
|
+
currentValue: String(mcf),
|
|
3919
|
+
values: [String(mcf)],
|
|
3920
|
+
},
|
|
3921
|
+
{
|
|
3922
|
+
id: "defaultMaxTurns",
|
|
3923
|
+
label: "Default max turns",
|
|
3924
|
+
description: "Default max turns before wrap-up (0 = unlimited, Enter to type)",
|
|
3925
|
+
currentValue: String(dmt),
|
|
3926
|
+
values: [String(dmt)],
|
|
3927
|
+
},
|
|
3928
|
+
{
|
|
3929
|
+
id: "graceTurns",
|
|
3930
|
+
label: "Grace turns",
|
|
3931
|
+
description: "Grace turns after wrap-up steer (Enter to type)",
|
|
3932
|
+
currentValue: String(gt),
|
|
3933
|
+
values: [String(gt)],
|
|
3934
|
+
},
|
|
3935
|
+
{
|
|
3936
|
+
id: "maxSubagentDepth",
|
|
3937
|
+
label: "Nested depth",
|
|
3938
|
+
description: "Hard cap on nested delegation — main is 0, its subagents 1 (0/1 = nesting off, Enter to type)",
|
|
3939
|
+
currentValue: String(msd),
|
|
3940
|
+
values: [String(msd)],
|
|
3941
|
+
},
|
|
3942
|
+
{
|
|
3943
|
+
id: "joinMode",
|
|
3944
|
+
label: "Join mode",
|
|
3945
|
+
description: "smart = each completion at the parent's next boundary, as in Claude Code; group = one notification per turn's batch",
|
|
3946
|
+
// `async` is an older name for `smart`; settings files may still hold it.
|
|
3947
|
+
currentValue: getDefaultJoinMode() === "group" ? "group" : "smart",
|
|
3948
|
+
values: ["smart", "group"],
|
|
3949
|
+
},
|
|
3950
|
+
{
|
|
3951
|
+
id: "backgroundByDefault",
|
|
3952
|
+
label: "Background by default",
|
|
3953
|
+
description: "An Agent call that doesn't say runs detached (off = blocks the turn and returns inline)",
|
|
3954
|
+
currentValue: getBackgroundByDefault() ? "on" : "off",
|
|
3955
|
+
values: ["on", "off"],
|
|
3956
|
+
},
|
|
3957
|
+
{
|
|
3958
|
+
id: "schedulingEnabled",
|
|
3959
|
+
label: "Scheduling",
|
|
3960
|
+
description: "Schedule subagent feature (off removes `schedule` param from Agent tool spec on next pi session)",
|
|
3961
|
+
currentValue: isSchedulingEnabled() ? "on" : "off",
|
|
3962
|
+
values: ["on", "off"],
|
|
3963
|
+
},
|
|
3964
|
+
{
|
|
3965
|
+
id: "workflowsEnabled",
|
|
3966
|
+
label: "Workflows",
|
|
3967
|
+
description:
|
|
3968
|
+
"Scripted workflows, on unless another extension provides a workflow tool "
|
|
3969
|
+
+ "(off keeps the SubagentWorkflow tool out of the tool spec; applies on next pi session)",
|
|
3970
|
+
currentValue: isWorkflowsEnabled() ? "on" : "off",
|
|
3971
|
+
values: ["on", "off"],
|
|
3972
|
+
},
|
|
3973
|
+
{
|
|
3974
|
+
id: "packageAgents",
|
|
3975
|
+
label: "Package agents",
|
|
3976
|
+
description: describeGateSetting(
|
|
3977
|
+
getPackageAgentsGate(),
|
|
3978
|
+
"Load agents declared by installed pi packages (`pi.subagents.agents` in their package.json). "
|
|
3979
|
+
+ "They rank below your global and project agents, so a same-named local file always wins.",
|
|
3980
|
+
),
|
|
3981
|
+
currentValue: gateAdmitsAnything(getPackageAgentsGate()) ? "on" : "off",
|
|
3982
|
+
values: ["on", "off"],
|
|
3983
|
+
},
|
|
3984
|
+
{
|
|
3985
|
+
id: "packageWorkflows",
|
|
3986
|
+
label: "Package workflows",
|
|
3987
|
+
description: describeGateSetting(
|
|
3988
|
+
getPackageWorkflowsGate(),
|
|
3989
|
+
"Resolve saved workflow names against scripts declared by installed pi packages "
|
|
3990
|
+
+ "(`pi.subagents.workflows`). Searched last, after your project, workspace and personal directories.",
|
|
3991
|
+
),
|
|
3992
|
+
currentValue: gateAdmitsAnything(getPackageWorkflowsGate()) ? "on" : "off",
|
|
3993
|
+
values: ["on", "off"],
|
|
3994
|
+
},
|
|
3995
|
+
{
|
|
3996
|
+
id: "scopeModels",
|
|
3997
|
+
label: "Scope models",
|
|
3998
|
+
description: "Validate subagent models against scoped models (/scoped-models)",
|
|
3999
|
+
currentValue: isScopeModelsEnabled() ? "on" : "off",
|
|
4000
|
+
values: ["on", "off"],
|
|
4001
|
+
},
|
|
4002
|
+
{
|
|
4003
|
+
id: "strictAgentFiles",
|
|
4004
|
+
label: "Strict agent files",
|
|
4005
|
+
description: "Fail startup on an unreadable/unparseable agent .md instead of skipping it with a warning",
|
|
4006
|
+
currentValue: strictAgentFiles ? "on" : "off",
|
|
4007
|
+
values: ["on", "off"],
|
|
4008
|
+
},
|
|
4009
|
+
{
|
|
4010
|
+
id: "disableDefaultAgents",
|
|
4011
|
+
label: "Disable defaults",
|
|
4012
|
+
description: "Hide built-in agents (general-purpose, Explore, Plan) — custom agents are unaffected",
|
|
4013
|
+
currentValue: isDefaultsDisabled() ? "on" : "off",
|
|
4014
|
+
values: ["on", "off"],
|
|
4015
|
+
},
|
|
4016
|
+
{
|
|
4017
|
+
id: "fallbackSubagent",
|
|
4018
|
+
label: "Fallback agent",
|
|
4019
|
+
description: `Agent used when subagent_type is unknown, disabled, or ambiguous; "${NO_FALLBACK}" rejects the call instead (strict dispatch)`,
|
|
4020
|
+
currentValue: fallbackValue,
|
|
4021
|
+
values: fallbackValues,
|
|
4022
|
+
},
|
|
4023
|
+
{
|
|
4024
|
+
id: "outputTranscript",
|
|
4025
|
+
label: "Output transcript",
|
|
4026
|
+
description: "Write each subagent's .output transcript by default. A custom agent's output_transcript frontmatter overrides this.",
|
|
4027
|
+
currentValue: getOutputTranscriptDefault() ? "on" : "off",
|
|
4028
|
+
values: ["on", "off"],
|
|
4029
|
+
},
|
|
4030
|
+
{
|
|
4031
|
+
id: "worktreeIsolation",
|
|
4032
|
+
label: "Worktree isolation",
|
|
4033
|
+
description:
|
|
4034
|
+
"Allow isolation: worktree to copy the repo. Off refuses worktrees on every path immediately — for repos where a copy costs too much time or disk — and drops the `isolation` param from the Agent tool spec on next pi session.",
|
|
4035
|
+
currentValue: isWorktreeIsolationEnabled() ? "on" : "off",
|
|
4036
|
+
values: ["on", "off"],
|
|
4037
|
+
},
|
|
4038
|
+
{
|
|
4039
|
+
id: "reportUsage",
|
|
4040
|
+
label: "Report usage to session",
|
|
4041
|
+
description:
|
|
4042
|
+
"Add subagent tokens and cost to this session's own totals, so pi's footer and /cost stop reading a delegating session as nearly free. Reported on the next tool result (agents that finish in the background are counted on the one after). Context-window % is unaffected.",
|
|
4043
|
+
currentValue: isReportUsageEnabled() ? "on" : "off",
|
|
4044
|
+
values: ["on", "off"],
|
|
4045
|
+
},
|
|
4046
|
+
{
|
|
4047
|
+
id: "showCost",
|
|
4048
|
+
label: "Show cost",
|
|
4049
|
+
description:
|
|
4050
|
+
"Show an estimated `~$0.0042` beside subagent token counts in the widget, fleet view, results and notifications. Priced by pi from the model's rates — omitted entirely for a model it has no rates for.",
|
|
4051
|
+
currentValue: isShowCostEnabled() ? "on" : "off",
|
|
4052
|
+
values: ["on", "off"],
|
|
4053
|
+
},
|
|
4054
|
+
{
|
|
4055
|
+
id: "showModel",
|
|
4056
|
+
label: "Show model",
|
|
4057
|
+
description:
|
|
4058
|
+
"Name the model driving each agent, and the thinking level it is running at, on the widget's running rows. The Agent tool result and the conversation viewer show the pair either way — this adds it to the widget, where the row is already dense.",
|
|
4059
|
+
currentValue: isShowModelEnabled() ? "on" : "off",
|
|
4060
|
+
values: ["on", "off"],
|
|
4061
|
+
},
|
|
4062
|
+
{
|
|
4063
|
+
id: "viewerMarkdown",
|
|
4064
|
+
label: "Viewer markdown",
|
|
4065
|
+
description:
|
|
4066
|
+
"How much of the conversation viewer renders as Markdown. assistant = assistant text only (default); all = tool results too, for tools that emit Markdown — accepting that a Markdown pass over a diff or a log eats `#` comments, swallows a `---` line and re-fences indented output; off = everything verbatim. `m` in the viewer cycles the same setting (footer: raw / md / md+).",
|
|
4067
|
+
currentValue: getViewerMarkdown(),
|
|
4068
|
+
values: ["off", "assistant", "all"],
|
|
4069
|
+
},
|
|
4070
|
+
{
|
|
4071
|
+
id: "fleetView",
|
|
4072
|
+
label: "Fleet view",
|
|
4073
|
+
description: "Claude Code-style main+subagents list below the editor (↓/← to navigate, Enter to view)",
|
|
4074
|
+
currentValue: isFleetViewEnabled() ? "on" : "off",
|
|
4075
|
+
values: ["on", "off"],
|
|
4076
|
+
},
|
|
4077
|
+
{
|
|
4078
|
+
id: "agentMentions",
|
|
4079
|
+
label: "Agent mentions",
|
|
4080
|
+
description: "Route `@handle message` at the prompt to that agent. model = an off-screen clone of this conversation calls the Agent tool, so the agent gets a context-written prompt, a transcript and per-tool detail, and the chat stays clean; direct = started here from your text, no model call. Messaging and resuming are direct either way.",
|
|
4081
|
+
currentValue: getAgentMentionMode(),
|
|
4082
|
+
values: ["model", "direct", "off"],
|
|
4083
|
+
},
|
|
4084
|
+
{
|
|
4085
|
+
id: "rememberAgents",
|
|
4086
|
+
label: "Remember agents",
|
|
4087
|
+
description: "Persist subagent sessions so `@handle` can resume one long after it finished (they also appear in /resume)",
|
|
4088
|
+
currentValue: getRememberAgents() ? "on" : "off",
|
|
4089
|
+
values: ["on", "off"],
|
|
4090
|
+
},
|
|
4091
|
+
{
|
|
4092
|
+
id: "widgetMode",
|
|
4093
|
+
label: "Widget",
|
|
4094
|
+
description: "Above-editor agent widget: all = every agent; background = hide foreground (they already render inline); off = hide the widget.",
|
|
4095
|
+
currentValue: getWidgetMode(),
|
|
4096
|
+
values: ["all", "background", "off"],
|
|
4097
|
+
},
|
|
4098
|
+
{
|
|
4099
|
+
id: "toolDescriptionMode",
|
|
4100
|
+
label: "Tool description",
|
|
4101
|
+
description: "Agent tool description sent to the LLM: full (rich, default), compact (~75% fewer tokens, for small/local models), or custom (.pi/agent-tool-description.md with {{placeholders}})",
|
|
4102
|
+
currentValue: getToolDescriptionMode(),
|
|
4103
|
+
values: ["full", "compact", "custom"],
|
|
4104
|
+
},
|
|
4105
|
+
];
|
|
4106
|
+
}
|
|
4107
|
+
|
|
4108
|
+
function applyValue(id: string, value: string) {
|
|
4109
|
+
if (id === "maxConcurrent") {
|
|
4110
|
+
const n = parseInt(value, 10);
|
|
4111
|
+
if (n >= 1) {
|
|
4112
|
+
manager.setMaxConcurrent(n);
|
|
4113
|
+
notifyApplied(ctx, `Max concurrency set to ${n}`);
|
|
4114
|
+
}
|
|
4115
|
+
} else if (id === "maxConcurrentForeground") {
|
|
4116
|
+
// 0 is meaningful here, unlike maxConcurrent above: it means unlimited.
|
|
4117
|
+
const n = parseInt(value, 10);
|
|
4118
|
+
if (n >= 0) {
|
|
4119
|
+
manager.setMaxConcurrentForeground(n);
|
|
4120
|
+
notifyApplied(ctx, n === 0
|
|
4121
|
+
? "Max foreground concurrency set to unlimited"
|
|
4122
|
+
: `Max foreground concurrency set to ${n}`);
|
|
4123
|
+
}
|
|
4124
|
+
} else if (id === "defaultMaxTurns") {
|
|
4125
|
+
const n = parseInt(value, 10);
|
|
4126
|
+
if (n === 0) {
|
|
4127
|
+
setDefaultMaxTurns(undefined);
|
|
4128
|
+
notifyApplied(ctx, "Default max turns set to unlimited");
|
|
4129
|
+
} else if (n >= 1) {
|
|
4130
|
+
setDefaultMaxTurns(n);
|
|
4131
|
+
notifyApplied(ctx, `Default max turns set to ${n}`);
|
|
4132
|
+
}
|
|
4133
|
+
} else if (id === "graceTurns") {
|
|
4134
|
+
const n = parseInt(value, 10);
|
|
4135
|
+
if (n >= 1) {
|
|
4136
|
+
setGraceTurns(n);
|
|
4137
|
+
notifyApplied(ctx, `Grace turns set to ${n}`);
|
|
4138
|
+
}
|
|
4139
|
+
} else if (id === "maxSubagentDepth") {
|
|
4140
|
+
const n = parseInt(value, 10);
|
|
4141
|
+
if (n >= 0) {
|
|
4142
|
+
setMaxSubagentDepth(n);
|
|
4143
|
+
notifyApplied(
|
|
4144
|
+
ctx,
|
|
4145
|
+
n <= 1
|
|
4146
|
+
? "Nested delegation disabled"
|
|
4147
|
+
: `Nested depth set to ${n}. Applies to agents started from now on.`,
|
|
4148
|
+
);
|
|
4149
|
+
}
|
|
4150
|
+
} else if (id === "joinMode") {
|
|
4151
|
+
setDefaultJoinMode(value as JoinMode);
|
|
4152
|
+
notifyApplied(ctx, `Default join mode set to ${value}`);
|
|
4153
|
+
} else if (id === "backgroundByDefault") {
|
|
4154
|
+
const enabled = value === "on";
|
|
4155
|
+
setBackgroundByDefault(enabled);
|
|
4156
|
+
notifyApplied(
|
|
4157
|
+
ctx,
|
|
4158
|
+
enabled
|
|
4159
|
+
? "Agent calls run in the background unless they pass run_in_background: false"
|
|
4160
|
+
: "Agent calls block and return inline unless they pass run_in_background: true",
|
|
4161
|
+
);
|
|
4162
|
+
} else if (id === "schedulingEnabled") {
|
|
4163
|
+
const enabled = value === "on";
|
|
4164
|
+
if (enabled === isSchedulingEnabled()) {
|
|
4165
|
+
ctx.ui.notify(`Scheduling already ${enabled ? "enabled" : "disabled"}.`, "info");
|
|
4166
|
+
} else {
|
|
4167
|
+
setSchedulingEnabled(enabled);
|
|
4168
|
+
if (!enabled) scheduler.stop(); // immediate kill — outstanding fires stop ticking
|
|
4169
|
+
notifyApplied(
|
|
4170
|
+
ctx,
|
|
4171
|
+
`Scheduling ${enabled ? "enabled" : "disabled"}. Tool spec change takes effect on next pi session.`,
|
|
4172
|
+
);
|
|
4173
|
+
}
|
|
4174
|
+
} else if (id === "workflowsEnabled") {
|
|
4175
|
+
const enabled = value === "on";
|
|
4176
|
+
if (enabled === isWorkflowsEnabled()) {
|
|
4177
|
+
ctx.ui.notify(`Workflows already ${enabled ? "enabled" : "disabled"}.`, "info");
|
|
4178
|
+
} else {
|
|
4179
|
+
setWorkflowsEnabled(enabled);
|
|
4180
|
+
// Runs already in flight keep going: the switch governs whether the
|
|
4181
|
+
// tool is offered, and killing live agents on a settings toggle would
|
|
4182
|
+
// lose work the user never asked to discard.
|
|
4183
|
+
notifyApplied(
|
|
4184
|
+
ctx,
|
|
4185
|
+
`Workflows ${enabled ? "enabled" : "disabled"}. Tool spec change takes effect on next pi session.`,
|
|
4186
|
+
);
|
|
4187
|
+
}
|
|
4188
|
+
} else if (id === "scopeModels") {
|
|
4189
|
+
const enabled = value === "on";
|
|
4190
|
+
setScopeModelsEnabled(enabled);
|
|
4191
|
+
notifyApplied(ctx, `Scope models ${enabled ? "enabled" : "disabled"}`);
|
|
4192
|
+
} else if (id === "strictAgentFiles") {
|
|
4193
|
+
const enabled = value === "on";
|
|
4194
|
+
strictAgentFiles = enabled;
|
|
4195
|
+
notifyApplied(ctx, `Strict agent files ${enabled ? "enabled" : "disabled"}. Takes effect on next pi session.`);
|
|
4196
|
+
} else if (id === "disableDefaultAgents") {
|
|
4197
|
+
const enabled = value === "on";
|
|
4198
|
+
setDisableDefaultAgents(enabled);
|
|
4199
|
+
notifyApplied(ctx, `Default agents ${enabled ? "disabled" : "enabled"}. Tool spec change takes effect on next pi session.`);
|
|
4200
|
+
} else if (id === "fallbackSubagent") {
|
|
4201
|
+
setFallbackSubagent(value);
|
|
4202
|
+
notifyApplied(
|
|
4203
|
+
ctx,
|
|
4204
|
+
value === NO_FALLBACK
|
|
4205
|
+
? "Unknown or disabled agent types will now be rejected"
|
|
4206
|
+
: `Unknown agent types will fall back to ${value}`,
|
|
4207
|
+
);
|
|
4208
|
+
} else if (id === "outputTranscript") {
|
|
4209
|
+
const enabled = value === "on";
|
|
4210
|
+
setOutputTranscriptDefault(enabled);
|
|
4211
|
+
notifyApplied(ctx, `Output transcript ${enabled ? "enabled" : "disabled"} by default`);
|
|
4212
|
+
} else if (id === "worktreeIsolation") {
|
|
4213
|
+
const enabled = value === "on";
|
|
4214
|
+
setWorktreeIsolationEnabled(enabled);
|
|
4215
|
+
// The refusal is live, but the tool schema is built at registration, so
|
|
4216
|
+
// the isolation parameter only appears/disappears next session.
|
|
4217
|
+
notifyApplied(
|
|
4218
|
+
ctx,
|
|
4219
|
+
`Worktree isolation ${enabled ? "enabled" : "disabled"}. Tool parameter updates on next pi session.`,
|
|
4220
|
+
);
|
|
4221
|
+
} else if (id === "packageAgents" || id === "packageWorkflows") {
|
|
4222
|
+
const enabled = value === "on";
|
|
4223
|
+
const isAgents = id === "packageAgents";
|
|
4224
|
+
const previous = isAgents ? getPackageAgentsGate() : getPackageWorkflowsGate();
|
|
4225
|
+
(isAgents ? setPackageAgentsGate : setPackageWorkflowsGate)(enabled);
|
|
4226
|
+
reloadCustomAgents();
|
|
4227
|
+
const label = isAgents ? "Package agents" : "Package workflows";
|
|
4228
|
+
// An allowlist cannot be expressed by an on/off row, so say plainly that
|
|
4229
|
+
// it is gone rather than letting a toggle quietly widen the gate.
|
|
4230
|
+
const replaced = Array.isArray(previous) && previous.length > 0
|
|
4231
|
+
? ` Replaced the allowlist (${previous.join(", ")}) — re-add it in .pi/subagents.json to narrow it again.`
|
|
4232
|
+
: "";
|
|
4233
|
+
// Same caveat every row that changes the advertised agent set carries:
|
|
4234
|
+
// the `Agent` tool's description and schema are built at registration,
|
|
4235
|
+
// so until the next session the model is reading a stale type list.
|
|
4236
|
+
const specNote = isAgents ? " Tool spec change takes effect on next pi session." : "";
|
|
4237
|
+
notifyApplied(ctx, `${label} ${enabled ? "enabled" : "disabled"}.${replaced}${specNote}`);
|
|
4238
|
+
} else if (id === "toolDescriptionMode") {
|
|
4239
|
+
setToolDescriptionMode(value as ToolDescriptionMode);
|
|
4240
|
+
notifyApplied(ctx, `Tool description set to ${value}. Takes effect on next pi session.`);
|
|
4241
|
+
} else if (id === "reportUsage") {
|
|
4242
|
+
const enabled = value === "on";
|
|
4243
|
+
setReportUsage(enabled);
|
|
4244
|
+
notifyApplied(
|
|
4245
|
+
ctx,
|
|
4246
|
+
enabled
|
|
4247
|
+
? "Subagent usage now counted in this session's totals"
|
|
4248
|
+
: "Subagent usage no longer counted in this session's totals",
|
|
4249
|
+
);
|
|
4250
|
+
} else if (id === "showCost") {
|
|
4251
|
+
const enabled = value === "on";
|
|
4252
|
+
setShowCost(enabled);
|
|
4253
|
+
notifyApplied(ctx, `Cost display ${enabled ? "enabled" : "disabled"}`);
|
|
4254
|
+
} else if (id === "showModel") {
|
|
4255
|
+
const enabled = value === "on";
|
|
4256
|
+
setShowModel(enabled);
|
|
4257
|
+
notifyApplied(ctx, `Model display ${enabled ? "enabled" : "disabled"}`);
|
|
4258
|
+
} else if (id === "viewerMarkdown") {
|
|
4259
|
+
setViewerMarkdown(value as ViewerMarkdownMode);
|
|
4260
|
+
notifyApplied(ctx, `Viewer markdown set to ${value}`);
|
|
4261
|
+
} else if (id === "fleetView") {
|
|
4262
|
+
const enabled = value === "on";
|
|
4263
|
+
setFleetViewEnabled(enabled);
|
|
4264
|
+
notifyApplied(ctx, `Fleet view ${enabled ? "enabled" : "disabled"}`);
|
|
4265
|
+
} else if (id === "agentMentions") {
|
|
4266
|
+
const mode = value as AgentMentionMode;
|
|
4267
|
+
setAgentMentionMode(mode);
|
|
4268
|
+
notifyApplied(
|
|
4269
|
+
ctx,
|
|
4270
|
+
mode === "off"
|
|
4271
|
+
? "Agent mentions disabled"
|
|
4272
|
+
: mode === "model"
|
|
4273
|
+
? "Agent mentions on — a conversation clone starts a mentioned agent off-screen"
|
|
4274
|
+
: "Agent mentions on — a mentioned agent starts here, with no model call",
|
|
4275
|
+
);
|
|
4276
|
+
} else if (id === "rememberAgents") {
|
|
4277
|
+
const enabled = value === "on";
|
|
4278
|
+
setRememberAgents(enabled);
|
|
4279
|
+
notifyApplied(ctx, `Remember agents ${enabled ? "enabled" : "disabled"}`);
|
|
4280
|
+
} else if (id === "widgetMode") {
|
|
4281
|
+
setWidgetMode(value as WidgetMode);
|
|
4282
|
+
notifyApplied(ctx, `Widget set to ${value}`);
|
|
4283
|
+
}
|
|
4284
|
+
}
|
|
4285
|
+
|
|
4286
|
+
let list: SettingsList;
|
|
4287
|
+
// Track current selection index directly (SettingsList doesn't expose it).
|
|
4288
|
+
// Updated on arrow keys so Enter knows which field is selected immediately.
|
|
4289
|
+
let currentIndex = 0;
|
|
4290
|
+
|
|
4291
|
+
const result = await ctx.ui.custom<string | undefined>((_tui, _theme, _kb, done) => {
|
|
4292
|
+
const items = buildItems();
|
|
4293
|
+
|
|
4294
|
+
list = new SettingsList(
|
|
4295
|
+
items,
|
|
4296
|
+
items.length + 2,
|
|
4297
|
+
getSettingsListTheme(),
|
|
4298
|
+
(id, newValue) => {
|
|
4299
|
+
applyValue(id, newValue);
|
|
4300
|
+
},
|
|
4301
|
+
() => done(undefined as undefined),
|
|
4302
|
+
);
|
|
4303
|
+
|
|
4304
|
+
const container = new Container();
|
|
4305
|
+
container.addChild(new Text("⚙ Subagent Settings", 0, 0));
|
|
4306
|
+
container.addChild(new Spacer(1));
|
|
4307
|
+
container.addChild(list);
|
|
4308
|
+
|
|
4309
|
+
return {
|
|
4310
|
+
render: (w: number) => container.render(w),
|
|
4311
|
+
invalidate: () => container.invalidate(),
|
|
4312
|
+
handleInput: (data: string) => {
|
|
4313
|
+
// Track navigation so Enter knows the current field
|
|
4314
|
+
if (matchesKey(data, "up")) {
|
|
4315
|
+
currentIndex = Math.max(0, currentIndex - 1);
|
|
4316
|
+
} else if (matchesKey(data, "down")) {
|
|
4317
|
+
currentIndex = Math.min(items.length - 1, currentIndex + 1);
|
|
4318
|
+
}
|
|
4319
|
+
|
|
4320
|
+
// Enter on numeric field → close and prompt for typed input
|
|
4321
|
+
if (matchesKey(data, Key.enter) && NUMERIC_IDS.has(items[currentIndex].id)) {
|
|
4322
|
+
done(items[currentIndex].id);
|
|
4323
|
+
return;
|
|
4324
|
+
}
|
|
4325
|
+
list.handleInput?.(data);
|
|
4326
|
+
},
|
|
4327
|
+
};
|
|
4328
|
+
});
|
|
4329
|
+
|
|
4330
|
+
// If a numeric field ID was returned, prompt for typed input
|
|
4331
|
+
if (result && NUMERIC_IDS.has(result)) {
|
|
4332
|
+
const current = result === "maxConcurrent"
|
|
4333
|
+
? String(manager.getMaxConcurrent())
|
|
4334
|
+
: result === "maxConcurrentForeground"
|
|
4335
|
+
? String(manager.getMaxConcurrentForeground())
|
|
4336
|
+
: result === "defaultMaxTurns"
|
|
4337
|
+
? String(getDefaultMaxTurns() ?? 0)
|
|
4338
|
+
: result === "maxSubagentDepth"
|
|
4339
|
+
? String(getMaxSubagentDepth())
|
|
4340
|
+
: String(getGraceTurns());
|
|
4341
|
+
|
|
4342
|
+
const label = result === "maxConcurrent"
|
|
4343
|
+
? "Max concurrency (1+)"
|
|
4344
|
+
: result === "maxConcurrentForeground"
|
|
4345
|
+
? "Max foreground concurrency (0 = unlimited)"
|
|
4346
|
+
: result === "defaultMaxTurns"
|
|
4347
|
+
? "Default max turns (0 = unlimited)"
|
|
4348
|
+
: result === "maxSubagentDepth"
|
|
4349
|
+
? "Nested depth (0/1 = nesting off)"
|
|
4350
|
+
: "Grace turns (1+)";
|
|
4351
|
+
|
|
4352
|
+
// Loop until user enters a valid integer or cancels (Esc / null).
|
|
4353
|
+
// Silently trims whitespace; rejects non-numeric input by re-prompting.
|
|
4354
|
+
let input: string | undefined = await ctx.ui.input(label, current);
|
|
4355
|
+
while (input != null) {
|
|
4356
|
+
const trimmed = input.trim();
|
|
4357
|
+
const n = Number(trimmed);
|
|
4358
|
+
if (trimmed !== "" && Number.isInteger(n)) {
|
|
4359
|
+
applyValue(result, String(n));
|
|
4360
|
+
await showSettings(ctx);
|
|
4361
|
+
return;
|
|
4362
|
+
}
|
|
4363
|
+
// Invalid — re-prompt with the user's last entry so they can edit it
|
|
4364
|
+
input = await ctx.ui.input(label, trimmed);
|
|
4365
|
+
}
|
|
4366
|
+
}
|
|
4367
|
+
}
|
|
4368
|
+
|
|
4369
|
+
// Persist the current snapshot, emit `subagents:settings_changed`, and surface
|
|
4370
|
+
// the right toast. Successful saves show info; persistence failures downgrade
|
|
4371
|
+
// to warning so users aren't silently reverted on restart. Event fires regardless
|
|
4372
|
+
// of outcome so listeners see the in-memory change.
|
|
4373
|
+
/**
|
|
4374
|
+
* Persist + broadcast the settings, silent on success — for a change whose
|
|
4375
|
+
* feedback is the UI it just changed: the viewer's `m` key, where a
|
|
4376
|
+
* notification per press would talk over the overlay it is describing.
|
|
4377
|
+
*
|
|
4378
|
+
* A *failed* write still speaks. Every other settings path warns when the
|
|
4379
|
+
* value is session-only, and swallowing it here would leave a preference
|
|
4380
|
+
* looking persisted when the next session will not have it.
|
|
4381
|
+
*/
|
|
4382
|
+
function persistSettings(ctx: ExtensionCommandContext | undefined, changeMsg: string): void {
|
|
4383
|
+
const { message, level } = saveAndEmitChanged(
|
|
4384
|
+
snapshotSettings(),
|
|
4385
|
+
changeMsg,
|
|
4386
|
+
(event, payload) => pi.events.emit(event, payload),
|
|
4387
|
+
);
|
|
4388
|
+
// `ctx` is absent only on the fleet path between sessions, where
|
|
4389
|
+
// `currentCtx` has been cleared and there is no UI to carry the warning to.
|
|
4390
|
+
// The write still happens.
|
|
4391
|
+
if (level === "warning") ctx?.ui.notify(message, level);
|
|
4392
|
+
}
|
|
4393
|
+
|
|
4394
|
+
function notifyApplied(ctx: ExtensionCommandContext, successMsg: string) {
|
|
4395
|
+
const { message, level } = saveAndEmitChanged(
|
|
4396
|
+
snapshotSettings(),
|
|
4397
|
+
successMsg,
|
|
4398
|
+
(event, payload) => pi.events.emit(event, payload),
|
|
4399
|
+
);
|
|
4400
|
+
ctx.ui.notify(message, level);
|
|
4401
|
+
}
|
|
4402
|
+
|
|
4403
|
+
pi.registerCommand("agents", {
|
|
4404
|
+
description: "Manage agents",
|
|
4405
|
+
handler: async (_args, ctx) => { await showAgentsMenu(ctx); },
|
|
4406
|
+
});
|
|
4407
|
+
|
|
4408
|
+
/**
|
|
4409
|
+
* What `/agents → Workflows` and the fleet list's `workflow` rows need from
|
|
4410
|
+
* here. One object, built once: both entry points open the same inspector,
|
|
4411
|
+
* and handing them different views of the session would let the two drift.
|
|
4412
|
+
*/
|
|
4413
|
+
const workflowMenuDeps: WorkflowMenuDeps = {
|
|
4414
|
+
tasks: workflowTasks,
|
|
4415
|
+
getRecord: id => manager.getRecord(id),
|
|
4416
|
+
viewAgentConversation,
|
|
4417
|
+
// Read lazily: `currentCtx` is rebound on every session_start, and the
|
|
4418
|
+
// fleet list may act between sessions, when there is none.
|
|
4419
|
+
getCtx: () => currentCtx as unknown as ExtensionCommandContext | undefined,
|
|
4420
|
+
};
|
|
4421
|
+
|
|
4422
|
+
fleet.setWorkflowSource(fleetWorkflows, id => openWorkflowFromFleet(id, workflowMenuDeps));
|
|
4423
|
+
}
|