@clanker-code/pi-subagents 0.10.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/.plans/PLAN-next-changes.md +183 -0
- package/.plans/README.md +14 -0
- package/AGENTS.md +31 -0
- package/CHANGELOG.md +583 -0
- package/CLAUDE.md +1 -0
- package/LICENSE +21 -0
- package/README.md +630 -0
- package/RELEASE.md +39 -0
- package/dist/abort-resend.d.ts +35 -0
- package/dist/abort-resend.js +71 -0
- package/dist/agent-details.d.ts +17 -0
- package/dist/agent-details.js +22 -0
- package/dist/agent-manager.d.ts +132 -0
- package/dist/agent-manager.js +493 -0
- package/dist/agent-runner.d.ts +165 -0
- package/dist/agent-runner.js +732 -0
- package/dist/agent-tool-description.d.ts +9 -0
- package/dist/agent-tool-description.js +147 -0
- package/dist/agent-types.d.ts +60 -0
- package/dist/agent-types.js +157 -0
- package/dist/context.d.ts +12 -0
- package/dist/context.js +56 -0
- package/dist/cross-extension-rpc.d.ts +46 -0
- package/dist/cross-extension-rpc.js +76 -0
- package/dist/custom-agents.d.ts +14 -0
- package/dist/custom-agents.js +149 -0
- package/dist/default-agents.d.ts +7 -0
- package/dist/default-agents.js +119 -0
- package/dist/enabled-models.d.ts +49 -0
- package/dist/enabled-models.js +145 -0
- package/dist/env.d.ts +6 -0
- package/dist/env.js +28 -0
- package/dist/group-join.d.ts +32 -0
- package/dist/group-join.js +116 -0
- package/dist/index.d.ts +36 -0
- package/dist/index.js +1918 -0
- package/dist/invocation-config.d.ts +25 -0
- package/dist/invocation-config.js +19 -0
- package/dist/memory.d.ts +49 -0
- package/dist/memory.js +151 -0
- package/dist/model-resolver.d.ts +19 -0
- package/dist/model-resolver.js +62 -0
- package/dist/notifications.d.ts +6 -0
- package/dist/notifications.js +107 -0
- package/dist/output-file.d.ts +24 -0
- package/dist/output-file.js +86 -0
- package/dist/peek.d.ts +37 -0
- package/dist/peek.js +121 -0
- package/dist/prompts.d.ts +40 -0
- package/dist/prompts.js +95 -0
- package/dist/schedule-store.d.ts +38 -0
- package/dist/schedule-store.js +155 -0
- package/dist/schedule.d.ts +109 -0
- package/dist/schedule.js +338 -0
- package/dist/settings.d.ts +135 -0
- package/dist/settings.js +168 -0
- package/dist/skill-loader.d.ts +24 -0
- package/dist/skill-loader.js +93 -0
- package/dist/status-note.d.ts +13 -0
- package/dist/status-note.js +24 -0
- package/dist/types.d.ts +184 -0
- package/dist/types.js +7 -0
- package/dist/ui/agent-tool-rendering.d.ts +34 -0
- package/dist/ui/agent-tool-rendering.js +154 -0
- package/dist/ui/agent-widget-tree.d.ts +33 -0
- package/dist/ui/agent-widget-tree.js +130 -0
- package/dist/ui/agent-widget.d.ts +156 -0
- package/dist/ui/agent-widget.js +408 -0
- package/dist/ui/conversation-viewer.d.ts +47 -0
- package/dist/ui/conversation-viewer.js +290 -0
- package/dist/ui/menu-select.d.ts +20 -0
- package/dist/ui/menu-select.js +46 -0
- package/dist/ui/schedule-menu.d.ts +16 -0
- package/dist/ui/schedule-menu.js +99 -0
- package/dist/ui/viewer-keys.d.ts +20 -0
- package/dist/ui/viewer-keys.js +17 -0
- package/dist/usage.d.ts +50 -0
- package/dist/usage.js +49 -0
- package/dist/wait.d.ts +10 -0
- package/dist/wait.js +37 -0
- package/dist/worktree.d.ts +45 -0
- package/dist/worktree.js +160 -0
- package/docs/design/default-extension-tool-exposure.md +56 -0
- package/docs/superpowers/plans/2026-06-19-recursive-subagent-widget.md +600 -0
- package/docs/superpowers/specs/2026-06-19-recursive-subagent-widget-design.md +189 -0
- package/examples/agent-tool-description.md +45 -0
- package/package.json +56 -0
- package/reviews/proposal-structured-output-schema.md +135 -0
- package/reviews/recursive-subagent-widget-preview-rev2.png +0 -0
- package/reviews/recursive-subagent-widget-preview.html +137 -0
- package/reviews/recursive-subagent-widget-preview.png +0 -0
- package/reviews/subagent-features-comparison.md +350 -0
- package/src/abort-resend.ts +75 -0
- package/src/agent-details.ts +31 -0
- package/src/agent-manager.ts +596 -0
- package/src/agent-runner.ts +872 -0
- package/src/agent-tool-description.ts +163 -0
- package/src/agent-types.ts +189 -0
- package/src/context.ts +58 -0
- package/src/cross-extension-rpc.ts +122 -0
- package/src/custom-agents.ts +160 -0
- package/src/default-agents.ts +123 -0
- package/src/enabled-models.ts +180 -0
- package/src/env.ts +33 -0
- package/src/group-join.ts +141 -0
- package/src/index.ts +2115 -0
- package/src/invocation-config.ts +42 -0
- package/src/memory.ts +165 -0
- package/src/model-resolver.ts +81 -0
- package/src/notifications.ts +120 -0
- package/src/output-file.ts +96 -0
- package/src/peek.ts +155 -0
- package/src/prompts.ts +129 -0
- package/src/schedule-store.ts +153 -0
- package/src/schedule.ts +365 -0
- package/src/settings.ts +289 -0
- package/src/skill-loader.ts +102 -0
- package/src/status-note.ts +25 -0
- package/src/types.ts +195 -0
- package/src/ui/agent-tool-rendering.ts +175 -0
- package/src/ui/agent-widget-tree.ts +169 -0
- package/src/ui/agent-widget.ts +497 -0
- package/src/ui/conversation-viewer.ts +297 -0
- package/src/ui/menu-select.ts +68 -0
- package/src/ui/schedule-menu.ts +105 -0
- package/src/ui/viewer-keys.ts +39 -0
- package/src/usage.ts +60 -0
- package/src/wait.ts +44 -0
- package/src/worktree.ts +191 -0
- package/vitest.config.ts +25 -0
package/src/index.ts
ADDED
|
@@ -0,0 +1,2115 @@
|
|
|
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
|
+
* list_models — LLM-callable: enumerate available models in the current registry
|
|
9
|
+
*
|
|
10
|
+
* Commands:
|
|
11
|
+
* /agents — Interactive agent management menu
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { randomUUID } from "node:crypto";
|
|
15
|
+
import { existsSync, mkdirSync, readFileSync, unlinkSync } from "node:fs";
|
|
16
|
+
import { join } from "node:path";
|
|
17
|
+
import { defineTool, type ExtensionAPI, type ExtensionCommandContext, type ExtensionContext, getAgentDir, getSettingsListTheme } from "@earendil-works/pi-coding-agent";
|
|
18
|
+
import { Container, Key, matchesKey, type SettingItem, SettingsList, Spacer, Text } from "@earendil-works/pi-tui";
|
|
19
|
+
import { Type } from "@sinclair/typebox";
|
|
20
|
+
import { registerAbortResend } from "./abort-resend.js";
|
|
21
|
+
import { buildDetails, formatLifetimeTokens } from "./agent-details.js";
|
|
22
|
+
import { AgentManager } from "./agent-manager.js";
|
|
23
|
+
import { getAgentConversation, getCurrentExtensionAgentId, getCurrentExtensionDepth, getDefaultMaxTurns, getGraceTurns, normalizeMaxTurns, SUBAGENT_TOOL_NAMES, setDefaultMaxTurns, setGraceTurns, steerAgent } from "./agent-runner.js";
|
|
24
|
+
import { buildAgentToolDescription, getModelLabelFromConfig } from "./agent-tool-description.js";
|
|
25
|
+
import { BUILTIN_TOOL_NAMES, getAgentConfig, getAllTypes, getAvailableTypes, isDefaultsDisabled, registerAgents, resolveType, setDefaultsDisabled } from "./agent-types.js";
|
|
26
|
+
import { registerRpcHandlers } from "./cross-extension-rpc.js";
|
|
27
|
+
import { loadCustomAgents } from "./custom-agents.js";
|
|
28
|
+
import { isModelInScope, readEnabledModels, resolveEnabledModels } from "./enabled-models.js";
|
|
29
|
+
import { GroupJoinManager } from "./group-join.js";
|
|
30
|
+
import { resolveAgentInvocationConfig, resolveJoinMode } from "./invocation-config.js";
|
|
31
|
+
import { type ModelRegistry, resolveModel } from "./model-resolver.js";
|
|
32
|
+
import { buildNotificationDetails, formatTaskNotification, registerSubagentNotificationRenderer } from "./notifications.js";
|
|
33
|
+
import { createOutputFilePath, streamToOutputFile, writeInitialEntry } from "./output-file.js";
|
|
34
|
+
import { type PeekOptions, peekAgentOutput } from "./peek.js";
|
|
35
|
+
import { SubagentScheduler } from "./schedule.js";
|
|
36
|
+
import { resolveStorePath, ScheduleStore } from "./schedule-store.js";
|
|
37
|
+
import { applyAndEmitLoaded, DEFAULT_WAIT_TIMEOUT_SECONDS, type SubagentsSettings, saveAndEmitChanged, type ToolDescriptionMode } from "./settings.js";
|
|
38
|
+
import { getStatusNote } from "./status-note.js";
|
|
39
|
+
import { type AgentConfig, type AgentInvocation, type AgentRecord, type JoinMode, MAX_RECURSIVE_DEPTH, type NotificationDetails, type SubagentType } from "./types.js";
|
|
40
|
+
import { renderAgentCall, renderAgentResult, renderSteerCall, tailPreview } from "./ui/agent-tool-rendering.js";
|
|
41
|
+
import {
|
|
42
|
+
type AgentActivity,
|
|
43
|
+
type AgentDetails,
|
|
44
|
+
AgentWidget,
|
|
45
|
+
buildInvocationTags,
|
|
46
|
+
describeActivity,
|
|
47
|
+
formatContextWindow,
|
|
48
|
+
formatDuration,
|
|
49
|
+
getDisplayName,
|
|
50
|
+
getPromptModeLabel,
|
|
51
|
+
type UICtx,
|
|
52
|
+
} from "./ui/agent-widget.js";
|
|
53
|
+
import type { WidgetAgentSnapshot, WidgetDisplayMode } from "./ui/agent-widget-tree.js";
|
|
54
|
+
import { menuSelect } from "./ui/menu-select.js";
|
|
55
|
+
import { showSchedulesMenu } from "./ui/schedule-menu.js";
|
|
56
|
+
import { addUsage, getLifetimeTotal, getSessionContextPercent } from "./usage.js";
|
|
57
|
+
import { formatWaitTimeout, raceWait, type WaitOutcome, waitTimeoutMessage } from "./wait.js";
|
|
58
|
+
|
|
59
|
+
// ---- Shared helpers ----
|
|
60
|
+
|
|
61
|
+
/** Tool execute return value for a text response. */
|
|
62
|
+
function textResult(msg: string, details?: AgentDetails) {
|
|
63
|
+
return { content: [{ type: "text" as const, text: msg }], details: details as any };
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Create an AgentActivity state and spawn callbacks for tracking tool usage.
|
|
68
|
+
* Used by the background spawn path to track tool usage.
|
|
69
|
+
*/
|
|
70
|
+
export function createActivityTracker(maxTurns?: number, onStreamUpdate?: () => void) {
|
|
71
|
+
const initialActivityDescription = "thinking…";
|
|
72
|
+
const state: AgentActivity = {
|
|
73
|
+
activeTools: new Map(),
|
|
74
|
+
toolUses: 0,
|
|
75
|
+
turnCount: 1,
|
|
76
|
+
maxTurns,
|
|
77
|
+
responseText: "",
|
|
78
|
+
session: undefined,
|
|
79
|
+
lifetimeUsage: { input: 0, output: 0, cacheWrite: 0 },
|
|
80
|
+
activityDescription: initialActivityDescription,
|
|
81
|
+
activityDescriptionUpdatedAt: Date.now(),
|
|
82
|
+
};
|
|
83
|
+
|
|
84
|
+
const updateActivityDescription = () => {
|
|
85
|
+
const next = describeActivity(state.activeTools, state.responseText);
|
|
86
|
+
if (next !== state.activityDescription) {
|
|
87
|
+
state.activityDescription = next;
|
|
88
|
+
state.activityDescriptionUpdatedAt = Date.now();
|
|
89
|
+
}
|
|
90
|
+
};
|
|
91
|
+
|
|
92
|
+
const callbacks = {
|
|
93
|
+
onToolActivity: (activity: { type: "start" | "end"; toolName: string }) => {
|
|
94
|
+
if (activity.type === "start") {
|
|
95
|
+
state.activeTools.set(activity.toolName + "_" + Date.now(), activity.toolName);
|
|
96
|
+
} else {
|
|
97
|
+
for (const [key, name] of state.activeTools) {
|
|
98
|
+
if (name === activity.toolName) { state.activeTools.delete(key); break; }
|
|
99
|
+
}
|
|
100
|
+
state.toolUses++;
|
|
101
|
+
}
|
|
102
|
+
updateActivityDescription();
|
|
103
|
+
onStreamUpdate?.();
|
|
104
|
+
},
|
|
105
|
+
onTextDelta: (_delta: string, fullText: string) => {
|
|
106
|
+
state.responseText = fullText;
|
|
107
|
+
updateActivityDescription();
|
|
108
|
+
onStreamUpdate?.();
|
|
109
|
+
},
|
|
110
|
+
onTurnEnd: (turnCount: number) => {
|
|
111
|
+
state.turnCount = turnCount;
|
|
112
|
+
onStreamUpdate?.();
|
|
113
|
+
},
|
|
114
|
+
onSessionCreated: (session: any) => {
|
|
115
|
+
state.session = session;
|
|
116
|
+
},
|
|
117
|
+
onAssistantUsage: (usage: { input: number; output: number; cacheWrite: number }) => {
|
|
118
|
+
addUsage(state.lifetimeUsage, usage);
|
|
119
|
+
onStreamUpdate?.();
|
|
120
|
+
},
|
|
121
|
+
};
|
|
122
|
+
|
|
123
|
+
return { state, callbacks };
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
export default function (pi: ExtensionAPI) {
|
|
127
|
+
const extensionDepth = getCurrentExtensionDepth();
|
|
128
|
+
const extensionAgentId = getCurrentExtensionAgentId();
|
|
129
|
+
const nextSubagentDepth = extensionDepth + 1;
|
|
130
|
+
registerSubagentNotificationRenderer(pi);
|
|
131
|
+
|
|
132
|
+
/** Reload agents from .pi/agents/*.md and merge with defaults (called on init and each Agent invocation). */
|
|
133
|
+
const reloadCustomAgents = () => {
|
|
134
|
+
const userAgents = loadCustomAgents(process.cwd());
|
|
135
|
+
registerAgents(userAgents);
|
|
136
|
+
};
|
|
137
|
+
|
|
138
|
+
// Initial load
|
|
139
|
+
reloadCustomAgents();
|
|
140
|
+
|
|
141
|
+
// ---- Abort + resend queued message (default F9) ----
|
|
142
|
+
// Escape dumps the queue into the editor; this shortcut aborts and auto-sends
|
|
143
|
+
// the queue as the next turn instead. General harness workaround. Read at
|
|
144
|
+
// session start; the env var PI_ABORT_RESEND_KEY and the setting both override
|
|
145
|
+
// the "f9" default (env > setting > default). A setting change applies next
|
|
146
|
+
// session, like other start-time settings.
|
|
147
|
+
let abortResendKey: string | undefined;
|
|
148
|
+
|
|
149
|
+
// ---- Agent activity tracking + widget ----
|
|
150
|
+
const agentActivity = new Map<string, AgentActivity>();
|
|
151
|
+
|
|
152
|
+
// ---- Cancellable pending notifications ----
|
|
153
|
+
// Holds notifications briefly so get_subagent_result can cancel them
|
|
154
|
+
// before they reach pi.sendMessage (fire-and-forget).
|
|
155
|
+
const pendingNudges = new Map<string, ReturnType<typeof setTimeout>>();
|
|
156
|
+
const NUDGE_HOLD_MS = 200;
|
|
157
|
+
|
|
158
|
+
function scheduleNudge(key: string, send: () => void, delay = NUDGE_HOLD_MS) {
|
|
159
|
+
cancelNudge(key);
|
|
160
|
+
pendingNudges.set(key, setTimeout(() => {
|
|
161
|
+
pendingNudges.delete(key);
|
|
162
|
+
try { send(); } catch { /* ignore stale completion side-effect errors */ }
|
|
163
|
+
}, delay));
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
function cancelNudge(key: string) {
|
|
167
|
+
const timer = pendingNudges.get(key);
|
|
168
|
+
if (timer != null) {
|
|
169
|
+
clearTimeout(timer);
|
|
170
|
+
pendingNudges.delete(key);
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
// ---- Retry stash (recoverable Agent invocations) ----
|
|
175
|
+
// On pre-spawn validation failures (model not found / out of scope / worktree
|
|
176
|
+
// validation), the full invocation is stashed under a short handle so the
|
|
177
|
+
// orchestrator can re-invoke via { retry: "<handle>", model: "<valid>" } and
|
|
178
|
+
// pick a valid model/agent WITHOUT re-emitting the (expensive, uncached)
|
|
179
|
+
// prompt. Session-scoped: cleared on session switch/shutdown. 10-min TTL.
|
|
180
|
+
interface StashedInvocation {
|
|
181
|
+
params: Record<string, unknown>;
|
|
182
|
+
stashedAt: number;
|
|
183
|
+
}
|
|
184
|
+
const RETRY_TTL_MS = 10 * 60_000;
|
|
185
|
+
const retryStash = new Map<string, StashedInvocation>();
|
|
186
|
+
|
|
187
|
+
/** Prune expired retry handles. Called lazily on stash read/write. */
|
|
188
|
+
function sweepRetryStash() {
|
|
189
|
+
const cutoff = Date.now() - RETRY_TTL_MS;
|
|
190
|
+
for (const [h, entry] of retryStash) {
|
|
191
|
+
if (entry.stashedAt < cutoff) retryStash.delete(h);
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/** Stash an invocation (preserving a stable handle on re-stash). Returns the handle.
|
|
196
|
+
* `omit` drops the given keys from the stash (used when the failing field itself
|
|
197
|
+
* shouldn't be retried as-is — e.g. an `isolation` that just failed). */
|
|
198
|
+
function stashInvocation(
|
|
199
|
+
params: Record<string, unknown>,
|
|
200
|
+
handle?: string,
|
|
201
|
+
omit: string[] = [],
|
|
202
|
+
): string {
|
|
203
|
+
sweepRetryStash();
|
|
204
|
+
const h = handle ?? `retry-${randomUUID().slice(0, 8)}`;
|
|
205
|
+
const copy: Record<string, unknown> = { ...params };
|
|
206
|
+
for (const k of omit) delete copy[k];
|
|
207
|
+
retryStash.set(h, { params: copy, stashedAt: Date.now() });
|
|
208
|
+
return h;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/** Category of recoverable failure — drives the tailored retry hint. */
|
|
212
|
+
type RetryKind = "model" | "subagent_type" | "isolation";
|
|
213
|
+
|
|
214
|
+
/** Build a recoverable failure result whose retry hint matches the failure kind. */
|
|
215
|
+
function retryableResult(handle: string, body: string, kind: RetryKind) {
|
|
216
|
+
const tail = `\n\nYour prompt was saved — you do NOT need to retype the prompt. To continue, re-invoke the Agent tool with:`;
|
|
217
|
+
const json = ` { "retry": "${handle}"${kindOverrideSnippet(kind)}}`;
|
|
218
|
+
const isolationNote =
|
|
219
|
+
kind === "isolation"
|
|
220
|
+
? `\n(The isolation that failed has been dropped for this handle, so retrying runs normally. If you have since fixed the repo — git init + commit — and want a worktree, add "isolation": "worktree".)`
|
|
221
|
+
: "";
|
|
222
|
+
return textResult(`${body}${tail}\n${json}${isolationNote}`);
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/** The override snippet appended to the retry JSON, tailored to the failure kind. */
|
|
226
|
+
function kindOverrideSnippet(kind: RetryKind): string {
|
|
227
|
+
switch (kind) {
|
|
228
|
+
case "model":
|
|
229
|
+
return `, "model": "<a valid model from the list above>"`;
|
|
230
|
+
case "subagent_type":
|
|
231
|
+
return `, "subagent_type": "<a valid type from the list above>"`;
|
|
232
|
+
case "isolation":
|
|
233
|
+
return "";
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
// ---- Individual nudge helper (async join mode) ----
|
|
238
|
+
function emitIndividualNudge(record: AgentRecord) {
|
|
239
|
+
if (record.resultConsumed) return; // re-check at send time
|
|
240
|
+
|
|
241
|
+
const notification = formatTaskNotification(record, 500);
|
|
242
|
+
const footer = record.outputFile ? `\nFull transcript available at: ${record.outputFile}` : '';
|
|
243
|
+
|
|
244
|
+
pi.sendMessage<NotificationDetails>({
|
|
245
|
+
customType: "subagent-notification",
|
|
246
|
+
content: notification + footer,
|
|
247
|
+
display: true,
|
|
248
|
+
details: buildNotificationDetails(record, 500, agentActivity.get(record.id)),
|
|
249
|
+
}, { deliverAs: "steer", triggerTurn: true });
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
function sendIndividualNudge(record: AgentRecord) {
|
|
253
|
+
agentActivity.delete(record.id);
|
|
254
|
+
widget.markFinished(record.id);
|
|
255
|
+
scheduleNudge(record.id, () => emitIndividualNudge(record));
|
|
256
|
+
widget.update();
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
// ---- Group join manager ----
|
|
260
|
+
const groupJoin = new GroupJoinManager(
|
|
261
|
+
(records, partial) => {
|
|
262
|
+
for (const r of records) { agentActivity.delete(r.id); widget.markFinished(r.id); }
|
|
263
|
+
|
|
264
|
+
const groupKey = `group:${records.map(r => r.id).join(",")}`;
|
|
265
|
+
scheduleNudge(groupKey, () => {
|
|
266
|
+
// Re-check at send time
|
|
267
|
+
const unconsumed = records.filter(r => !r.resultConsumed);
|
|
268
|
+
if (unconsumed.length === 0) { widget.update(); return; }
|
|
269
|
+
|
|
270
|
+
const notifications = unconsumed.map(r => formatTaskNotification(r, 300)).join('\n\n');
|
|
271
|
+
const label = partial
|
|
272
|
+
? `${unconsumed.length} agent(s) finished (partial — others still running)`
|
|
273
|
+
: `${unconsumed.length} agent(s) finished`;
|
|
274
|
+
|
|
275
|
+
const [first, ...rest] = unconsumed;
|
|
276
|
+
const details = buildNotificationDetails(first, 300, agentActivity.get(first.id));
|
|
277
|
+
if (rest.length > 0) {
|
|
278
|
+
details.others = rest.map(r => buildNotificationDetails(r, 300, agentActivity.get(r.id)));
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
pi.sendMessage<NotificationDetails>({
|
|
282
|
+
customType: "subagent-notification",
|
|
283
|
+
content: `Background agent group completed: ${label}\n\n${notifications}\n\nUse get_subagent_result for full output.`,
|
|
284
|
+
display: true,
|
|
285
|
+
details,
|
|
286
|
+
}, { deliverAs: "steer", triggerTurn: true });
|
|
287
|
+
});
|
|
288
|
+
widget.update();
|
|
289
|
+
},
|
|
290
|
+
30_000,
|
|
291
|
+
);
|
|
292
|
+
|
|
293
|
+
function widgetSnapshotFromEvent(payload: any): WidgetAgentSnapshot | undefined {
|
|
294
|
+
if (!payload || typeof payload.id !== "string" || typeof payload.type !== "string") return undefined;
|
|
295
|
+
return {
|
|
296
|
+
id: payload.id,
|
|
297
|
+
type: payload.type,
|
|
298
|
+
description: String(payload.description ?? payload.type),
|
|
299
|
+
status: String(payload.status ?? "running"),
|
|
300
|
+
startedAt: typeof payload.startedAt === "number" ? payload.startedAt : Date.now(),
|
|
301
|
+
completedAt: typeof payload.completedAt === "number" ? payload.completedAt : undefined,
|
|
302
|
+
error: typeof payload.error === "string" ? payload.error : undefined,
|
|
303
|
+
toolUses: typeof payload.toolUses === "number" ? payload.toolUses : 0,
|
|
304
|
+
depth: typeof payload.depth === "number" ? payload.depth : undefined,
|
|
305
|
+
parentAgentId: typeof payload.parentAgentId === "string" ? payload.parentAgentId : undefined,
|
|
306
|
+
invocation: payload.invocation,
|
|
307
|
+
};
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
/** Helper: build event data for lifecycle events from an AgentRecord. */
|
|
311
|
+
function buildEventData(record: AgentRecord) {
|
|
312
|
+
const durationMs = record.completedAt ? record.completedAt - record.startedAt : Date.now() - record.startedAt;
|
|
313
|
+
// All three fields are lifetime-accumulated (Σ over every assistant message_end),
|
|
314
|
+
// so they survive compaction together — input + output ≤ total always.
|
|
315
|
+
// tokens is omitted when nothing was ever produced (e.g. agent errored before
|
|
316
|
+
// any message_end fired), preserving prior payload shape.
|
|
317
|
+
const u = record.lifetimeUsage;
|
|
318
|
+
const total = getLifetimeTotal(u);
|
|
319
|
+
const tokens = total > 0
|
|
320
|
+
? { input: u.input, output: u.output, total }
|
|
321
|
+
: undefined;
|
|
322
|
+
return {
|
|
323
|
+
id: record.id,
|
|
324
|
+
type: record.type,
|
|
325
|
+
description: record.description,
|
|
326
|
+
result: record.result,
|
|
327
|
+
error: record.error,
|
|
328
|
+
status: record.status,
|
|
329
|
+
toolUses: record.toolUses,
|
|
330
|
+
durationMs,
|
|
331
|
+
tokens,
|
|
332
|
+
depth: record.depth,
|
|
333
|
+
parentAgentId: record.parentAgentId,
|
|
334
|
+
startedAt: record.startedAt,
|
|
335
|
+
completedAt: record.completedAt,
|
|
336
|
+
invocation: record.invocation,
|
|
337
|
+
};
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
// Background completion: route through group join or send individual nudge
|
|
341
|
+
const manager = new AgentManager((record) => {
|
|
342
|
+
// Emit lifecycle event based on terminal status
|
|
343
|
+
const isError = record.status === "error" || record.status === "stopped" || record.status === "aborted";
|
|
344
|
+
const eventData = buildEventData(record);
|
|
345
|
+
if (isError) {
|
|
346
|
+
pi.events.emit("subagents:failed", eventData);
|
|
347
|
+
} else {
|
|
348
|
+
pi.events.emit("subagents:completed", eventData);
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
// Persist final record for cross-extension history reconstruction
|
|
352
|
+
pi.appendEntry("subagents:record", {
|
|
353
|
+
id: record.id, type: record.type, description: record.description,
|
|
354
|
+
status: record.status, result: record.result, error: record.error,
|
|
355
|
+
startedAt: record.startedAt, completedAt: record.completedAt,
|
|
356
|
+
});
|
|
357
|
+
|
|
358
|
+
// Skip notification if result was already consumed via get_subagent_result
|
|
359
|
+
if (record.resultConsumed) {
|
|
360
|
+
agentActivity.delete(record.id);
|
|
361
|
+
widget.markFinished(record.id);
|
|
362
|
+
widget.update();
|
|
363
|
+
return;
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
// If this agent is pending batch finalization (debounce window still open),
|
|
367
|
+
// don't send an individual nudge — finalizeBatch will pick it up retroactively.
|
|
368
|
+
if (currentBatchAgents.some(a => a.id === record.id)) {
|
|
369
|
+
widget.update();
|
|
370
|
+
return;
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
const result = groupJoin.onAgentComplete(record);
|
|
374
|
+
if (result === 'pass') {
|
|
375
|
+
sendIndividualNudge(record);
|
|
376
|
+
}
|
|
377
|
+
// 'held' → do nothing, group will fire later
|
|
378
|
+
// 'delivered' → group callback already fired
|
|
379
|
+
widget.update();
|
|
380
|
+
}, undefined, (record) => {
|
|
381
|
+
// Emit started event when agent transitions to running (including from queue)
|
|
382
|
+
pi.events.emit("subagents:started", {
|
|
383
|
+
id: record.id,
|
|
384
|
+
type: record.type,
|
|
385
|
+
description: record.description,
|
|
386
|
+
depth: record.depth,
|
|
387
|
+
parentAgentId: record.parentAgentId,
|
|
388
|
+
status: "running",
|
|
389
|
+
startedAt: record.startedAt,
|
|
390
|
+
toolUses: record.toolUses,
|
|
391
|
+
invocation: record.invocation,
|
|
392
|
+
});
|
|
393
|
+
}, (record, info) => {
|
|
394
|
+
// Emit compacted event when agent's session compacts (preserves count on record).
|
|
395
|
+
pi.events.emit("subagents:compacted", {
|
|
396
|
+
id: record.id,
|
|
397
|
+
type: record.type,
|
|
398
|
+
description: record.description,
|
|
399
|
+
reason: info.reason,
|
|
400
|
+
tokensBefore: info.tokensBefore,
|
|
401
|
+
compactionCount: record.compactionCount,
|
|
402
|
+
depth: record.depth,
|
|
403
|
+
parentAgentId: record.parentAgentId,
|
|
404
|
+
});
|
|
405
|
+
});
|
|
406
|
+
|
|
407
|
+
// Expose manager via Symbol.for() global registry for cross-package access.
|
|
408
|
+
// Standard Node.js pattern for cross-package singletons (used by OpenTelemetry, etc.).
|
|
409
|
+
const MANAGER_KEY = Symbol.for("pi-subagents:manager");
|
|
410
|
+
(globalThis as any)[MANAGER_KEY] = {
|
|
411
|
+
waitForAll: () => manager.waitForAll(),
|
|
412
|
+
hasRunning: () => manager.hasRunning(),
|
|
413
|
+
spawn: (piRef: any, ctx: any, type: string, prompt: string, options: any) =>
|
|
414
|
+
manager.spawn(piRef, ctx, type, prompt, options),
|
|
415
|
+
getRecord: (id: string) => manager.getRecord(id),
|
|
416
|
+
};
|
|
417
|
+
|
|
418
|
+
// --- Cross-extension RPC via pi.events ---
|
|
419
|
+
let currentCtx: ExtensionContext | undefined;
|
|
420
|
+
|
|
421
|
+
// ---- Subagent scheduler ----
|
|
422
|
+
// Session-scoped: store is constructed inside session_start once sessionId
|
|
423
|
+
// is available. Mirrors pi-chonky-tasks's session-scoped task store —
|
|
424
|
+
// schedules reset on /new, restore on /resume.
|
|
425
|
+
const scheduler = new SubagentScheduler();
|
|
426
|
+
|
|
427
|
+
function startScheduler(ctx: ExtensionContext) {
|
|
428
|
+
try {
|
|
429
|
+
const sessionId = ctx.sessionManager?.getSessionId?.();
|
|
430
|
+
if (!sessionId) return; // sessionId not yet available — try again on next event
|
|
431
|
+
const path = resolveStorePath(ctx.cwd, sessionId);
|
|
432
|
+
const store = new ScheduleStore(path);
|
|
433
|
+
scheduler.start(pi, ctx, manager, store);
|
|
434
|
+
pi.events.emit("subagents:scheduler_ready", { sessionId, jobCount: store.list().length });
|
|
435
|
+
} catch (err) {
|
|
436
|
+
// Scheduling is non-essential — log and move on so the rest of the
|
|
437
|
+
// extension keeps working if e.g. .pi/ is unwritable.
|
|
438
|
+
console.warn("[pi-subagents] Failed to start scheduler:", err);
|
|
439
|
+
}
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
// Capture ctx from session_start for RPC spawn handler + start the scheduler.
|
|
443
|
+
pi.on("session_start", async (_event, ctx) => {
|
|
444
|
+
currentCtx = ctx;
|
|
445
|
+
manager.clearCompleted();
|
|
446
|
+
widget.clearSnapshots();
|
|
447
|
+
retryStash.clear();
|
|
448
|
+
if (isSchedulingEnabled() && !scheduler.isActive()) startScheduler(ctx);
|
|
449
|
+
});
|
|
450
|
+
|
|
451
|
+
pi.on("session_before_switch", () => {
|
|
452
|
+
manager.clearCompleted();
|
|
453
|
+
widget.clearSnapshots();
|
|
454
|
+
retryStash.clear();
|
|
455
|
+
scheduler.stop();
|
|
456
|
+
});
|
|
457
|
+
|
|
458
|
+
const { unsubPing: unsubPingRpc, unsubSpawn: unsubSpawnRpc, unsubStop: unsubStopRpc } = registerRpcHandlers({
|
|
459
|
+
events: pi.events,
|
|
460
|
+
pi,
|
|
461
|
+
getCtx: () => currentCtx,
|
|
462
|
+
manager,
|
|
463
|
+
});
|
|
464
|
+
|
|
465
|
+
// Broadcast readiness so extensions loaded after us can discover us
|
|
466
|
+
pi.events.emit("subagents:ready", {});
|
|
467
|
+
|
|
468
|
+
// On shutdown, abort all agents immediately and clean up.
|
|
469
|
+
// If the session is going down, there's nothing left to consume agent results.
|
|
470
|
+
pi.on("session_shutdown", async () => {
|
|
471
|
+
unsubSpawnRpc();
|
|
472
|
+
unsubStopRpc();
|
|
473
|
+
unsubPingRpc();
|
|
474
|
+
unsubWidgetStarted?.();
|
|
475
|
+
unsubWidgetCompleted?.();
|
|
476
|
+
unsubWidgetFailed?.();
|
|
477
|
+
currentCtx = undefined;
|
|
478
|
+
delete (globalThis as any)[MANAGER_KEY];
|
|
479
|
+
scheduler.stop();
|
|
480
|
+
manager.abortAll();
|
|
481
|
+
for (const timer of pendingNudges.values()) clearTimeout(timer);
|
|
482
|
+
pendingNudges.clear();
|
|
483
|
+
retryStash.clear();
|
|
484
|
+
manager.dispose();
|
|
485
|
+
});
|
|
486
|
+
|
|
487
|
+
// Live widget: show running agents above editor
|
|
488
|
+
const widget = new AgentWidget(manager, agentActivity);
|
|
489
|
+
const upsertWidgetEventSnapshot = (payload: unknown) => {
|
|
490
|
+
const snapshot = widgetSnapshotFromEvent(payload);
|
|
491
|
+
if (snapshot) widget.upsertSnapshot(snapshot);
|
|
492
|
+
};
|
|
493
|
+
const unsubWidgetStarted = pi.events.on("subagents:started", upsertWidgetEventSnapshot);
|
|
494
|
+
const unsubWidgetCompleted = pi.events.on("subagents:completed", upsertWidgetEventSnapshot);
|
|
495
|
+
const unsubWidgetFailed = pi.events.on("subagents:failed", upsertWidgetEventSnapshot);
|
|
496
|
+
|
|
497
|
+
// ---- Widget display configuration ----
|
|
498
|
+
let widgetDisplayMode: WidgetDisplayMode = "auto";
|
|
499
|
+
function getWidgetDisplayMode(): WidgetDisplayMode { return widgetDisplayMode; }
|
|
500
|
+
function setWidgetDisplayMode(mode: WidgetDisplayMode): void {
|
|
501
|
+
widgetDisplayMode = mode;
|
|
502
|
+
widget.setDisplayMode(mode);
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
// ---- Join mode configuration ----
|
|
506
|
+
let defaultJoinMode: JoinMode = 'async';
|
|
507
|
+
function getDefaultJoinMode(): JoinMode { return defaultJoinMode; }
|
|
508
|
+
function setDefaultJoinMode(mode: JoinMode) { defaultJoinMode = mode; }
|
|
509
|
+
|
|
510
|
+
// Master switch for the schedule subagent feature. Defaults to enabled.
|
|
511
|
+
// Read once at extension init (before tool registration) so the Agent tool's
|
|
512
|
+
// param schema reflects the persisted setting. Runtime toggles via /agents
|
|
513
|
+
// → Settings short-circuit the menu entry + the execute-time addJob path
|
|
514
|
+
// immediately, but the schema-level removal only takes effect on next
|
|
515
|
+
// extension load (next pi session). Documented in CHANGELOG/README.
|
|
516
|
+
let schedulingEnabled = true;
|
|
517
|
+
function isSchedulingEnabled(): boolean { return schedulingEnabled; }
|
|
518
|
+
function setSchedulingEnabled(b: boolean) { schedulingEnabled = b; }
|
|
519
|
+
|
|
520
|
+
// ---- Scope models configuration ----
|
|
521
|
+
// When enabled, subagent model choices are validated against `enabledModels`
|
|
522
|
+
// from pi's settings — both global `<agentDir>/settings.json` and
|
|
523
|
+
// project-local `<cwd>/.pi/settings.json` (project overrides global).
|
|
524
|
+
// Off by default; opt-in via `/agents → Settings`. See docstring on
|
|
525
|
+
// SubagentsSettings.scopeModels for the hard-error vs warn-and-proceed
|
|
526
|
+
// policy and its rationale.
|
|
527
|
+
let scopeModelsEnabled = false;
|
|
528
|
+
function isScopeModelsEnabled(): boolean { return scopeModelsEnabled; }
|
|
529
|
+
function setScopeModelsEnabled(enabled: boolean): void { scopeModelsEnabled = enabled; }
|
|
530
|
+
|
|
531
|
+
// ---- Disable default agents configuration ----
|
|
532
|
+
// When enabled, the three hardcoded default agents (general-purpose, Explore,
|
|
533
|
+
// Plan) are not registered. User-defined agents from .pi/agents/*.md are
|
|
534
|
+
// completely unaffected — only DEFAULT_AGENTS are suppressed.
|
|
535
|
+
// Defaults to false; opt-in via `/agents → Settings` or subagents.json.
|
|
536
|
+
// State lives in agent-types.ts (isDefaultsDisabled) because registerAgents
|
|
537
|
+
// needs it; this wrapper just re-registers after flipping it.
|
|
538
|
+
function setDisableDefaultAgents(b: boolean): void {
|
|
539
|
+
setDefaultsDisabled(b);
|
|
540
|
+
reloadCustomAgents(); // re-register with new setting
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
// ---- Agent tool description mode ----
|
|
544
|
+
// "full" (default) keeps the rich Claude Code-style description; "compact"
|
|
545
|
+
// swaps in a ~75% smaller one for small/local models (#91). Read once at
|
|
546
|
+
// tool registration — flipping it applies on the next pi session.
|
|
547
|
+
let toolDescriptionMode: ToolDescriptionMode = "full";
|
|
548
|
+
function getToolDescriptionMode(): ToolDescriptionMode { return toolDescriptionMode; }
|
|
549
|
+
function setToolDescriptionMode(mode: ToolDescriptionMode): void { toolDescriptionMode = mode; }
|
|
550
|
+
|
|
551
|
+
// ---- Wait timeout configuration ----
|
|
552
|
+
// How long get_subagent_result wait:true blocks before returning current
|
|
553
|
+
// status. Bounds the parent turn; the caller re-invokes to keep waiting.
|
|
554
|
+
let waitTimeoutSeconds = DEFAULT_WAIT_TIMEOUT_SECONDS;
|
|
555
|
+
function getWaitTimeoutSeconds(): number { return waitTimeoutSeconds; }
|
|
556
|
+
function setWaitTimeoutSeconds(seconds: number): void {
|
|
557
|
+
waitTimeoutSeconds = Math.min(3600, Math.max(30, Math.trunc(seconds)));
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
// ---- Batch tracking for smart join mode ----
|
|
561
|
+
// Collects background agent IDs spawned in the current turn for smart grouping.
|
|
562
|
+
// Uses a debounced timer: each new agent resets the 100ms window so that all
|
|
563
|
+
// parallel tool calls (which may be dispatched across multiple microtasks by the
|
|
564
|
+
// framework) are captured in the same batch.
|
|
565
|
+
let currentBatchAgents: { id: string; joinMode: JoinMode }[] = [];
|
|
566
|
+
let batchFinalizeTimer: ReturnType<typeof setTimeout> | undefined;
|
|
567
|
+
let batchCounter = 0;
|
|
568
|
+
|
|
569
|
+
/** Finalize the current batch: if 2+ smart-mode agents, register as a group. */
|
|
570
|
+
function finalizeBatch() {
|
|
571
|
+
batchFinalizeTimer = undefined;
|
|
572
|
+
const batchAgents = [...currentBatchAgents];
|
|
573
|
+
currentBatchAgents = [];
|
|
574
|
+
|
|
575
|
+
const smartAgents = batchAgents.filter(a => a.joinMode === 'smart' || a.joinMode === 'group');
|
|
576
|
+
if (smartAgents.length >= 2) {
|
|
577
|
+
const groupId = `batch-${++batchCounter}`;
|
|
578
|
+
const ids = smartAgents.map(a => a.id);
|
|
579
|
+
groupJoin.registerGroup(groupId, ids);
|
|
580
|
+
// Retroactively process agents that already completed during the debounce window.
|
|
581
|
+
// Their onComplete fired but was deferred (agent was in currentBatchAgents),
|
|
582
|
+
// so we feed them into the group now.
|
|
583
|
+
for (const id of ids) {
|
|
584
|
+
const record = manager.getRecord(id);
|
|
585
|
+
if (!record) continue;
|
|
586
|
+
record.groupId = groupId;
|
|
587
|
+
if (record.completedAt != null && !record.resultConsumed) {
|
|
588
|
+
groupJoin.onAgentComplete(record);
|
|
589
|
+
}
|
|
590
|
+
}
|
|
591
|
+
} else {
|
|
592
|
+
// No group formed — send individual nudges for any agents that completed
|
|
593
|
+
// during the debounce window and had their notification deferred.
|
|
594
|
+
for (const { id } of batchAgents) {
|
|
595
|
+
const record = manager.getRecord(id);
|
|
596
|
+
if (record?.completedAt != null && !record.resultConsumed) {
|
|
597
|
+
sendIndividualNudge(record);
|
|
598
|
+
}
|
|
599
|
+
}
|
|
600
|
+
}
|
|
601
|
+
}
|
|
602
|
+
|
|
603
|
+
// Grab UI context from first tool execution + clear lingering widget on new turn
|
|
604
|
+
pi.on("tool_execution_start", async (_event, ctx) => {
|
|
605
|
+
widget.setUICtx(ctx.ui as UICtx);
|
|
606
|
+
widget.onTurnStart();
|
|
607
|
+
});
|
|
608
|
+
|
|
609
|
+
// Apply persisted settings on startup and emit `subagents:settings_loaded`.
|
|
610
|
+
// Global + project merged; missing → defaults; corrupt file emits a warning
|
|
611
|
+
// to stderr and falls back to defaults.
|
|
612
|
+
applyAndEmitLoaded(
|
|
613
|
+
{
|
|
614
|
+
setMaxConcurrent: (n) => manager.setMaxConcurrent(n),
|
|
615
|
+
setDefaultMaxTurns,
|
|
616
|
+
setGraceTurns,
|
|
617
|
+
setDefaultJoinMode,
|
|
618
|
+
setSchedulingEnabled,
|
|
619
|
+
setScopeModels: setScopeModelsEnabled,
|
|
620
|
+
setDisableDefaultAgents: setDisableDefaultAgents,
|
|
621
|
+
setToolDescriptionMode: setToolDescriptionMode,
|
|
622
|
+
setWaitTimeoutSeconds,
|
|
623
|
+
setAbortResendKey: (key: string) => { abortResendKey = key; },
|
|
624
|
+
setWidgetDisplayMode,
|
|
625
|
+
},
|
|
626
|
+
(event, payload) => pi.events.emit(event, payload),
|
|
627
|
+
);
|
|
628
|
+
|
|
629
|
+
// Register the abort+resend shortcut AFTER settings load so the persisted
|
|
630
|
+
// key is honored (env > setting > "f9").
|
|
631
|
+
registerAbortResend(pi, abortResendKey);
|
|
632
|
+
|
|
633
|
+
// ---- Agent tool ----
|
|
634
|
+
|
|
635
|
+
// Schedule param + its guideline are gated on `schedulingEnabled` (read once
|
|
636
|
+
// at registration; flipping the setting later requires next pi session for
|
|
637
|
+
// the schema to update). Defining the shape once and spreading it via Partial
|
|
638
|
+
// preserves Type.Object's inference when present and produces a
|
|
639
|
+
// `schedule`-free schema when absent — zero LLM-context cost in disabled mode.
|
|
640
|
+
const scheduleParamShape = {
|
|
641
|
+
schedule: Type.Optional(
|
|
642
|
+
Type.String({
|
|
643
|
+
description:
|
|
644
|
+
'Opt-in only — fire later instead of now. Omit to run immediately (the default, almost always correct). ' +
|
|
645
|
+
'Formats: 6-field cron ("0 0 9 * * 1" = 9am Mon), interval ("5m"/"1h"), one-shot ("+10m" or ISO). ' +
|
|
646
|
+
'Forces background; incompatible with inherit_context and resume. Returns job ID.',
|
|
647
|
+
}),
|
|
648
|
+
),
|
|
649
|
+
};
|
|
650
|
+
const scheduleParam: Partial<typeof scheduleParamShape> =
|
|
651
|
+
isSchedulingEnabled() ? scheduleParamShape : {};
|
|
652
|
+
|
|
653
|
+
const agentToolDescription = buildAgentToolDescription({
|
|
654
|
+
mode: getToolDescriptionMode(),
|
|
655
|
+
extensionDepth,
|
|
656
|
+
schedulingEnabled: isSchedulingEnabled(),
|
|
657
|
+
});
|
|
658
|
+
|
|
659
|
+
pi.registerTool(defineTool({
|
|
660
|
+
name: SUBAGENT_TOOL_NAMES.AGENT,
|
|
661
|
+
label: "Agent",
|
|
662
|
+
description: agentToolDescription,
|
|
663
|
+
promptSnippet: "Launch autonomous sub-agents for complex multi-step tasks",
|
|
664
|
+
promptGuidelines: [
|
|
665
|
+
"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.",
|
|
666
|
+
"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.",
|
|
667
|
+
"Agents always run in the background. You will be notified on completion — do not poll or sleep waiting for it. Continue with other work instead.",
|
|
668
|
+
"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.",
|
|
669
|
+
],
|
|
670
|
+
parameters: Type.Object({
|
|
671
|
+
prompt: Type.Optional(
|
|
672
|
+
Type.String({
|
|
673
|
+
description: "The task for the agent to perform. OMIT this when retrying with a saved handle — it is preserved by the retry.",
|
|
674
|
+
}),
|
|
675
|
+
),
|
|
676
|
+
description: Type.String({
|
|
677
|
+
description: "A short (3-5 word) description of the task (shown in UI).",
|
|
678
|
+
}),
|
|
679
|
+
subagent_type: Type.Optional(
|
|
680
|
+
Type.String({
|
|
681
|
+
description: `The type of specialized agent to use. Available types: ${getAvailableTypes().join(", ")}. Custom agents from .pi/agents/*.md (project) or ${getAgentDir()}/agents/*.md (global) are also available. OMIT when retrying (preserved by the handle) unless you want to override it.`,
|
|
682
|
+
}),
|
|
683
|
+
),
|
|
684
|
+
model: Type.Optional(
|
|
685
|
+
Type.String({
|
|
686
|
+
description:
|
|
687
|
+
'Optional model override. Accepts "provider/modelId" or fuzzy name (e.g. "haiku", "sonnet"). Omit to use the agent type\'s default.',
|
|
688
|
+
}),
|
|
689
|
+
),
|
|
690
|
+
thinking: Type.Optional(
|
|
691
|
+
Type.String({
|
|
692
|
+
description: "Thinking level: off, minimal, low, medium, high, xhigh. Overrides agent default.",
|
|
693
|
+
}),
|
|
694
|
+
),
|
|
695
|
+
max_turns: Type.Optional(
|
|
696
|
+
Type.Number({
|
|
697
|
+
description: "Maximum number of agentic turns before stopping. Omit for unlimited (default).",
|
|
698
|
+
minimum: 1,
|
|
699
|
+
}),
|
|
700
|
+
),
|
|
701
|
+
retry: Type.Optional(
|
|
702
|
+
Type.String({
|
|
703
|
+
description: "Retry handle returned by a recoverable failure (model not found / out of scope / worktree validation). Reloads your original prompt and settings so you don't retype them; pass `model` (and optionally `subagent_type`) to override what failed. Other params you pass override the stashed values.",
|
|
704
|
+
}),
|
|
705
|
+
),
|
|
706
|
+
resume: Type.Optional(
|
|
707
|
+
Type.String({
|
|
708
|
+
description: "Optional agent ID to resume from. Continues from previous context.",
|
|
709
|
+
}),
|
|
710
|
+
),
|
|
711
|
+
isolated: Type.Optional(
|
|
712
|
+
Type.Boolean({
|
|
713
|
+
description: "If true, agent gets no extension/MCP tools — only built-in tools.",
|
|
714
|
+
}),
|
|
715
|
+
),
|
|
716
|
+
inherit_context: Type.Optional(
|
|
717
|
+
Type.Boolean({
|
|
718
|
+
description: "If true, fork the parent conversation into the agent so it sees the chat history. Recommended for questions or requests that require current context. Default: false (fresh context).",
|
|
719
|
+
}),
|
|
720
|
+
),
|
|
721
|
+
isolation: Type.Optional(
|
|
722
|
+
Type.Literal("worktree", {
|
|
723
|
+
description: 'Set to "worktree" to run the agent in a temporary git worktree that is automatically created from the current repo state at HEAD and removed on completion. Changes are saved to a branch. Requires the working directory to be a git repo with at least one commit.',
|
|
724
|
+
}),
|
|
725
|
+
),
|
|
726
|
+
...scheduleParam,
|
|
727
|
+
}),
|
|
728
|
+
|
|
729
|
+
// ---- Custom rendering: Claude Code style ----
|
|
730
|
+
|
|
731
|
+
renderCall(args, theme) {
|
|
732
|
+
return renderAgentCall(args, theme);
|
|
733
|
+
},
|
|
734
|
+
|
|
735
|
+
renderResult(result, { expanded, isPartial }, theme) {
|
|
736
|
+
return renderAgentResult(result, { expanded, isPartial }, theme);
|
|
737
|
+
},
|
|
738
|
+
|
|
739
|
+
// ---- Execute ----
|
|
740
|
+
|
|
741
|
+
execute: async (toolCallId, params, signal, onUpdate, ctx) => {
|
|
742
|
+
// Ensure we have UI context for widget rendering
|
|
743
|
+
widget.setUICtx(ctx.ui as UICtx);
|
|
744
|
+
|
|
745
|
+
// Reload custom agents so new .pi/agents/*.md files are picked up without restart
|
|
746
|
+
reloadCustomAgents();
|
|
747
|
+
|
|
748
|
+
const emitPromptPreview = (prompt: string | undefined, description: string | undefined, subagentType?: string) => {
|
|
749
|
+
if (!prompt?.trim()) return;
|
|
750
|
+
const displayName = subagentType && resolveType(subagentType as SubagentType)
|
|
751
|
+
? getDisplayName(resolveType(subagentType as SubagentType)!)
|
|
752
|
+
: "Agent";
|
|
753
|
+
onUpdate?.(textResult("", {
|
|
754
|
+
displayName,
|
|
755
|
+
description: description ?? "",
|
|
756
|
+
subagentType: subagentType ?? "Agent",
|
|
757
|
+
toolUses: 0,
|
|
758
|
+
tokens: "",
|
|
759
|
+
durationMs: 0,
|
|
760
|
+
status: "running",
|
|
761
|
+
activity: `Prompt: ${tailPreview(prompt)}`,
|
|
762
|
+
spinnerFrame: 0,
|
|
763
|
+
}));
|
|
764
|
+
};
|
|
765
|
+
|
|
766
|
+
// ---- Retry: reload a stashed invocation, overlaying any newly-passed params ----
|
|
767
|
+
// `P` is the effective params object used for all spawn-relevant reads below.
|
|
768
|
+
// `retryHandle` is preserved across repeated failures so one handle retries N times.
|
|
769
|
+
let retryHandle: string | undefined;
|
|
770
|
+
let P: typeof params = params;
|
|
771
|
+
if (params.retry) {
|
|
772
|
+
sweepRetryStash();
|
|
773
|
+
const stashed = retryStash.get(params.retry);
|
|
774
|
+
if (!stashed) {
|
|
775
|
+
return textResult(
|
|
776
|
+
`Retry handle "${params.retry}" was not found or has expired. ` +
|
|
777
|
+
`Re-invoke the Agent tool directly with your prompt and a valid model.`,
|
|
778
|
+
);
|
|
779
|
+
}
|
|
780
|
+
retryHandle = params.retry;
|
|
781
|
+
const { retry: _omit, ...overrides } = params;
|
|
782
|
+
P = { ...stashed.params, ...overrides } as typeof params;
|
|
783
|
+
}
|
|
784
|
+
emitPromptPreview(P.prompt, P.description, P.subagent_type);
|
|
785
|
+
|
|
786
|
+
// Retry supplied the prompt/type from the stash; otherwise both are required.
|
|
787
|
+
if (!retryHandle && (!P.prompt || !P.subagent_type)) {
|
|
788
|
+
return textResult(
|
|
789
|
+
`Missing required argument${!P.prompt && !P.subagent_type ? "s" : ""}: ` +
|
|
790
|
+
[!P.prompt && "prompt", !P.subagent_type && "subagent_type"].filter(Boolean).join(", ") +
|
|
791
|
+
".",
|
|
792
|
+
);
|
|
793
|
+
}
|
|
794
|
+
|
|
795
|
+
const rawType = P.subagent_type as SubagentType;
|
|
796
|
+
const resolved = resolveType(rawType);
|
|
797
|
+
if (!resolved) {
|
|
798
|
+
// Unknown agent type — recoverable. List valid types so the orchestrator
|
|
799
|
+
// can retry (overlaying subagent_type) without re-typing the prompt.
|
|
800
|
+
const valid = getAvailableTypes();
|
|
801
|
+
const list = valid.length > 0 ? valid.join(", ") : "(none — define one in .pi/agents/*.md)";
|
|
802
|
+
const h = stashInvocation(P, retryHandle);
|
|
803
|
+
return retryableResult(h, `Unknown agent type "${rawType}". Available types: ${list}.`, "subagent_type");
|
|
804
|
+
}
|
|
805
|
+
const subagentType = resolved;
|
|
806
|
+
|
|
807
|
+
const displayName = getDisplayName(subagentType);
|
|
808
|
+
|
|
809
|
+
// Get agent config (if any)
|
|
810
|
+
const customConfig = getAgentConfig(subagentType);
|
|
811
|
+
|
|
812
|
+
const resolvedConfig = resolveAgentInvocationConfig(customConfig, P);
|
|
813
|
+
|
|
814
|
+
// Resolve model from agent config first; tool-call params only fill gaps.
|
|
815
|
+
let model = ctx.model;
|
|
816
|
+
if (resolvedConfig.modelInput) {
|
|
817
|
+
const resolved = resolveModel(resolvedConfig.modelInput, ctx.modelRegistry);
|
|
818
|
+
if (typeof resolved === "string") {
|
|
819
|
+
if (resolvedConfig.modelFromParams) {
|
|
820
|
+
// Caller-supplied model not found — stash the invocation so the
|
|
821
|
+
// orchestrator can retry with a valid model without re-typing the prompt.
|
|
822
|
+
const h = stashInvocation(P, retryHandle);
|
|
823
|
+
return retryableResult(h, resolved, "model");
|
|
824
|
+
}
|
|
825
|
+
// config-specified: silent fallback to parent
|
|
826
|
+
} else {
|
|
827
|
+
model = resolved;
|
|
828
|
+
}
|
|
829
|
+
}
|
|
830
|
+
|
|
831
|
+
// Scope validation: the effective resolved model is checked against the
|
|
832
|
+
// user's enabledModels list (read in `enabled-models.ts`).
|
|
833
|
+
//
|
|
834
|
+
// Design: scopeModels guards against *runtime* LLM choices, not user-level config.
|
|
835
|
+
// - Caller-supplied out-of-scope → hard error (the orchestrator made an explicit
|
|
836
|
+
// out-of-scope choice; surface it so it picks differently).
|
|
837
|
+
// - Frontmatter-pinned or parent-inherited out-of-scope → warn but proceed (the
|
|
838
|
+
// user authored/installed this agent or chose the parent's model; trust it).
|
|
839
|
+
// See SubagentsSettings.scopeModels docstring for the full policy.
|
|
840
|
+
if (isScopeModelsEnabled() && model) {
|
|
841
|
+
const allowed = resolveEnabledModels(readEnabledModels(ctx.cwd), ctx.modelRegistry, ctx.cwd);
|
|
842
|
+
if (allowed && !isModelInScope(model, allowed)) {
|
|
843
|
+
if (resolvedConfig.modelFromParams) {
|
|
844
|
+
const list = [...allowed].sort().map(m => ` ${m}`).join("\n");
|
|
845
|
+
const h = stashInvocation(P, retryHandle);
|
|
846
|
+
return retryableResult(
|
|
847
|
+
h,
|
|
848
|
+
`Model not in scope: "${resolvedConfig.modelInput}".\n\nAllowed models (from enabledModels):\n${list}`,
|
|
849
|
+
"model",
|
|
850
|
+
);
|
|
851
|
+
}
|
|
852
|
+
// Frontmatter-pinned or parent-inherited: warn + proceed.
|
|
853
|
+
const agentLabel = customConfig?.displayName ?? subagentType;
|
|
854
|
+
const modelLabel = resolvedConfig.modelInput ?? `${model.provider}/${model.id}`;
|
|
855
|
+
ctx.ui.notify(
|
|
856
|
+
`Agent "${agentLabel}" using out-of-scope model "${modelLabel}"`,
|
|
857
|
+
"warning",
|
|
858
|
+
);
|
|
859
|
+
}
|
|
860
|
+
}
|
|
861
|
+
|
|
862
|
+
const thinking = resolvedConfig.thinking;
|
|
863
|
+
const inheritContext = resolvedConfig.inheritContext;
|
|
864
|
+
const isolated = resolvedConfig.isolated;
|
|
865
|
+
const isolation = resolvedConfig.isolation;
|
|
866
|
+
|
|
867
|
+
const parentModelId = ctx.model?.id;
|
|
868
|
+
const effectiveModelId = model?.id;
|
|
869
|
+
const modelName = effectiveModelId && effectiveModelId !== parentModelId
|
|
870
|
+
? (model?.name ?? effectiveModelId).replace(/^Claude\s+/i, "").toLowerCase()
|
|
871
|
+
: undefined;
|
|
872
|
+
const effectiveMaxTurns = normalizeMaxTurns(resolvedConfig.maxTurns ?? getDefaultMaxTurns());
|
|
873
|
+
const agentInvocation: AgentInvocation = {
|
|
874
|
+
modelName,
|
|
875
|
+
thinking,
|
|
876
|
+
// Explicit value only — the default fallback would just add noise.
|
|
877
|
+
// Normalize so `0` (unlimited) doesn't surface as a misleading "max turns: 0".
|
|
878
|
+
maxTurns: normalizeMaxTurns(resolvedConfig.maxTurns),
|
|
879
|
+
isolated,
|
|
880
|
+
inheritContext,
|
|
881
|
+
isolation,
|
|
882
|
+
depth: nextSubagentDepth,
|
|
883
|
+
parentAgentId: extensionAgentId,
|
|
884
|
+
maxDepth: MAX_RECURSIVE_DEPTH,
|
|
885
|
+
};
|
|
886
|
+
// Tool-result render shows the mode label too; viewer's header already does.
|
|
887
|
+
const modeLabel = getPromptModeLabel(subagentType);
|
|
888
|
+
const { tags: invocationTags } = buildInvocationTags(agentInvocation);
|
|
889
|
+
const agentTags = modeLabel ? [modeLabel, ...invocationTags] : invocationTags;
|
|
890
|
+
const detailBase = {
|
|
891
|
+
displayName,
|
|
892
|
+
description: P.description,
|
|
893
|
+
subagentType,
|
|
894
|
+
modelName,
|
|
895
|
+
tags: agentTags.length > 0 ? agentTags : undefined,
|
|
896
|
+
};
|
|
897
|
+
|
|
898
|
+
// ---- Schedule: register a job, don't spawn now ----
|
|
899
|
+
if (params.schedule) {
|
|
900
|
+
if (!isSchedulingEnabled()) {
|
|
901
|
+
return textResult("Scheduling is disabled in this project. Enable via /agents → Settings → Scheduling.");
|
|
902
|
+
}
|
|
903
|
+
if (params.resume) {
|
|
904
|
+
return textResult("Cannot combine `schedule` with `resume` — schedules create fresh agents.");
|
|
905
|
+
}
|
|
906
|
+
if (params.inherit_context) {
|
|
907
|
+
return textResult("Cannot combine `schedule` with `inherit_context` — there is no parent conversation at fire time.");
|
|
908
|
+
}
|
|
909
|
+
if (!scheduler.isActive()) {
|
|
910
|
+
return textResult("Scheduler is not active in this session yet. Try again after the session has fully started.");
|
|
911
|
+
}
|
|
912
|
+
try {
|
|
913
|
+
const job = scheduler.addJob({
|
|
914
|
+
name: params.description as string,
|
|
915
|
+
description: params.description as string,
|
|
916
|
+
schedule: params.schedule as string,
|
|
917
|
+
subagent_type: subagentType,
|
|
918
|
+
prompt: params.prompt as string,
|
|
919
|
+
model: params.model as string | undefined,
|
|
920
|
+
thinking: thinking,
|
|
921
|
+
max_turns: effectiveMaxTurns,
|
|
922
|
+
isolated: isolated,
|
|
923
|
+
isolation: isolation,
|
|
924
|
+
});
|
|
925
|
+
const next = scheduler.getNextRun(job.id);
|
|
926
|
+
return textResult(
|
|
927
|
+
`Scheduled "${job.name}" (id: ${job.id}, type: ${job.scheduleType}). ` +
|
|
928
|
+
`Next run: ${next ?? "(unknown)"}. ` +
|
|
929
|
+
`Manage via /agents → Scheduled jobs.`,
|
|
930
|
+
);
|
|
931
|
+
} catch (err) {
|
|
932
|
+
return textResult(err instanceof Error ? err.message : String(err));
|
|
933
|
+
}
|
|
934
|
+
}
|
|
935
|
+
|
|
936
|
+
// Resume existing agent
|
|
937
|
+
if (params.resume) {
|
|
938
|
+
const existing = manager.getRecord(params.resume);
|
|
939
|
+
if (!existing) {
|
|
940
|
+
return textResult(`Agent not found: "${params.resume}". It may have been cleaned up.`);
|
|
941
|
+
}
|
|
942
|
+
if (!existing.session) {
|
|
943
|
+
return textResult(`Agent "${params.resume}" has no active session to resume.`);
|
|
944
|
+
}
|
|
945
|
+
const { state, callbacks } = createActivityTracker(effectiveMaxTurns);
|
|
946
|
+
const record = manager.resume(params.resume, params.prompt!, { signal, ...callbacks });
|
|
947
|
+
if (!record) {
|
|
948
|
+
return textResult(`Failed to resume agent "${params.resume}".`);
|
|
949
|
+
}
|
|
950
|
+
agentActivity.set(record.id, state);
|
|
951
|
+
widget.ensureTimer();
|
|
952
|
+
widget.update();
|
|
953
|
+
const resumeDetails = { displayName: getDisplayName(record.type), description: record.description, subagentType: record.type, modelName: record.invocation?.modelName };
|
|
954
|
+
return textResult(
|
|
955
|
+
`Agent resumed in background.\nAgent ID: ${record.id}\nType: ${resumeDetails.displayName}\nDescription: ${record.description}\n\n` +
|
|
956
|
+
`You will be notified when this agent completes.\nUse get_subagent_result to retrieve full results, or steer_subagent to send it messages.\nDo not duplicate this agent's work.`,
|
|
957
|
+
buildDetails(resumeDetails, record, state, { status: "background" }),
|
|
958
|
+
);
|
|
959
|
+
}
|
|
960
|
+
|
|
961
|
+
// Background execution
|
|
962
|
+
const { state: bgState, callbacks: bgCallbacks } = createActivityTracker(effectiveMaxTurns);
|
|
963
|
+
|
|
964
|
+
// Wrap onSessionCreated to wire output file streaming.
|
|
965
|
+
// The callback lazily reads record.outputFile (set right after spawn)
|
|
966
|
+
// rather than closing over a value that doesn't exist yet.
|
|
967
|
+
let id: string;
|
|
968
|
+
const origBgOnSession = bgCallbacks.onSessionCreated;
|
|
969
|
+
bgCallbacks.onSessionCreated = (session: any) => {
|
|
970
|
+
origBgOnSession(session);
|
|
971
|
+
const rec = manager.getRecord(id);
|
|
972
|
+
if (rec?.outputFile) {
|
|
973
|
+
rec.outputCleanup = streamToOutputFile(session, rec.outputFile, id, ctx.cwd);
|
|
974
|
+
}
|
|
975
|
+
};
|
|
976
|
+
|
|
977
|
+
try {
|
|
978
|
+
id = manager.spawn(pi, ctx, subagentType, P.prompt!, {
|
|
979
|
+
description: P.description,
|
|
980
|
+
model,
|
|
981
|
+
maxTurns: effectiveMaxTurns,
|
|
982
|
+
isolated,
|
|
983
|
+
inheritContext,
|
|
984
|
+
thinkingLevel: thinking,
|
|
985
|
+
isBackground: true,
|
|
986
|
+
isolation,
|
|
987
|
+
invocation: agentInvocation,
|
|
988
|
+
depth: nextSubagentDepth,
|
|
989
|
+
parentAgentId: extensionAgentId,
|
|
990
|
+
...bgCallbacks,
|
|
991
|
+
});
|
|
992
|
+
} catch (err) {
|
|
993
|
+
// Pre-spawn failure (typically strict worktree-isolation). Stash WITHOUT
|
|
994
|
+
// isolation so a plain retry safely runs normally; the tailored hint tells
|
|
995
|
+
// the orchestrator how to re-add isolation once the repo is ready.
|
|
996
|
+
const h = stashInvocation(P, retryHandle, ["isolation"]);
|
|
997
|
+
return retryableResult(h, err instanceof Error ? err.message : String(err), "isolation");
|
|
998
|
+
}
|
|
999
|
+
|
|
1000
|
+
// Set output file + join mode synchronously after spawn, before the
|
|
1001
|
+
// event loop yields — onSessionCreated is async so this is safe.
|
|
1002
|
+
const joinMode = resolveJoinMode(defaultJoinMode);
|
|
1003
|
+
const record = manager.getRecord(id);
|
|
1004
|
+
if (record) {
|
|
1005
|
+
record.joinMode = joinMode;
|
|
1006
|
+
record.toolCallId = toolCallId;
|
|
1007
|
+
record.outputFile = createOutputFilePath(ctx.cwd, id, ctx.sessionManager.getSessionId());
|
|
1008
|
+
writeInitialEntry(record.outputFile, id, P.prompt!, ctx.cwd);
|
|
1009
|
+
}
|
|
1010
|
+
|
|
1011
|
+
if (joinMode === 'async') {
|
|
1012
|
+
// No join mode or explicit async — not part of any batch
|
|
1013
|
+
} else {
|
|
1014
|
+
// smart or group — add to current batch
|
|
1015
|
+
currentBatchAgents.push({ id, joinMode });
|
|
1016
|
+
// Debounce: reset timer on each new agent so parallel tool calls
|
|
1017
|
+
// dispatched across multiple event loop ticks are captured together
|
|
1018
|
+
if (batchFinalizeTimer) clearTimeout(batchFinalizeTimer);
|
|
1019
|
+
batchFinalizeTimer = setTimeout(finalizeBatch, 100);
|
|
1020
|
+
}
|
|
1021
|
+
|
|
1022
|
+
agentActivity.set(id, bgState);
|
|
1023
|
+
widget.ensureTimer();
|
|
1024
|
+
widget.update();
|
|
1025
|
+
|
|
1026
|
+
// Emit created event
|
|
1027
|
+
pi.events.emit("subagents:created", {
|
|
1028
|
+
id,
|
|
1029
|
+
type: subagentType,
|
|
1030
|
+
description: P.description,
|
|
1031
|
+
isBackground: true,
|
|
1032
|
+
depth: record?.depth ?? nextSubagentDepth,
|
|
1033
|
+
parentAgentId: extensionAgentId,
|
|
1034
|
+
});
|
|
1035
|
+
|
|
1036
|
+
const isQueued = record?.status === "queued";
|
|
1037
|
+
return textResult(
|
|
1038
|
+
`Agent ${isQueued ? "queued" : "started"} in background.\nAgent ID: ${id}\nType: ${displayName}\nDescription: ${P.description}\n` +
|
|
1039
|
+
(record?.outputFile ? `Output file: ${record.outputFile}\n` : "") +
|
|
1040
|
+
(isQueued ? `Position: queued (max ${manager.getMaxConcurrent()} concurrent)\n` : "") +
|
|
1041
|
+
`\nYou will be notified when this agent completes.\nUse get_subagent_result to retrieve full results, or steer_subagent to send it messages.\nDo not duplicate this agent's work.`,
|
|
1042
|
+
{ ...detailBase, toolUses: 0, tokens: "", durationMs: 0, status: "background" as const, agentId: id },
|
|
1043
|
+
);
|
|
1044
|
+
},
|
|
1045
|
+
}));
|
|
1046
|
+
|
|
1047
|
+
// ---- get_subagent_result tool ----
|
|
1048
|
+
|
|
1049
|
+
pi.registerTool(defineTool({
|
|
1050
|
+
name: SUBAGENT_TOOL_NAMES.GET_RESULT,
|
|
1051
|
+
label: "Get Agent Result",
|
|
1052
|
+
description:
|
|
1053
|
+
"Check status and retrieve results from a background agent. Use the agent ID returned by Agent.",
|
|
1054
|
+
promptSnippet: "Check status and retrieve results from a background agent",
|
|
1055
|
+
parameters: Type.Object({
|
|
1056
|
+
agent_id: Type.String({
|
|
1057
|
+
description: "The agent ID to check.",
|
|
1058
|
+
}),
|
|
1059
|
+
wait: Type.Optional(
|
|
1060
|
+
Type.Boolean({
|
|
1061
|
+
description: `If true, block until the agent completes before returning. Blocks up to the configured wait timeout (${formatWaitTimeout(getWaitTimeoutSeconds())} by default); if the agent is still running when the timeout is reached, returns its current status — call again with wait: true to keep waiting. Interruptible by the parent turn. Default: false.`,
|
|
1062
|
+
}),
|
|
1063
|
+
),
|
|
1064
|
+
verbose: Type.Optional(
|
|
1065
|
+
Type.Boolean({
|
|
1066
|
+
description: "If true, include the agent's full conversation (messages + tool calls). Default: false.",
|
|
1067
|
+
}),
|
|
1068
|
+
),
|
|
1069
|
+
peek: Type.Optional(
|
|
1070
|
+
Type.Object({
|
|
1071
|
+
lines: Type.Optional(Type.Number({ minimum: 1, description: "Number of trailing lines to return. Default: 20." })),
|
|
1072
|
+
regex: Type.Optional(Type.String({ description: "Optional regex filter applied to each source line (filter-then-tail). Only matching lines are returned." })),
|
|
1073
|
+
after: Type.Optional(Type.Number({ minimum: 0, description: "Return all source lines after this line number (1-based, matching the [N] prefixes). Use the last [N] you saw to fetch only new lines without missing anything. Overrides `lines`." })),
|
|
1074
|
+
}, {
|
|
1075
|
+
description: "Return a lightweight tail/filter view of the agent's result or live output file, with line numbers. Ignored when verbose is true.",
|
|
1076
|
+
}),
|
|
1077
|
+
),
|
|
1078
|
+
}),
|
|
1079
|
+
execute: async (_toolCallId, params, signal, _onUpdate, _ctx) => {
|
|
1080
|
+
const record = manager.getRecord(params.agent_id);
|
|
1081
|
+
if (!record) {
|
|
1082
|
+
return textResult(`Agent not found: "${params.agent_id}". It may have been cleaned up.`);
|
|
1083
|
+
}
|
|
1084
|
+
|
|
1085
|
+
// PEEK — lightweight view; ignored when verbose is true.
|
|
1086
|
+
if (params.peek && !params.verbose) {
|
|
1087
|
+
const peek = peekAgentOutput(record, params.peek as PeekOptions);
|
|
1088
|
+
return textResult(
|
|
1089
|
+
peek
|
|
1090
|
+
? `${peek.text}\n\n---\nUse verbose: true for the full conversation, or omit peek for the complete result.`
|
|
1091
|
+
: "No output yet for this agent.",
|
|
1092
|
+
);
|
|
1093
|
+
}
|
|
1094
|
+
|
|
1095
|
+
// WAIT — race the agent's completion against the configured timeout and
|
|
1096
|
+
// the parent abort signal. On timeout/abort we return current status
|
|
1097
|
+
// WITHOUT aborting the subagent (background agents are detached).
|
|
1098
|
+
let waitOutcome: WaitOutcome = "completed";
|
|
1099
|
+
if (params.wait && record.status === "running" && record.promise) {
|
|
1100
|
+
cancelNudge(params.agent_id);
|
|
1101
|
+
waitOutcome = await raceWait(record.promise, signal, getWaitTimeoutSeconds());
|
|
1102
|
+
if (waitOutcome === "completed") {
|
|
1103
|
+
record.resultConsumed = true;
|
|
1104
|
+
}
|
|
1105
|
+
}
|
|
1106
|
+
|
|
1107
|
+
const displayName = getDisplayName(record.type);
|
|
1108
|
+
const duration = formatDuration(record.startedAt, record.completedAt);
|
|
1109
|
+
const tokens = formatLifetimeTokens(record);
|
|
1110
|
+
const contextPercent = getSessionContextPercent(record.session);
|
|
1111
|
+
const statsParts = [`Tool uses: ${record.toolUses}`];
|
|
1112
|
+
if (tokens) statsParts.push(tokens);
|
|
1113
|
+
if (contextPercent !== null) statsParts.push(`Context: ${Math.round(contextPercent)}%`);
|
|
1114
|
+
if (record.compactionCount) statsParts.push(`Compactions: ${record.compactionCount}`);
|
|
1115
|
+
statsParts.push(`Duration: ${duration}`);
|
|
1116
|
+
|
|
1117
|
+
let output =
|
|
1118
|
+
`Agent: ${record.id}\n` +
|
|
1119
|
+
`Type: ${displayName} | Status: ${record.status}${getStatusNote(record.status)} | ${statsParts.join(" | ")}\n` +
|
|
1120
|
+
`Description: ${record.description}\n\n`;
|
|
1121
|
+
|
|
1122
|
+
if (record.status === "running") {
|
|
1123
|
+
// The wait returned while the agent was still running (timeout or abort).
|
|
1124
|
+
output += waitTimeoutMessage(waitOutcome, getWaitTimeoutSeconds());
|
|
1125
|
+
} else if (record.status === "error") {
|
|
1126
|
+
output += `Error: ${record.error}`;
|
|
1127
|
+
} else {
|
|
1128
|
+
output += record.result?.trim() || "No output.";
|
|
1129
|
+
}
|
|
1130
|
+
|
|
1131
|
+
// Mark result as consumed — suppresses the completion notification
|
|
1132
|
+
if (record.status !== "running" && record.status !== "queued") {
|
|
1133
|
+
record.resultConsumed = true;
|
|
1134
|
+
cancelNudge(params.agent_id);
|
|
1135
|
+
}
|
|
1136
|
+
|
|
1137
|
+
// Verbose: include full conversation
|
|
1138
|
+
if (params.verbose && record.session) {
|
|
1139
|
+
const conversation = getAgentConversation(record.session);
|
|
1140
|
+
if (conversation) {
|
|
1141
|
+
output += `\n\n--- Agent Conversation ---\n${conversation}`;
|
|
1142
|
+
}
|
|
1143
|
+
}
|
|
1144
|
+
|
|
1145
|
+
return textResult(output);
|
|
1146
|
+
},
|
|
1147
|
+
}));
|
|
1148
|
+
|
|
1149
|
+
// ---- steer_subagent tool ----
|
|
1150
|
+
|
|
1151
|
+
pi.registerTool(defineTool({
|
|
1152
|
+
name: SUBAGENT_TOOL_NAMES.STEER,
|
|
1153
|
+
label: "Steer Agent",
|
|
1154
|
+
description:
|
|
1155
|
+
"Send a steering message to a running agent. The message will interrupt the agent after its current tool execution " +
|
|
1156
|
+
"and be injected into its conversation, allowing you to redirect its work mid-run. Only works on running agents.",
|
|
1157
|
+
promptSnippet: "Send a steering message to redirect a running background agent",
|
|
1158
|
+
parameters: Type.Object({
|
|
1159
|
+
agent_id: Type.String({
|
|
1160
|
+
description: "The agent ID to steer (must be currently running).",
|
|
1161
|
+
}),
|
|
1162
|
+
message: Type.String({
|
|
1163
|
+
description: "The steering message to send. This will appear as a user message in the agent's conversation.",
|
|
1164
|
+
}),
|
|
1165
|
+
}),
|
|
1166
|
+
renderCall(args, theme) {
|
|
1167
|
+
return renderSteerCall(args, theme);
|
|
1168
|
+
},
|
|
1169
|
+
execute: async (_toolCallId, params, _signal, _onUpdate, _ctx) => {
|
|
1170
|
+
const record = manager.getRecord(params.agent_id);
|
|
1171
|
+
if (!record) {
|
|
1172
|
+
return textResult(`Agent not found: "${params.agent_id}". It may have been cleaned up.`);
|
|
1173
|
+
}
|
|
1174
|
+
if (record.status !== "running") {
|
|
1175
|
+
return textResult(`Agent "${params.agent_id}" is not running (status: ${record.status}). Cannot steer a non-running agent.`);
|
|
1176
|
+
}
|
|
1177
|
+
if (!record.session) {
|
|
1178
|
+
// Session not ready yet — queue the steer for delivery once initialized
|
|
1179
|
+
if (!record.pendingSteers) record.pendingSteers = [];
|
|
1180
|
+
record.pendingSteers.push(params.message);
|
|
1181
|
+
pi.events.emit("subagents:steered", { id: record.id, message: params.message });
|
|
1182
|
+
return textResult(`Steering message queued for agent ${record.id}. It will be delivered once the session initializes.`);
|
|
1183
|
+
}
|
|
1184
|
+
|
|
1185
|
+
try {
|
|
1186
|
+
await steerAgent(record.session, params.message);
|
|
1187
|
+
pi.events.emit("subagents:steered", { id: record.id, message: params.message });
|
|
1188
|
+
const tokens = formatLifetimeTokens(record);
|
|
1189
|
+
const contextPercent = getSessionContextPercent(record.session);
|
|
1190
|
+
const stateParts: string[] = [];
|
|
1191
|
+
if (tokens) stateParts.push(tokens);
|
|
1192
|
+
stateParts.push(`${record.toolUses} tool ${record.toolUses === 1 ? "use" : "uses"}`);
|
|
1193
|
+
if (contextPercent !== null) stateParts.push(`context ${Math.round(contextPercent)}% full`);
|
|
1194
|
+
if (record.compactionCount) stateParts.push(`${record.compactionCount} compaction${record.compactionCount === 1 ? "" : "s"}`);
|
|
1195
|
+
return textResult(
|
|
1196
|
+
`Steering message sent to agent ${record.id}. The agent will process it after its current tool execution.\n` +
|
|
1197
|
+
`Current state: ${stateParts.join(" · ")}`,
|
|
1198
|
+
);
|
|
1199
|
+
} catch (err) {
|
|
1200
|
+
return textResult(`Failed to steer agent: ${err instanceof Error ? err.message : String(err)}`);
|
|
1201
|
+
}
|
|
1202
|
+
},
|
|
1203
|
+
}));
|
|
1204
|
+
|
|
1205
|
+
// ---- list_models tool ----
|
|
1206
|
+
|
|
1207
|
+
pi.registerTool(defineTool({
|
|
1208
|
+
name: SUBAGENT_TOOL_NAMES.LIST_MODELS,
|
|
1209
|
+
label: "List Models",
|
|
1210
|
+
description:
|
|
1211
|
+
"List every model available in the current session's model registry (the same set the `model:` param on Agent accepts). " +
|
|
1212
|
+
"Returns one model per line as `provider/id (name)` with the active model marked. " +
|
|
1213
|
+
"Useful when dispatching work and you want to pick a model explicitly, or to confirm a model name before passing it to `model:`.",
|
|
1214
|
+
promptSnippet: "Enumerate available models in the current registry",
|
|
1215
|
+
parameters: Type.Object({
|
|
1216
|
+
provider: Type.Optional(
|
|
1217
|
+
Type.String({
|
|
1218
|
+
description: "Optional provider name filter (case-insensitive). When set, only models from that provider are returned.",
|
|
1219
|
+
}),
|
|
1220
|
+
),
|
|
1221
|
+
}),
|
|
1222
|
+
execute: async (_toolCallId, params, _signal, _onUpdate, ctx) => {
|
|
1223
|
+
const registry = ctx.modelRegistry as ModelRegistry | undefined;
|
|
1224
|
+
if (!registry) {
|
|
1225
|
+
return textResult("No model registry is available in the current session.");
|
|
1226
|
+
}
|
|
1227
|
+
const all = ((registry.getAvailable?.() ?? registry.getAll()) ?? []) as Array<{
|
|
1228
|
+
id: string;
|
|
1229
|
+
name: string;
|
|
1230
|
+
provider: string;
|
|
1231
|
+
contextWindow?: number;
|
|
1232
|
+
reasoning?: boolean;
|
|
1233
|
+
}>;
|
|
1234
|
+
const providerFilter = params.provider?.trim().toLowerCase();
|
|
1235
|
+
const filtered = providerFilter
|
|
1236
|
+
? all.filter((m) => m.provider.toLowerCase() === providerFilter)
|
|
1237
|
+
: all;
|
|
1238
|
+
if (filtered.length === 0) {
|
|
1239
|
+
return textResult(
|
|
1240
|
+
providerFilter
|
|
1241
|
+
? `No models available for provider "${params.provider}". Available providers: ${[...new Set(all.map((m) => m.provider))].sort().join(", ") || "(none)"}.`
|
|
1242
|
+
: "No models are available in the current registry.",
|
|
1243
|
+
);
|
|
1244
|
+
}
|
|
1245
|
+
const currentId = (ctx.model as { id?: string } | undefined)?.id;
|
|
1246
|
+
const currentProvider = (ctx.model as { provider?: string } | undefined)?.provider;
|
|
1247
|
+
const sorted = [...filtered].sort((a, b) =>
|
|
1248
|
+
a.provider === b.provider ? a.id.localeCompare(b.id) : a.provider.localeCompare(b.provider),
|
|
1249
|
+
);
|
|
1250
|
+
const lines = sorted.map((m) => {
|
|
1251
|
+
const isCurrent = currentId === m.id && (!currentProvider || currentProvider === m.provider);
|
|
1252
|
+
const ctxInfo = m.contextWindow ? ` · ctx ${formatContextWindow(m.contextWindow)}` : "";
|
|
1253
|
+
const reasoning = m.reasoning ? " · reasoning" : "";
|
|
1254
|
+
const marker = isCurrent ? "* " : " ";
|
|
1255
|
+
return `${marker}${m.provider}/${m.id} (${m.name})${ctxInfo}${reasoning}`;
|
|
1256
|
+
});
|
|
1257
|
+
const header = `${sorted.length} model${sorted.length === 1 ? "" : "s"} available${providerFilter ? ` for provider "${params.provider}"` : ""}${currentId ? " — * = active" : ""}:`;
|
|
1258
|
+
return textResult(`${header}\n${lines.join("\n")}`);
|
|
1259
|
+
},
|
|
1260
|
+
}));
|
|
1261
|
+
|
|
1262
|
+
// ---- /agents interactive menu ----
|
|
1263
|
+
|
|
1264
|
+
const projectAgentsDir = () => join(process.cwd(), ".pi", "agents");
|
|
1265
|
+
const personalAgentsDir = () => join(getAgentDir(), "agents");
|
|
1266
|
+
|
|
1267
|
+
/** Find the file path of a custom agent by name (project first, then global). */
|
|
1268
|
+
function findAgentFile(name: string): { path: string; location: "project" | "personal" } | undefined {
|
|
1269
|
+
const projectPath = join(projectAgentsDir(), `${name}.md`);
|
|
1270
|
+
if (existsSync(projectPath)) return { path: projectPath, location: "project" };
|
|
1271
|
+
const personalPath = join(personalAgentsDir(), `${name}.md`);
|
|
1272
|
+
if (existsSync(personalPath)) return { path: personalPath, location: "personal" };
|
|
1273
|
+
return undefined;
|
|
1274
|
+
}
|
|
1275
|
+
|
|
1276
|
+
function getModelLabel(type: string, registry?: ModelRegistry): string {
|
|
1277
|
+
const cfg = getAgentConfig(type);
|
|
1278
|
+
if (!cfg?.model) return "inherit";
|
|
1279
|
+
// If registry provided, check if the model actually resolves
|
|
1280
|
+
if (registry) {
|
|
1281
|
+
const resolved = resolveModel(cfg.model, registry);
|
|
1282
|
+
if (typeof resolved === "string") return "inherit"; // model not available
|
|
1283
|
+
}
|
|
1284
|
+
return getModelLabelFromConfig(cfg.model);
|
|
1285
|
+
}
|
|
1286
|
+
|
|
1287
|
+
async function showAgentsMenu(ctx: ExtensionCommandContext) {
|
|
1288
|
+
reloadCustomAgents();
|
|
1289
|
+
const allNames = getAllTypes();
|
|
1290
|
+
|
|
1291
|
+
// Build select options
|
|
1292
|
+
const options: string[] = [];
|
|
1293
|
+
|
|
1294
|
+
// Running agents entry (only if there are active agents)
|
|
1295
|
+
const agents = manager.listAgents();
|
|
1296
|
+
if (agents.length > 0) {
|
|
1297
|
+
const running = agents.filter(a => a.status === "running" || a.status === "queued").length;
|
|
1298
|
+
const done = agents.filter(a => a.status === "completed" || a.status === "steered").length;
|
|
1299
|
+
options.push(`Running agents (${agents.length}) — ${running} running, ${done} done`);
|
|
1300
|
+
}
|
|
1301
|
+
|
|
1302
|
+
// Agent types list
|
|
1303
|
+
if (allNames.length > 0) {
|
|
1304
|
+
options.push(`Agent types (${allNames.length})`);
|
|
1305
|
+
}
|
|
1306
|
+
|
|
1307
|
+
// Scheduled jobs entry (always present when scheduler is active)
|
|
1308
|
+
if (scheduler.isActive()) {
|
|
1309
|
+
const jobCount = scheduler.list().length;
|
|
1310
|
+
options.push(`Scheduled jobs (${jobCount})`);
|
|
1311
|
+
}
|
|
1312
|
+
|
|
1313
|
+
// Actions
|
|
1314
|
+
options.push("Create new agent");
|
|
1315
|
+
options.push("Settings");
|
|
1316
|
+
|
|
1317
|
+
const noAgentsMsg = allNames.length === 0 && agents.length === 0
|
|
1318
|
+
? "No agents found. Create specialized subagents that can be delegated to.\n\n" +
|
|
1319
|
+
"Each subagent has its own context window, custom system prompt, and specific tools.\n\n" +
|
|
1320
|
+
"Try creating: Code Reviewer, Security Auditor, Test Writer, or Documentation Writer.\n\n"
|
|
1321
|
+
: "";
|
|
1322
|
+
|
|
1323
|
+
if (noAgentsMsg) {
|
|
1324
|
+
ctx.ui.notify(noAgentsMsg, "info");
|
|
1325
|
+
}
|
|
1326
|
+
|
|
1327
|
+
const choice = await menuSelect(ctx, { title: "Agents", options });
|
|
1328
|
+
if (!choice) return;
|
|
1329
|
+
|
|
1330
|
+
if (choice.startsWith("Running agents (")) {
|
|
1331
|
+
await showRunningAgents(ctx);
|
|
1332
|
+
await showAgentsMenu(ctx);
|
|
1333
|
+
} else if (choice.startsWith("Agent types (")) {
|
|
1334
|
+
await showAllAgentsList(ctx);
|
|
1335
|
+
await showAgentsMenu(ctx);
|
|
1336
|
+
} else if (choice.startsWith("Scheduled jobs (")) {
|
|
1337
|
+
await showSchedulesMenu(ctx, scheduler);
|
|
1338
|
+
await showAgentsMenu(ctx);
|
|
1339
|
+
} else if (choice === "Create new agent") {
|
|
1340
|
+
await showCreateWizard(ctx);
|
|
1341
|
+
} else if (choice === "Settings") {
|
|
1342
|
+
await showSettings(ctx);
|
|
1343
|
+
await showAgentsMenu(ctx);
|
|
1344
|
+
}
|
|
1345
|
+
}
|
|
1346
|
+
|
|
1347
|
+
async function showAllAgentsList(ctx: ExtensionCommandContext) {
|
|
1348
|
+
const allNames = getAllTypes();
|
|
1349
|
+
if (allNames.length === 0) {
|
|
1350
|
+
ctx.ui.notify("No agents.", "info");
|
|
1351
|
+
return;
|
|
1352
|
+
}
|
|
1353
|
+
|
|
1354
|
+
// Source indicators: defaults unmarked, custom agents get • (project) or ◦ (global)
|
|
1355
|
+
// Disabled agents get ✕ prefix
|
|
1356
|
+
const sourceIndicator = (cfg: AgentConfig | undefined) => {
|
|
1357
|
+
const disabled = cfg?.enabled === false;
|
|
1358
|
+
if (cfg?.source === "project") return disabled ? "✕• " : "• ";
|
|
1359
|
+
if (cfg?.source === "global") return disabled ? "✕◦ " : "◦ ";
|
|
1360
|
+
if (disabled) return "✕ ";
|
|
1361
|
+
return " ";
|
|
1362
|
+
};
|
|
1363
|
+
|
|
1364
|
+
const entries = allNames.map(name => {
|
|
1365
|
+
const cfg = getAgentConfig(name);
|
|
1366
|
+
const disabled = cfg?.enabled === false;
|
|
1367
|
+
const model = getModelLabel(name, ctx.modelRegistry);
|
|
1368
|
+
const indicator = sourceIndicator(cfg);
|
|
1369
|
+
const prefix = `${indicator}${name} · ${model}`;
|
|
1370
|
+
const desc = disabled ? "(disabled)" : (cfg?.description ?? name);
|
|
1371
|
+
return { name, prefix, desc };
|
|
1372
|
+
});
|
|
1373
|
+
const maxPrefix = Math.max(...entries.map(e => e.prefix.length));
|
|
1374
|
+
|
|
1375
|
+
const hasCustom = allNames.some(n => { const c = getAgentConfig(n); return c && !c.isDefault && c.enabled !== false; });
|
|
1376
|
+
const hasDisabled = allNames.some(n => getAgentConfig(n)?.enabled === false);
|
|
1377
|
+
const legendParts: string[] = [];
|
|
1378
|
+
if (hasCustom) legendParts.push("• = project ◦ = global");
|
|
1379
|
+
if (hasDisabled) legendParts.push("✕ = disabled");
|
|
1380
|
+
const legend = legendParts.length ? "\n" + legendParts.join(" ") : "";
|
|
1381
|
+
|
|
1382
|
+
const options = entries.map(({ prefix, desc }) =>
|
|
1383
|
+
`${prefix.padEnd(maxPrefix)} — ${desc}`,
|
|
1384
|
+
);
|
|
1385
|
+
if (legend) options.push(legend);
|
|
1386
|
+
|
|
1387
|
+
const choice = await menuSelect(ctx, { title: "Agent types", options });
|
|
1388
|
+
if (!choice) return;
|
|
1389
|
+
|
|
1390
|
+
const agentName = choice.split(" · ")[0].replace(/^[•◦✕\s]+/, "").trim();
|
|
1391
|
+
if (getAgentConfig(agentName)) {
|
|
1392
|
+
await showAgentDetail(ctx, agentName);
|
|
1393
|
+
await showAllAgentsList(ctx);
|
|
1394
|
+
}
|
|
1395
|
+
}
|
|
1396
|
+
|
|
1397
|
+
async function showRunningAgents(ctx: ExtensionCommandContext) {
|
|
1398
|
+
const agents = manager.listAgents();
|
|
1399
|
+
if (agents.length === 0) {
|
|
1400
|
+
ctx.ui.notify("No agents.", "info");
|
|
1401
|
+
return;
|
|
1402
|
+
}
|
|
1403
|
+
|
|
1404
|
+
const options = agents.map(a => {
|
|
1405
|
+
const dn = getDisplayName(a.type);
|
|
1406
|
+
const dur = formatDuration(a.startedAt, a.completedAt);
|
|
1407
|
+
return `${dn} (${a.description}) · ${a.toolUses} tools · ${a.status} · ${dur}`;
|
|
1408
|
+
});
|
|
1409
|
+
|
|
1410
|
+
const choice = await menuSelect(ctx, { title: "Running agents", options });
|
|
1411
|
+
if (!choice) return;
|
|
1412
|
+
|
|
1413
|
+
// Find the selected agent by matching the option index
|
|
1414
|
+
const idx = options.indexOf(choice);
|
|
1415
|
+
if (idx < 0) return;
|
|
1416
|
+
const record = agents[idx];
|
|
1417
|
+
|
|
1418
|
+
await viewAgentConversation(ctx, record);
|
|
1419
|
+
// Back-navigation: re-show the list
|
|
1420
|
+
await showRunningAgents(ctx);
|
|
1421
|
+
}
|
|
1422
|
+
|
|
1423
|
+
async function viewAgentConversation(ctx: ExtensionCommandContext, record: AgentRecord) {
|
|
1424
|
+
if (!record.session) {
|
|
1425
|
+
ctx.ui.notify(`Agent is ${record.status === "queued" ? "queued" : "expired"} — no session available.`, "info");
|
|
1426
|
+
return;
|
|
1427
|
+
}
|
|
1428
|
+
|
|
1429
|
+
const { ConversationViewer, VIEWPORT_HEIGHT_PCT } = await import("./ui/conversation-viewer.js");
|
|
1430
|
+
const session = record.session;
|
|
1431
|
+
const activity = agentActivity.get(record.id);
|
|
1432
|
+
|
|
1433
|
+
await ctx.ui.custom<undefined>(
|
|
1434
|
+
(tui, theme, keybindings, done) => {
|
|
1435
|
+
return new ConversationViewer(tui, session, record, activity, theme, done, () => {
|
|
1436
|
+
if (manager.abort(record.id)) {
|
|
1437
|
+
ctx.ui.notify(`Stopped "${record.description}".`, "info");
|
|
1438
|
+
}
|
|
1439
|
+
}, keybindings);
|
|
1440
|
+
},
|
|
1441
|
+
{
|
|
1442
|
+
overlay: true,
|
|
1443
|
+
overlayOptions: { anchor: "center", width: "90%", maxHeight: `${VIEWPORT_HEIGHT_PCT}%` },
|
|
1444
|
+
},
|
|
1445
|
+
);
|
|
1446
|
+
}
|
|
1447
|
+
|
|
1448
|
+
async function showAgentDetail(ctx: ExtensionCommandContext, name: string) {
|
|
1449
|
+
const cfg = getAgentConfig(name);
|
|
1450
|
+
if (!cfg) {
|
|
1451
|
+
ctx.ui.notify(`Agent config not found for "${name}".`, "warning");
|
|
1452
|
+
return;
|
|
1453
|
+
}
|
|
1454
|
+
|
|
1455
|
+
const file = findAgentFile(name);
|
|
1456
|
+
const isDefault = cfg.isDefault === true;
|
|
1457
|
+
const disabled = cfg.enabled === false;
|
|
1458
|
+
|
|
1459
|
+
let menuOptions: string[];
|
|
1460
|
+
if (disabled && file) {
|
|
1461
|
+
// Disabled agent with a file — offer Enable
|
|
1462
|
+
menuOptions = isDefault
|
|
1463
|
+
? ["Enable", "Edit", "Reset to default", "Delete", "Back"]
|
|
1464
|
+
: ["Enable", "Edit", "Delete", "Back"];
|
|
1465
|
+
} else if (isDefault && !file) {
|
|
1466
|
+
// Default agent with no .md override
|
|
1467
|
+
menuOptions = ["Eject (export as .md)", "Disable", "Back"];
|
|
1468
|
+
} else if (isDefault && file) {
|
|
1469
|
+
// Default agent with .md override (ejected)
|
|
1470
|
+
menuOptions = ["Edit", "Disable", "Reset to default", "Delete", "Back"];
|
|
1471
|
+
} else {
|
|
1472
|
+
// User-defined agent
|
|
1473
|
+
menuOptions = ["Edit", "Disable", "Delete", "Back"];
|
|
1474
|
+
}
|
|
1475
|
+
|
|
1476
|
+
const choice = await menuSelect(ctx, { title: name, options: menuOptions });
|
|
1477
|
+
if (!choice || choice === "Back") return;
|
|
1478
|
+
|
|
1479
|
+
if (choice === "Edit" && file) {
|
|
1480
|
+
const content = readFileSync(file.path, "utf-8");
|
|
1481
|
+
const edited = await ctx.ui.editor(`Edit ${name}`, content);
|
|
1482
|
+
if (edited !== undefined && edited !== content) {
|
|
1483
|
+
const { writeFileSync } = await import("node:fs");
|
|
1484
|
+
writeFileSync(file.path, edited, "utf-8");
|
|
1485
|
+
reloadCustomAgents();
|
|
1486
|
+
ctx.ui.notify(`Updated ${file.path}`, "info");
|
|
1487
|
+
}
|
|
1488
|
+
} else if (choice === "Delete") {
|
|
1489
|
+
if (file) {
|
|
1490
|
+
const confirmed = await ctx.ui.confirm("Delete agent", `Delete ${name} from ${file.location} (${file.path})?`);
|
|
1491
|
+
if (confirmed) {
|
|
1492
|
+
unlinkSync(file.path);
|
|
1493
|
+
reloadCustomAgents();
|
|
1494
|
+
ctx.ui.notify(`Deleted ${file.path}`, "info");
|
|
1495
|
+
}
|
|
1496
|
+
}
|
|
1497
|
+
} else if (choice === "Reset to default" && file) {
|
|
1498
|
+
const confirmed = await ctx.ui.confirm("Reset to default", `Delete override ${file.path} and restore embedded default?`);
|
|
1499
|
+
if (confirmed) {
|
|
1500
|
+
unlinkSync(file.path);
|
|
1501
|
+
reloadCustomAgents();
|
|
1502
|
+
ctx.ui.notify(`Restored default ${name}`, "info");
|
|
1503
|
+
}
|
|
1504
|
+
} else if (choice.startsWith("Eject")) {
|
|
1505
|
+
await ejectAgent(ctx, name, cfg);
|
|
1506
|
+
} else if (choice === "Disable") {
|
|
1507
|
+
await disableAgent(ctx, name);
|
|
1508
|
+
} else if (choice === "Enable") {
|
|
1509
|
+
await enableAgent(ctx, name);
|
|
1510
|
+
}
|
|
1511
|
+
}
|
|
1512
|
+
|
|
1513
|
+
/** Eject a default agent: write its embedded config as a .md file. */
|
|
1514
|
+
async function ejectAgent(ctx: ExtensionCommandContext, name: string, cfg: AgentConfig) {
|
|
1515
|
+
const location = await menuSelect(ctx, {
|
|
1516
|
+
title: "Choose location",
|
|
1517
|
+
options: [
|
|
1518
|
+
"Project (.pi/agents/)",
|
|
1519
|
+
`Personal (${personalAgentsDir()})`,
|
|
1520
|
+
],
|
|
1521
|
+
});
|
|
1522
|
+
if (!location) return;
|
|
1523
|
+
|
|
1524
|
+
const targetDir = location.startsWith("Project") ? projectAgentsDir() : personalAgentsDir();
|
|
1525
|
+
mkdirSync(targetDir, { recursive: true });
|
|
1526
|
+
|
|
1527
|
+
const targetPath = join(targetDir, `${name}.md`);
|
|
1528
|
+
if (existsSync(targetPath)) {
|
|
1529
|
+
const overwrite = await ctx.ui.confirm("Overwrite", `${targetPath} already exists. Overwrite?`);
|
|
1530
|
+
if (!overwrite) return;
|
|
1531
|
+
}
|
|
1532
|
+
|
|
1533
|
+
// Build the .md file content
|
|
1534
|
+
const fmFields: string[] = [];
|
|
1535
|
+
fmFields.push(`description: ${JSON.stringify(cfg.description)}`);
|
|
1536
|
+
if (cfg.displayName) fmFields.push(`display_name: ${cfg.displayName}`);
|
|
1537
|
+
fmFields.push(`tools: ${cfg.builtinToolNames?.join(", ") || "all"}`);
|
|
1538
|
+
if (cfg.model) fmFields.push(`model: ${cfg.model}`);
|
|
1539
|
+
if (cfg.thinking) fmFields.push(`thinking: ${cfg.thinking}`);
|
|
1540
|
+
if (cfg.maxTurns) fmFields.push(`max_turns: ${cfg.maxTurns}`);
|
|
1541
|
+
fmFields.push(`prompt_mode: ${cfg.promptMode}`);
|
|
1542
|
+
if (cfg.extensions === false) fmFields.push("extensions: false");
|
|
1543
|
+
else if (Array.isArray(cfg.extensions)) fmFields.push(`extensions: ${cfg.extensions.join(", ")}`);
|
|
1544
|
+
if (cfg.excludeExtensions?.length) fmFields.push(`exclude_extensions: ${cfg.excludeExtensions.join(", ")}`);
|
|
1545
|
+
if (cfg.skills === false) fmFields.push("skills: false");
|
|
1546
|
+
else if (Array.isArray(cfg.skills)) fmFields.push(`skills: ${cfg.skills.join(", ")}`);
|
|
1547
|
+
if (cfg.disallowedTools?.length) fmFields.push(`disallowed_tools: ${cfg.disallowedTools.join(", ")}`);
|
|
1548
|
+
if (cfg.inheritContext) fmFields.push("inherit_context: true");
|
|
1549
|
+
if (cfg.isolated) fmFields.push("isolated: true");
|
|
1550
|
+
if (cfg.memory) fmFields.push(`memory: ${cfg.memory}`);
|
|
1551
|
+
if (cfg.isolation) fmFields.push(`isolation: ${cfg.isolation}`);
|
|
1552
|
+
|
|
1553
|
+
const content = `---\n${fmFields.join("\n")}\n---\n\n${cfg.systemPrompt}\n`;
|
|
1554
|
+
|
|
1555
|
+
const { writeFileSync } = await import("node:fs");
|
|
1556
|
+
writeFileSync(targetPath, content, "utf-8");
|
|
1557
|
+
reloadCustomAgents();
|
|
1558
|
+
ctx.ui.notify(`Ejected ${name} to ${targetPath}`, "info");
|
|
1559
|
+
}
|
|
1560
|
+
|
|
1561
|
+
/** Disable an agent: set enabled: false in its .md file, or create a stub for built-in defaults. */
|
|
1562
|
+
async function disableAgent(ctx: ExtensionCommandContext, name: string) {
|
|
1563
|
+
const file = findAgentFile(name);
|
|
1564
|
+
if (file) {
|
|
1565
|
+
// Existing file — set enabled: false in frontmatter (idempotent)
|
|
1566
|
+
const content = readFileSync(file.path, "utf-8");
|
|
1567
|
+
if (content.includes("\nenabled: false\n")) {
|
|
1568
|
+
ctx.ui.notify(`${name} is already disabled.`, "info");
|
|
1569
|
+
return;
|
|
1570
|
+
}
|
|
1571
|
+
const updated = content.replace(/^---\n/, "---\nenabled: false\n");
|
|
1572
|
+
const { writeFileSync } = await import("node:fs");
|
|
1573
|
+
writeFileSync(file.path, updated, "utf-8");
|
|
1574
|
+
reloadCustomAgents();
|
|
1575
|
+
ctx.ui.notify(`Disabled ${name} (${file.path})`, "info");
|
|
1576
|
+
return;
|
|
1577
|
+
}
|
|
1578
|
+
|
|
1579
|
+
// No file (built-in default) — create a stub
|
|
1580
|
+
const location = await menuSelect(ctx, {
|
|
1581
|
+
title: "Choose location",
|
|
1582
|
+
options: [
|
|
1583
|
+
"Project (.pi/agents/)",
|
|
1584
|
+
`Personal (${personalAgentsDir()})`,
|
|
1585
|
+
],
|
|
1586
|
+
});
|
|
1587
|
+
if (!location) return;
|
|
1588
|
+
|
|
1589
|
+
const targetDir = location.startsWith("Project") ? projectAgentsDir() : personalAgentsDir();
|
|
1590
|
+
mkdirSync(targetDir, { recursive: true });
|
|
1591
|
+
|
|
1592
|
+
const targetPath = join(targetDir, `${name}.md`);
|
|
1593
|
+
const { writeFileSync } = await import("node:fs");
|
|
1594
|
+
writeFileSync(targetPath, "---\nenabled: false\n---\n", "utf-8");
|
|
1595
|
+
reloadCustomAgents();
|
|
1596
|
+
ctx.ui.notify(`Disabled ${name} (${targetPath})`, "info");
|
|
1597
|
+
}
|
|
1598
|
+
|
|
1599
|
+
/** Enable a disabled agent by removing enabled: false from its frontmatter. */
|
|
1600
|
+
async function enableAgent(ctx: ExtensionCommandContext, name: string) {
|
|
1601
|
+
const file = findAgentFile(name);
|
|
1602
|
+
if (!file) return;
|
|
1603
|
+
|
|
1604
|
+
const content = readFileSync(file.path, "utf-8");
|
|
1605
|
+
const updated = content.replace(/^(---\n)enabled: false\n/, "$1");
|
|
1606
|
+
const { writeFileSync } = await import("node:fs");
|
|
1607
|
+
|
|
1608
|
+
// If the file was just a stub ("---\n---\n"), delete it to restore the built-in default
|
|
1609
|
+
if (updated.trim() === "---\n---" || updated.trim() === "---\n---\n") {
|
|
1610
|
+
unlinkSync(file.path);
|
|
1611
|
+
reloadCustomAgents();
|
|
1612
|
+
ctx.ui.notify(`Enabled ${name} (removed ${file.path})`, "info");
|
|
1613
|
+
} else {
|
|
1614
|
+
writeFileSync(file.path, updated, "utf-8");
|
|
1615
|
+
reloadCustomAgents();
|
|
1616
|
+
ctx.ui.notify(`Enabled ${name} (${file.path})`, "info");
|
|
1617
|
+
}
|
|
1618
|
+
}
|
|
1619
|
+
|
|
1620
|
+
async function showCreateWizard(ctx: ExtensionCommandContext) {
|
|
1621
|
+
const location = await menuSelect(ctx, {
|
|
1622
|
+
title: "Choose location",
|
|
1623
|
+
options: [
|
|
1624
|
+
"Project (.pi/agents/)",
|
|
1625
|
+
`Personal (${personalAgentsDir()})`,
|
|
1626
|
+
],
|
|
1627
|
+
});
|
|
1628
|
+
if (!location) return;
|
|
1629
|
+
|
|
1630
|
+
const targetDir = location.startsWith("Project") ? projectAgentsDir() : personalAgentsDir();
|
|
1631
|
+
|
|
1632
|
+
const method = await menuSelect(ctx, {
|
|
1633
|
+
title: "Creation method",
|
|
1634
|
+
options: [
|
|
1635
|
+
"Generate with Claude (recommended)",
|
|
1636
|
+
"Manual configuration",
|
|
1637
|
+
],
|
|
1638
|
+
});
|
|
1639
|
+
if (!method) return;
|
|
1640
|
+
|
|
1641
|
+
if (method.startsWith("Generate")) {
|
|
1642
|
+
await showGenerateWizard(ctx, targetDir);
|
|
1643
|
+
} else {
|
|
1644
|
+
await showManualWizard(ctx, targetDir);
|
|
1645
|
+
}
|
|
1646
|
+
}
|
|
1647
|
+
|
|
1648
|
+
async function showGenerateWizard(ctx: ExtensionCommandContext, targetDir: string) {
|
|
1649
|
+
const description = await ctx.ui.input("Describe what this agent should do");
|
|
1650
|
+
if (!description) return;
|
|
1651
|
+
|
|
1652
|
+
const name = await ctx.ui.input("Agent name (filename, no spaces)");
|
|
1653
|
+
if (!name) return;
|
|
1654
|
+
|
|
1655
|
+
mkdirSync(targetDir, { recursive: true });
|
|
1656
|
+
|
|
1657
|
+
const targetPath = join(targetDir, `${name}.md`);
|
|
1658
|
+
if (existsSync(targetPath)) {
|
|
1659
|
+
const overwrite = await ctx.ui.confirm("Overwrite", `${targetPath} already exists. Overwrite?`);
|
|
1660
|
+
if (!overwrite) return;
|
|
1661
|
+
}
|
|
1662
|
+
|
|
1663
|
+
ctx.ui.notify("Generating agent definition...", "info");
|
|
1664
|
+
|
|
1665
|
+
const generatePrompt = `Create a custom pi sub-agent definition file based on this description: "${description}"
|
|
1666
|
+
|
|
1667
|
+
Write a markdown file to: ${targetPath}
|
|
1668
|
+
|
|
1669
|
+
The file format is a markdown file with YAML frontmatter and a system prompt body:
|
|
1670
|
+
|
|
1671
|
+
\`\`\`markdown
|
|
1672
|
+
---
|
|
1673
|
+
description: <one-line description shown in UI>
|
|
1674
|
+
tools: <comma-separated built-in tools: read, bash, edit, write, grep, find, ls. Use "none" for no tools. Omit for all tools>
|
|
1675
|
+
model: <optional model as "provider/modelId", e.g. "anthropic/claude-haiku-4-5-20251001". Omit to inherit parent model>
|
|
1676
|
+
thinking: <optional thinking level: off, minimal, low, medium, high, xhigh. Omit to inherit>
|
|
1677
|
+
max_turns: <optional max agentic turns. 0 or omit for unlimited (default)>
|
|
1678
|
+
prompt_mode: <"replace" (body IS the full system prompt) or "append" (body is appended to default prompt). Default: replace>
|
|
1679
|
+
extensions: <true (inherit all MCP/extension tools), false (none), or comma-separated names. Default: true>
|
|
1680
|
+
skills: <true (inherit all), false (none), or comma-separated skill names to preload into prompt. Default: true>
|
|
1681
|
+
disallowed_tools: <comma-separated tool names to block, even if otherwise available. Omit for none>
|
|
1682
|
+
inherit_context: <true to fork parent conversation into agent so it sees chat history. Recommended for tasks needing current context. Default: false>
|
|
1683
|
+
isolated: <true for no extension/MCP tools, only built-in tools. Default: false>
|
|
1684
|
+
memory: <"user" (global), "project" (per-project), or "local" (gitignored per-project) for persistent memory. Omit for none>
|
|
1685
|
+
isolation: <"worktree" to run in isolated git worktree. Omit for normal>
|
|
1686
|
+
---
|
|
1687
|
+
|
|
1688
|
+
<system prompt body — instructions for the agent>
|
|
1689
|
+
\`\`\`
|
|
1690
|
+
|
|
1691
|
+
Guidelines for choosing settings:
|
|
1692
|
+
- For read-only tasks (review, analysis): tools: read, bash, grep, find, ls
|
|
1693
|
+
- For code modification tasks: include edit, write
|
|
1694
|
+
- Use prompt_mode: append if the agent should keep the default system prompt and add specialization on top
|
|
1695
|
+
- Use prompt_mode: replace for fully custom agents with their own personality/instructions
|
|
1696
|
+
- Set inherit_context: true if the agent needs to know what was discussed in the parent conversation
|
|
1697
|
+
- Set isolated: true if the agent should NOT have access to MCP servers or other extensions
|
|
1698
|
+
- Only include frontmatter fields that differ from defaults — omit fields where the default is fine
|
|
1699
|
+
|
|
1700
|
+
Write the file using the write tool. Only write the file, nothing else.`;
|
|
1701
|
+
|
|
1702
|
+
const record = await manager.spawnAndWait(pi, ctx, "general-purpose", generatePrompt, {
|
|
1703
|
+
description: `Generate ${name} agent`,
|
|
1704
|
+
maxTurns: 5,
|
|
1705
|
+
});
|
|
1706
|
+
|
|
1707
|
+
if (record.status === "error") {
|
|
1708
|
+
ctx.ui.notify(`Generation failed: ${record.error}`, "warning");
|
|
1709
|
+
return;
|
|
1710
|
+
}
|
|
1711
|
+
|
|
1712
|
+
reloadCustomAgents();
|
|
1713
|
+
|
|
1714
|
+
if (existsSync(targetPath)) {
|
|
1715
|
+
ctx.ui.notify(`Created ${targetPath}`, "info");
|
|
1716
|
+
} else {
|
|
1717
|
+
ctx.ui.notify("Agent generation completed but file was not created. Check the agent output.", "warning");
|
|
1718
|
+
}
|
|
1719
|
+
}
|
|
1720
|
+
|
|
1721
|
+
async function showManualWizard(ctx: ExtensionCommandContext, targetDir: string) {
|
|
1722
|
+
// 1. Name
|
|
1723
|
+
const name = await ctx.ui.input("Agent name (filename, no spaces)");
|
|
1724
|
+
if (!name) return;
|
|
1725
|
+
|
|
1726
|
+
// 2. Description
|
|
1727
|
+
const description = await ctx.ui.input("Description (one line)");
|
|
1728
|
+
if (!description) return;
|
|
1729
|
+
|
|
1730
|
+
// 3. Tools
|
|
1731
|
+
const toolChoice = await menuSelect(ctx, {
|
|
1732
|
+
title: "Tools",
|
|
1733
|
+
options: ["all", "none", "read-only (read, bash, grep, find, ls)", "custom..."],
|
|
1734
|
+
});
|
|
1735
|
+
if (!toolChoice) return;
|
|
1736
|
+
|
|
1737
|
+
let tools: string;
|
|
1738
|
+
if (toolChoice === "all") {
|
|
1739
|
+
tools = BUILTIN_TOOL_NAMES.join(", ");
|
|
1740
|
+
} else if (toolChoice === "none") {
|
|
1741
|
+
tools = "none";
|
|
1742
|
+
} else if (toolChoice.startsWith("read-only")) {
|
|
1743
|
+
tools = "read, bash, grep, find, ls";
|
|
1744
|
+
} else {
|
|
1745
|
+
const customTools = await ctx.ui.input("Tools (comma-separated)", BUILTIN_TOOL_NAMES.join(", "));
|
|
1746
|
+
if (!customTools) return;
|
|
1747
|
+
tools = customTools;
|
|
1748
|
+
}
|
|
1749
|
+
|
|
1750
|
+
// 4. Model
|
|
1751
|
+
const modelChoice = await menuSelect(ctx, {
|
|
1752
|
+
title: "Model",
|
|
1753
|
+
options: [
|
|
1754
|
+
"inherit (parent model)",
|
|
1755
|
+
"haiku",
|
|
1756
|
+
"sonnet",
|
|
1757
|
+
"opus",
|
|
1758
|
+
"custom...",
|
|
1759
|
+
],
|
|
1760
|
+
});
|
|
1761
|
+
if (!modelChoice) return;
|
|
1762
|
+
|
|
1763
|
+
let modelLine = "";
|
|
1764
|
+
if (modelChoice === "haiku") modelLine = "\nmodel: anthropic/claude-haiku-4-5-20251001";
|
|
1765
|
+
else if (modelChoice === "sonnet") modelLine = "\nmodel: anthropic/claude-sonnet-4-6";
|
|
1766
|
+
else if (modelChoice === "opus") modelLine = "\nmodel: anthropic/claude-opus-4-6";
|
|
1767
|
+
else if (modelChoice === "custom...") {
|
|
1768
|
+
const customModel = await ctx.ui.input("Model (provider/modelId)");
|
|
1769
|
+
if (customModel) modelLine = `\nmodel: ${customModel}`;
|
|
1770
|
+
}
|
|
1771
|
+
|
|
1772
|
+
// 5. Thinking
|
|
1773
|
+
const thinkingChoice = await menuSelect(ctx, {
|
|
1774
|
+
title: "Thinking level",
|
|
1775
|
+
options: [
|
|
1776
|
+
"inherit",
|
|
1777
|
+
"off",
|
|
1778
|
+
"minimal",
|
|
1779
|
+
"low",
|
|
1780
|
+
"medium",
|
|
1781
|
+
"high",
|
|
1782
|
+
"xhigh",
|
|
1783
|
+
],
|
|
1784
|
+
});
|
|
1785
|
+
if (!thinkingChoice) return;
|
|
1786
|
+
|
|
1787
|
+
let thinkingLine = "";
|
|
1788
|
+
if (thinkingChoice !== "inherit") thinkingLine = `\nthinking: ${thinkingChoice}`;
|
|
1789
|
+
|
|
1790
|
+
// 6. System prompt
|
|
1791
|
+
const systemPrompt = await ctx.ui.editor("System prompt", "");
|
|
1792
|
+
if (systemPrompt === undefined) return;
|
|
1793
|
+
|
|
1794
|
+
// Build the file
|
|
1795
|
+
const content = `---
|
|
1796
|
+
description: ${description}
|
|
1797
|
+
tools: ${tools}${modelLine}${thinkingLine}
|
|
1798
|
+
prompt_mode: replace
|
|
1799
|
+
---
|
|
1800
|
+
|
|
1801
|
+
${systemPrompt}
|
|
1802
|
+
`;
|
|
1803
|
+
|
|
1804
|
+
mkdirSync(targetDir, { recursive: true });
|
|
1805
|
+
const targetPath = join(targetDir, `${name}.md`);
|
|
1806
|
+
|
|
1807
|
+
if (existsSync(targetPath)) {
|
|
1808
|
+
const overwrite = await ctx.ui.confirm("Overwrite", `${targetPath} already exists. Overwrite?`);
|
|
1809
|
+
if (!overwrite) return;
|
|
1810
|
+
}
|
|
1811
|
+
|
|
1812
|
+
const { writeFileSync } = await import("node:fs");
|
|
1813
|
+
writeFileSync(targetPath, content, "utf-8");
|
|
1814
|
+
reloadCustomAgents();
|
|
1815
|
+
ctx.ui.notify(`Created ${targetPath}`, "info");
|
|
1816
|
+
}
|
|
1817
|
+
|
|
1818
|
+
function snapshotSettings(): SubagentsSettings {
|
|
1819
|
+
return {
|
|
1820
|
+
maxConcurrent: manager.getMaxConcurrent(),
|
|
1821
|
+
// 0 = unlimited — per SubagentsSettings.defaultMaxTurns docstring and
|
|
1822
|
+
// normalizeMaxTurns() in agent-runner.ts (which maps 0 → undefined).
|
|
1823
|
+
defaultMaxTurns: getDefaultMaxTurns() ?? 0,
|
|
1824
|
+
graceTurns: getGraceTurns(),
|
|
1825
|
+
defaultJoinMode: getDefaultJoinMode(),
|
|
1826
|
+
schedulingEnabled: isSchedulingEnabled(),
|
|
1827
|
+
scopeModels: isScopeModelsEnabled(),
|
|
1828
|
+
disableDefaultAgents: isDefaultsDisabled(),
|
|
1829
|
+
toolDescriptionMode: getToolDescriptionMode(),
|
|
1830
|
+
waitTimeoutSeconds: getWaitTimeoutSeconds(),
|
|
1831
|
+
abortResendKey: abortResendKey,
|
|
1832
|
+
widgetDisplayMode: getWidgetDisplayMode(),
|
|
1833
|
+
};
|
|
1834
|
+
}
|
|
1835
|
+
|
|
1836
|
+
const NUMERIC_IDS = new Set(["maxConcurrent", "defaultMaxTurns", "graceTurns", "waitTimeoutSeconds"]);
|
|
1837
|
+
const TEXT_IDS = new Set(["abortResendKey"]);
|
|
1838
|
+
|
|
1839
|
+
async function showSettings(ctx: ExtensionCommandContext) {
|
|
1840
|
+
function buildItems(): SettingItem[] {
|
|
1841
|
+
const mc = manager.getMaxConcurrent();
|
|
1842
|
+
const dmt = getDefaultMaxTurns() ?? 0;
|
|
1843
|
+
const gt = getGraceTurns();
|
|
1844
|
+
|
|
1845
|
+
return [
|
|
1846
|
+
{
|
|
1847
|
+
id: "maxConcurrent",
|
|
1848
|
+
label: "Max concurrency",
|
|
1849
|
+
description: "Max concurrent background agents (Enter to type)",
|
|
1850
|
+
currentValue: String(mc),
|
|
1851
|
+
values: [String(mc)],
|
|
1852
|
+
},
|
|
1853
|
+
{
|
|
1854
|
+
id: "defaultMaxTurns",
|
|
1855
|
+
label: "Default max turns",
|
|
1856
|
+
description: "Default max turns before wrap-up (0 = unlimited, Enter to type)",
|
|
1857
|
+
currentValue: String(dmt),
|
|
1858
|
+
values: [String(dmt)],
|
|
1859
|
+
},
|
|
1860
|
+
{
|
|
1861
|
+
id: "graceTurns",
|
|
1862
|
+
label: "Grace turns",
|
|
1863
|
+
description: "Grace turns after wrap-up steer (Enter to type)",
|
|
1864
|
+
currentValue: String(gt),
|
|
1865
|
+
values: [String(gt)],
|
|
1866
|
+
},
|
|
1867
|
+
{
|
|
1868
|
+
id: "joinMode",
|
|
1869
|
+
label: "Join mode",
|
|
1870
|
+
description: "Default join mode for background agents",
|
|
1871
|
+
currentValue: getDefaultJoinMode(),
|
|
1872
|
+
values: ["smart", "async", "group"],
|
|
1873
|
+
},
|
|
1874
|
+
{
|
|
1875
|
+
id: "schedulingEnabled",
|
|
1876
|
+
label: "Scheduling",
|
|
1877
|
+
description: "Schedule subagent feature (off removes `schedule` param from Agent tool spec on next pi session)",
|
|
1878
|
+
currentValue: isSchedulingEnabled() ? "on" : "off",
|
|
1879
|
+
values: ["on", "off"],
|
|
1880
|
+
},
|
|
1881
|
+
{
|
|
1882
|
+
id: "scopeModels",
|
|
1883
|
+
label: "Scope models",
|
|
1884
|
+
description: "Validate subagent models against scoped models (/scoped-models)",
|
|
1885
|
+
currentValue: isScopeModelsEnabled() ? "on" : "off",
|
|
1886
|
+
values: ["on", "off"],
|
|
1887
|
+
},
|
|
1888
|
+
{
|
|
1889
|
+
id: "disableDefaultAgents",
|
|
1890
|
+
label: "Disable defaults",
|
|
1891
|
+
description: "Hide built-in agents (general-purpose, Explore, Plan) — custom agents are unaffected",
|
|
1892
|
+
currentValue: isDefaultsDisabled() ? "on" : "off",
|
|
1893
|
+
values: ["on", "off"],
|
|
1894
|
+
},
|
|
1895
|
+
{
|
|
1896
|
+
id: "toolDescriptionMode",
|
|
1897
|
+
label: "Tool description",
|
|
1898
|
+
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}})",
|
|
1899
|
+
currentValue: getToolDescriptionMode(),
|
|
1900
|
+
values: ["full", "compact", "custom"],
|
|
1901
|
+
},
|
|
1902
|
+
{
|
|
1903
|
+
id: "widgetDisplayMode",
|
|
1904
|
+
label: "Widget display",
|
|
1905
|
+
description: "Recursive subagent widget display: auto (rich with compact fallback), rich, or compact",
|
|
1906
|
+
currentValue: getWidgetDisplayMode(),
|
|
1907
|
+
values: ["auto", "rich", "compact"],
|
|
1908
|
+
},
|
|
1909
|
+
{
|
|
1910
|
+
id: "waitTimeoutSeconds",
|
|
1911
|
+
label: "Wait timeout",
|
|
1912
|
+
description: "Seconds get_subagent_result wait:true blocks before returning status (30–3600, Enter to type)",
|
|
1913
|
+
currentValue: String(getWaitTimeoutSeconds()),
|
|
1914
|
+
values: [String(getWaitTimeoutSeconds())],
|
|
1915
|
+
},
|
|
1916
|
+
{
|
|
1917
|
+
id: "abortResendKey",
|
|
1918
|
+
label: "Abort+resend key",
|
|
1919
|
+
description: "Key that aborts the current turn AND auto-sends queued message(s) as the next turn (vs Escape, which restores the queue to the editor). Default f9. Enter to type (e.g. f8, shift+escape). Applies next session.",
|
|
1920
|
+
currentValue: abortResendKey ?? "f9",
|
|
1921
|
+
values: [abortResendKey ?? "f9"],
|
|
1922
|
+
},
|
|
1923
|
+
];
|
|
1924
|
+
}
|
|
1925
|
+
|
|
1926
|
+
function applyValue(id: string, value: string) {
|
|
1927
|
+
if (id === "maxConcurrent") {
|
|
1928
|
+
const n = parseInt(value, 10);
|
|
1929
|
+
if (n >= 1) {
|
|
1930
|
+
manager.setMaxConcurrent(n);
|
|
1931
|
+
notifyApplied(ctx, `Max concurrency set to ${n}`);
|
|
1932
|
+
}
|
|
1933
|
+
} else if (id === "defaultMaxTurns") {
|
|
1934
|
+
const n = parseInt(value, 10);
|
|
1935
|
+
if (n === 0) {
|
|
1936
|
+
setDefaultMaxTurns(undefined);
|
|
1937
|
+
notifyApplied(ctx, "Default max turns set to unlimited");
|
|
1938
|
+
} else if (n >= 1) {
|
|
1939
|
+
setDefaultMaxTurns(n);
|
|
1940
|
+
notifyApplied(ctx, `Default max turns set to ${n}`);
|
|
1941
|
+
}
|
|
1942
|
+
} else if (id === "graceTurns") {
|
|
1943
|
+
const n = parseInt(value, 10);
|
|
1944
|
+
if (n >= 1) {
|
|
1945
|
+
setGraceTurns(n);
|
|
1946
|
+
notifyApplied(ctx, `Grace turns set to ${n}`);
|
|
1947
|
+
}
|
|
1948
|
+
} else if (id === "joinMode") {
|
|
1949
|
+
setDefaultJoinMode(value as JoinMode);
|
|
1950
|
+
notifyApplied(ctx, `Default join mode set to ${value}`);
|
|
1951
|
+
} else if (id === "schedulingEnabled") {
|
|
1952
|
+
const enabled = value === "on";
|
|
1953
|
+
if (enabled === isSchedulingEnabled()) {
|
|
1954
|
+
ctx.ui.notify(`Scheduling already ${enabled ? "enabled" : "disabled"}.`, "info");
|
|
1955
|
+
} else {
|
|
1956
|
+
setSchedulingEnabled(enabled);
|
|
1957
|
+
if (!enabled) scheduler.stop(); // immediate kill — outstanding fires stop ticking
|
|
1958
|
+
notifyApplied(
|
|
1959
|
+
ctx,
|
|
1960
|
+
`Scheduling ${enabled ? "enabled" : "disabled"}. Tool spec change takes effect on next pi session.`,
|
|
1961
|
+
);
|
|
1962
|
+
}
|
|
1963
|
+
} else if (id === "scopeModels") {
|
|
1964
|
+
const enabled = value === "on";
|
|
1965
|
+
setScopeModelsEnabled(enabled);
|
|
1966
|
+
notifyApplied(ctx, `Scope models ${enabled ? "enabled" : "disabled"}`);
|
|
1967
|
+
} else if (id === "disableDefaultAgents") {
|
|
1968
|
+
const enabled = value === "on";
|
|
1969
|
+
setDisableDefaultAgents(enabled);
|
|
1970
|
+
notifyApplied(ctx, `Default agents ${enabled ? "disabled" : "enabled"}. Tool spec change takes effect on next pi session.`);
|
|
1971
|
+
} else if (id === "toolDescriptionMode") {
|
|
1972
|
+
setToolDescriptionMode(value as ToolDescriptionMode);
|
|
1973
|
+
notifyApplied(ctx, `Tool description set to ${value}. Takes effect on next pi session.`);
|
|
1974
|
+
} else if (id === "widgetDisplayMode") {
|
|
1975
|
+
setWidgetDisplayMode(value as WidgetDisplayMode);
|
|
1976
|
+
notifyApplied(ctx, `Widget display set to ${value}`);
|
|
1977
|
+
} else if (id === "waitTimeoutSeconds") {
|
|
1978
|
+
const n = parseInt(value, 10);
|
|
1979
|
+
if (n >= 30 && n <= 3600) {
|
|
1980
|
+
setWaitTimeoutSeconds(n);
|
|
1981
|
+
notifyApplied(ctx, `Wait timeout set to ${formatWaitTimeout(n)}`);
|
|
1982
|
+
}
|
|
1983
|
+
} else if (id === "abortResendKey") {
|
|
1984
|
+
const key = value.trim();
|
|
1985
|
+
if (key) {
|
|
1986
|
+
abortResendKey = key;
|
|
1987
|
+
notifyApplied(ctx, `Abort+resend key set to ${key}. Takes effect on next pi session.`);
|
|
1988
|
+
}
|
|
1989
|
+
}
|
|
1990
|
+
}
|
|
1991
|
+
|
|
1992
|
+
let list: SettingsList;
|
|
1993
|
+
// Track current selection index directly (SettingsList doesn't expose it).
|
|
1994
|
+
// Updated on arrow keys so Enter knows which field is selected immediately.
|
|
1995
|
+
let currentIndex = 0;
|
|
1996
|
+
|
|
1997
|
+
const result = await ctx.ui.custom<string | undefined>((_tui, _theme, _kb, done) => {
|
|
1998
|
+
const items = buildItems();
|
|
1999
|
+
|
|
2000
|
+
list = new SettingsList(
|
|
2001
|
+
items,
|
|
2002
|
+
items.length + 2,
|
|
2003
|
+
getSettingsListTheme(),
|
|
2004
|
+
(id, newValue) => {
|
|
2005
|
+
applyValue(id, newValue);
|
|
2006
|
+
},
|
|
2007
|
+
() => done(undefined as undefined),
|
|
2008
|
+
);
|
|
2009
|
+
|
|
2010
|
+
const container = new Container();
|
|
2011
|
+
container.addChild(new Text("⚙ Subagent Settings", 0, 0));
|
|
2012
|
+
container.addChild(new Spacer(1));
|
|
2013
|
+
container.addChild(list);
|
|
2014
|
+
|
|
2015
|
+
return {
|
|
2016
|
+
render: (w: number) => container.render(w),
|
|
2017
|
+
invalidate: () => container.invalidate(),
|
|
2018
|
+
handleInput: (data: string) => {
|
|
2019
|
+
// Back out of the settings menu (left arrow mirrors Esc)
|
|
2020
|
+
if (matchesKey(data, "left") || matchesKey(data, "escape")) {
|
|
2021
|
+
done(undefined as undefined);
|
|
2022
|
+
return;
|
|
2023
|
+
}
|
|
2024
|
+
|
|
2025
|
+
// Track navigation so Enter knows the current field
|
|
2026
|
+
if (matchesKey(data, "up")) {
|
|
2027
|
+
currentIndex = Math.max(0, currentIndex - 1);
|
|
2028
|
+
} else if (matchesKey(data, "down")) {
|
|
2029
|
+
currentIndex = Math.min(items.length - 1, currentIndex + 1);
|
|
2030
|
+
}
|
|
2031
|
+
|
|
2032
|
+
// Right arrow selects/activates the current item, just like Enter
|
|
2033
|
+
if (matchesKey(data, "right")) {
|
|
2034
|
+
const item = items[currentIndex];
|
|
2035
|
+
if (NUMERIC_IDS.has(item.id) || TEXT_IDS.has(item.id)) {
|
|
2036
|
+
done(item.id);
|
|
2037
|
+
return;
|
|
2038
|
+
}
|
|
2039
|
+
// For toggle items, SettingsList treats Space as activate
|
|
2040
|
+
list.handleInput?.(" ");
|
|
2041
|
+
return;
|
|
2042
|
+
}
|
|
2043
|
+
|
|
2044
|
+
// Enter on numeric or text field → close and prompt for typed input
|
|
2045
|
+
if (matchesKey(data, Key.enter) && (NUMERIC_IDS.has(items[currentIndex].id) || TEXT_IDS.has(items[currentIndex].id))) {
|
|
2046
|
+
done(items[currentIndex].id);
|
|
2047
|
+
return;
|
|
2048
|
+
}
|
|
2049
|
+
list.handleInput?.(data);
|
|
2050
|
+
},
|
|
2051
|
+
};
|
|
2052
|
+
});
|
|
2053
|
+
|
|
2054
|
+
// If a numeric field ID was returned, prompt for typed input
|
|
2055
|
+
if (result && NUMERIC_IDS.has(result)) {
|
|
2056
|
+
const current = result === "maxConcurrent"
|
|
2057
|
+
? String(manager.getMaxConcurrent())
|
|
2058
|
+
: result === "defaultMaxTurns"
|
|
2059
|
+
? String(getDefaultMaxTurns() ?? 0)
|
|
2060
|
+
: result === "waitTimeoutSeconds"
|
|
2061
|
+
? String(getWaitTimeoutSeconds())
|
|
2062
|
+
: String(getGraceTurns());
|
|
2063
|
+
|
|
2064
|
+
const label = result === "maxConcurrent"
|
|
2065
|
+
? "Max concurrency (1+)"
|
|
2066
|
+
: result === "defaultMaxTurns"
|
|
2067
|
+
? "Default max turns (0 = unlimited)"
|
|
2068
|
+
: result === "waitTimeoutSeconds"
|
|
2069
|
+
? "Wait timeout seconds (30–3600)"
|
|
2070
|
+
: "Grace turns (1+)";
|
|
2071
|
+
|
|
2072
|
+
// Loop until user enters a valid integer or cancels (Esc / null).
|
|
2073
|
+
// Silently trims whitespace; rejects non-numeric input by re-prompting.
|
|
2074
|
+
let input: string | undefined = await ctx.ui.input(label, current);
|
|
2075
|
+
while (input != null) {
|
|
2076
|
+
const trimmed = input.trim();
|
|
2077
|
+
const n = Number(trimmed);
|
|
2078
|
+
if (trimmed !== "" && Number.isInteger(n)) {
|
|
2079
|
+
applyValue(result, String(n));
|
|
2080
|
+
await showSettings(ctx);
|
|
2081
|
+
return;
|
|
2082
|
+
}
|
|
2083
|
+
// Invalid — re-prompt with the user's last entry so they can edit it
|
|
2084
|
+
input = await ctx.ui.input(label, trimmed);
|
|
2085
|
+
}
|
|
2086
|
+
} else if (result && TEXT_IDS.has(result)) {
|
|
2087
|
+
// Free-form text field (e.g. a key id). Prompt once; apply if non-empty.
|
|
2088
|
+
const current = result === "abortResendKey" ? (abortResendKey ?? "f9") : "";
|
|
2089
|
+
const label = "Abort+resend key (e.g. f9, f8, shift+escape)";
|
|
2090
|
+
const input = await ctx.ui.input(label, current);
|
|
2091
|
+
if (input != null && input.trim() !== "") {
|
|
2092
|
+
applyValue(result, input.trim());
|
|
2093
|
+
await showSettings(ctx);
|
|
2094
|
+
}
|
|
2095
|
+
}
|
|
2096
|
+
}
|
|
2097
|
+
|
|
2098
|
+
// Persist the current snapshot, emit `subagents:settings_changed`, and surface
|
|
2099
|
+
// the right toast. Successful saves show info; persistence failures downgrade
|
|
2100
|
+
// to warning so users aren't silently reverted on restart. Event fires regardless
|
|
2101
|
+
// of outcome so listeners see the in-memory change.
|
|
2102
|
+
function notifyApplied(ctx: ExtensionCommandContext, successMsg: string) {
|
|
2103
|
+
const { message, level } = saveAndEmitChanged(
|
|
2104
|
+
snapshotSettings(),
|
|
2105
|
+
successMsg,
|
|
2106
|
+
(event, payload) => pi.events.emit(event, payload),
|
|
2107
|
+
);
|
|
2108
|
+
ctx.ui.notify(message, level);
|
|
2109
|
+
}
|
|
2110
|
+
|
|
2111
|
+
pi.registerCommand("agents", {
|
|
2112
|
+
description: "Manage agents",
|
|
2113
|
+
handler: async (_args, ctx) => { await showAgentsMenu(ctx); },
|
|
2114
|
+
});
|
|
2115
|
+
}
|