pi-subagents 0.34.0 → 0.35.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +78 -9
- package/README.md +213 -32
- package/index.ts +1 -0
- package/install.mjs +1 -1
- package/package.json +23 -8
- package/prompts/review-loop.md +3 -1
- package/skills/pi-subagents/SKILL.md +87 -25
- package/src/agents/agent-management.ts +82 -15
- package/src/agents/agent-serializer.ts +19 -0
- package/src/agents/agents.ts +91 -49
- package/src/agents/frontmatter.ts +67 -13
- package/src/agents/skills.ts +25 -12
- package/src/api/background-work.ts +197 -0
- package/src/api/delegation.ts +158 -0
- package/src/extension/chain-validation.ts +165 -0
- package/src/extension/doctor.ts +15 -0
- package/src/extension/fanout-child.ts +3 -1
- package/src/extension/index.ts +65 -124
- package/src/extension/rpc.ts +10 -2
- package/src/extension/schemas.ts +18 -14
- package/src/extension/steering-notices.ts +35 -0
- package/src/extension/tool-description.ts +20 -9
- package/src/intercom/intercom-bridge.ts +3 -2
- package/src/intercom/native-supervisor-channel.ts +9 -1
- package/src/intercom/result-intercom.ts +4 -0
- package/src/runs/background/async-execution.ts +293 -44
- package/src/runs/background/async-job-tracker.ts +56 -9
- package/src/runs/background/async-resume.ts +159 -52
- package/src/runs/background/async-status.ts +25 -18
- package/src/runs/background/auto-drain.ts +67 -0
- package/src/runs/background/chain-root-attachment.ts +16 -8
- package/src/runs/background/control-channel.ts +260 -13
- package/src/runs/background/fleet-view.ts +23 -2
- package/src/runs/background/notify.ts +79 -10
- package/src/runs/background/result-watcher.ts +12 -9
- package/src/runs/background/run-id-resolver.ts +14 -2
- package/src/runs/background/run-status.ts +23 -15
- package/src/runs/background/scheduled-runs.ts +3 -0
- package/src/runs/background/stale-run-reconciler.ts +32 -10
- package/src/runs/background/steering.ts +237 -0
- package/src/runs/background/subagent-runner.ts +898 -236
- package/src/runs/background/subagent-wait.ts +484 -0
- package/src/runs/background/top-level-async.ts +2 -1
- package/src/runs/background/wait-config.ts +36 -0
- package/src/runs/background/wait-tool.ts +26 -0
- package/src/runs/foreground/async-steering-action.ts +230 -0
- package/src/runs/foreground/chain-clarify.ts +22 -6
- package/src/runs/foreground/chain-execution.ts +50 -32
- package/src/runs/foreground/execution.ts +308 -94
- package/src/runs/foreground/subagent-executor.ts +592 -268
- package/src/runs/shared/acceptance.ts +355 -97
- package/src/runs/shared/child-protocol.ts +121 -0
- package/src/runs/shared/completion-guard.ts +8 -127
- package/src/runs/shared/dynamic-fanout.ts +6 -4
- package/src/runs/shared/model-fallback.ts +36 -0
- package/src/runs/shared/nested-events.ts +9 -4
- package/src/runs/shared/nested-render.ts +4 -1
- package/src/runs/shared/parallel-utils.ts +7 -0
- package/src/runs/shared/pi-args.ts +34 -7
- package/src/runs/shared/pi-spawn.ts +18 -12
- package/src/runs/shared/session-lease.ts +279 -0
- package/src/runs/shared/single-output.ts +61 -6
- package/src/runs/shared/spawn-budget.ts +128 -0
- package/src/runs/shared/subagent-control.ts +10 -6
- package/src/runs/shared/subagent-prompt-runtime.ts +127 -26
- package/src/runs/shared/task-intent.ts +176 -0
- package/src/runs/shared/tool-availability.ts +65 -0
- package/src/runs/shared/turn-budget.ts +49 -4
- package/src/shared/atomic-json.ts +4 -1
- package/src/shared/fork-context.ts +28 -3
- package/src/shared/model-info.ts +7 -4
- package/src/shared/status-format.ts +7 -1
- package/src/shared/types.ts +203 -25
- package/src/shared/utils.ts +35 -7
- package/src/slash/delegation-adapters.ts +457 -0
- package/src/slash/delegation-request.ts +103 -0
- package/src/slash/prompt-template-bridge.ts +167 -344
- package/src/slash/slash-commands.ts +239 -6
- package/src/slash/subagents-admin.ts +428 -0
- package/src/slash/subagents-editor.ts +86 -0
- package/src/tui/fleet.ts +405 -0
- package/src/tui/render.ts +90 -16
- package/src/watchdog/change-signature.ts +127 -0
- package/src/watchdog/child-status.ts +205 -0
- package/src/watchdog/emission-guard.ts +123 -0
- package/src/watchdog/lsp-diagnostics.ts +532 -0
- package/src/watchdog/model-selection.ts +167 -0
- package/src/watchdog/register-child.ts +117 -0
- package/src/watchdog/register-main.ts +433 -0
- package/src/watchdog/render.ts +54 -0
- package/src/watchdog/review.ts +293 -0
- package/src/watchdog/runtime.ts +712 -0
- package/src/watchdog/settings.ts +528 -0
- package/src/watchdog/tool-actions.ts +155 -0
- package/src/watchdog/turn-delta.ts +161 -0
- package/src/watchdog/types.ts +188 -0
- package/src/watchdog/warning-format.ts +73 -0
- package/src/runs/background/wait.ts +0 -394
|
@@ -0,0 +1,428 @@
|
|
|
1
|
+
import * as fs from "node:fs";
|
|
2
|
+
import * as os from "node:os";
|
|
3
|
+
import * as path from "node:path";
|
|
4
|
+
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
5
|
+
import {
|
|
6
|
+
agentHasFrontmatterField,
|
|
7
|
+
discoverAgentsAll,
|
|
8
|
+
EXTRA_AGENT_DIRS_ENV,
|
|
9
|
+
frontmatterNameForConfig,
|
|
10
|
+
mergeBuiltinAgentOverride,
|
|
11
|
+
removeBuiltinAgentOverrideFields,
|
|
12
|
+
type AgentConfig,
|
|
13
|
+
type BuiltinAgentOverrideBase,
|
|
14
|
+
} from "../agents/agents.ts";
|
|
15
|
+
import { serializeAgent } from "../agents/agent-serializer.ts";
|
|
16
|
+
import { editableAgentConfig, preservedAgentFrontmatterFields } from "../agents/agent-management.ts";
|
|
17
|
+
import { findModelInfo, getSupportedThinkingLevels, toModelInfo } from "../shared/model-info.ts";
|
|
18
|
+
import { editorLabel, resolveEditorCommand, runEditorAndWait } from "./subagents-editor.ts";
|
|
19
|
+
|
|
20
|
+
const ADMIN_MESSAGE_TYPE = "subagents-admin";
|
|
21
|
+
const INHERIT_MODEL_CHOICE = "Default / inherit session model";
|
|
22
|
+
const INHERIT_THINKING_CHOICE = "Default / inherit session thinking";
|
|
23
|
+
|
|
24
|
+
type ModelInfo = { provider: string; id: string };
|
|
25
|
+
|
|
26
|
+
function sourceRank(source: AgentConfig["source"]): number {
|
|
27
|
+
if (source === "project") return 0;
|
|
28
|
+
if (source === "user") return 1;
|
|
29
|
+
if (source === "package") return 2;
|
|
30
|
+
return 3;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function allVisibleAgents(cwd: string): AgentConfig[] {
|
|
34
|
+
const d = discoverAgentsAll(cwd);
|
|
35
|
+
return [...d.project, ...d.user, ...d.package, ...d.builtin]
|
|
36
|
+
.filter((agent) => !agent.disabled)
|
|
37
|
+
.sort((a, b) => a.name.localeCompare(b.name) || sourceRank(a.source) - sourceRank(b.source));
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function agentLabel(agent: AgentConfig): string {
|
|
41
|
+
const model = agent.model ? ` · ${agent.model}` : "";
|
|
42
|
+
return `${agent.name} [${agent.source}]${model} — ${agent.description}`;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function agentChoices(agents: AgentConfig[]): Map<string, AgentConfig> {
|
|
46
|
+
const labels = agents.map(agentLabel);
|
|
47
|
+
const counts = new Map<string, number>();
|
|
48
|
+
for (const label of labels) counts.set(label, (counts.get(label) ?? 0) + 1);
|
|
49
|
+
return new Map(agents.map((agent, index) => {
|
|
50
|
+
const label = labels[index]!;
|
|
51
|
+
return [counts.get(label) === 1 ? label : `${label} · ${agent.filePath}`, agent] as const;
|
|
52
|
+
}));
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function agentMatches(agent: AgentConfig, rawName: string): boolean {
|
|
56
|
+
const name = rawName.trim();
|
|
57
|
+
return agent.name === name || frontmatterNameForConfig(agent) === name;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function sendAdminMessage(pi: ExtensionAPI, content: string): void {
|
|
61
|
+
pi.sendMessage({
|
|
62
|
+
customType: ADMIN_MESSAGE_TYPE,
|
|
63
|
+
content,
|
|
64
|
+
display: true,
|
|
65
|
+
});
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function modelFullId(model: ModelInfo): string {
|
|
69
|
+
return `${model.provider}/${model.id}`;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function liveAvailableModels(ctx: ExtensionContext) {
|
|
73
|
+
try {
|
|
74
|
+
ctx.modelRegistry.refresh?.();
|
|
75
|
+
} catch (error) {
|
|
76
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
77
|
+
ctx.ui.notify(`Could not refresh the model registry; using the last loaded choices. ${message}`, "warning");
|
|
78
|
+
}
|
|
79
|
+
return ctx.modelRegistry.getAvailable();
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function buildBuiltinBase(agent: AgentConfig): BuiltinAgentOverrideBase {
|
|
83
|
+
return {
|
|
84
|
+
model: agent.model,
|
|
85
|
+
fallbackModels: agent.fallbackModels ? [...agent.fallbackModels] : undefined,
|
|
86
|
+
thinking: agent.thinking,
|
|
87
|
+
systemPromptMode: agent.systemPromptMode,
|
|
88
|
+
inheritProjectContext: agent.inheritProjectContext,
|
|
89
|
+
inheritSkills: agent.inheritSkills,
|
|
90
|
+
defaultContext: agent.defaultContext,
|
|
91
|
+
acceptanceRole: agent.acceptanceRole,
|
|
92
|
+
disabled: agent.disabled,
|
|
93
|
+
systemPrompt: agent.systemPrompt,
|
|
94
|
+
skills: agent.skills ? [...agent.skills] : undefined,
|
|
95
|
+
tools: agent.tools ? [...agent.tools] : undefined,
|
|
96
|
+
mcpDirectTools: agent.mcpDirectTools ? [...agent.mcpDirectTools] : undefined,
|
|
97
|
+
subagentOnlyExtensions: agent.subagentOnlyExtensions ? [...agent.subagentOnlyExtensions] : undefined,
|
|
98
|
+
completionGuard: agent.completionGuard,
|
|
99
|
+
toolBudget: agent.toolBudget,
|
|
100
|
+
};
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
type EditableOverrideField = "model" | "thinking" | "systemPrompt";
|
|
104
|
+
|
|
105
|
+
type AgentSelection =
|
|
106
|
+
| { kind: "selected"; agent: AgentConfig }
|
|
107
|
+
| { kind: "cancelled" }
|
|
108
|
+
| { kind: "not-found"; agents: AgentConfig[]; requestedName?: string }
|
|
109
|
+
| { kind: "ambiguous"; requestedName: string; matches: AgentConfig[] };
|
|
110
|
+
|
|
111
|
+
function savesThroughSettings(agent: AgentConfig, field: EditableOverrideField): boolean {
|
|
112
|
+
if (agent.source === "builtin") return true;
|
|
113
|
+
if (agent.source === "package") {
|
|
114
|
+
if (field === "systemPrompt") return false;
|
|
115
|
+
return !agentHasFrontmatterField(agent, field);
|
|
116
|
+
}
|
|
117
|
+
if (!agent.override) return false;
|
|
118
|
+
// A lower-scope override can flow into a higher-scope custom agent with the
|
|
119
|
+
// same name. Persist that agent's edits in its own frontmatter instead of
|
|
120
|
+
// rewriting the shared lower-scope override used by another agent.
|
|
121
|
+
if (agent.source !== agent.override.scope) return false;
|
|
122
|
+
// Custom-agent overrides fill only fields absent from frontmatter. Compare the
|
|
123
|
+
// effective value with the pre-override base so an override on one field does
|
|
124
|
+
// not redirect edits to an unrelated frontmatter-owned field.
|
|
125
|
+
return agent[field] !== agent.override.base[field];
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function isReadOnlyExtraAgent(agent: AgentConfig): boolean {
|
|
129
|
+
const configured = process.env[EXTRA_AGENT_DIRS_ENV];
|
|
130
|
+
if (!configured || agent.source !== "user") return false;
|
|
131
|
+
const filePath = path.resolve(agent.filePath);
|
|
132
|
+
return configured.split(path.delimiter).map((dir) => dir.trim()).filter(Boolean).some((dir) => {
|
|
133
|
+
const root = path.resolve(dir);
|
|
134
|
+
const relative = path.relative(root, filePath);
|
|
135
|
+
return relative !== "" && relative !== ".." && !relative.startsWith(`..${path.sep}`) && !path.isAbsolute(relative);
|
|
136
|
+
});
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function readOnlyAgentMessage(agent: AgentConfig, field: EditableOverrideField): string | undefined {
|
|
140
|
+
if (agent.source === "package") {
|
|
141
|
+
return `Cannot update '${agent.name}' ${field} because that field is owned by its read-only package definition.`;
|
|
142
|
+
}
|
|
143
|
+
return isReadOnlyExtraAgent(agent)
|
|
144
|
+
? `Cannot update '${agent.name}' because its definition in PI_SUBAGENT_EXTRA_AGENT_DIRS is read-only.`
|
|
145
|
+
: undefined;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
async function selectAgent(ctx: ExtensionContext, args: string): Promise<AgentSelection> {
|
|
149
|
+
const agents = allVisibleAgents(ctx.cwd);
|
|
150
|
+
const requestedName = args.trim().split(/\s+/)[0] ?? "";
|
|
151
|
+
if (agents.length === 0) return { kind: "not-found", agents, requestedName: requestedName || undefined };
|
|
152
|
+
|
|
153
|
+
if (requestedName) {
|
|
154
|
+
const matches = agents.filter((agent) => agentMatches(agent, requestedName));
|
|
155
|
+
if (matches.length === 1) return { kind: "selected", agent: matches[0]! };
|
|
156
|
+
if (matches.length > 1 && !ctx.hasUI) return { kind: "ambiguous", requestedName, matches };
|
|
157
|
+
if (matches.length > 1) {
|
|
158
|
+
const byLabel = agentChoices(matches);
|
|
159
|
+
const choice = await ctx.ui.select(`Multiple subagents named '${requestedName}'`, [...byLabel.keys()]);
|
|
160
|
+
return choice ? { kind: "selected", agent: byLabel.get(choice)! } : { kind: "cancelled" };
|
|
161
|
+
}
|
|
162
|
+
return { kind: "not-found", agents, requestedName };
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
if (!ctx.hasUI) return { kind: "not-found", agents };
|
|
166
|
+
const byLabel = agentChoices(agents);
|
|
167
|
+
const choice = await ctx.ui.select("Select subagent", [...byLabel.keys()]);
|
|
168
|
+
return choice ? { kind: "selected", agent: byLabel.get(choice)! } : { kind: "cancelled" };
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
function metadataFor(agent: AgentConfig): string {
|
|
172
|
+
const tools = [...(agent.tools ?? []), ...(agent.mcpDirectTools ?? []).map((tool) => `mcp:${tool}`)];
|
|
173
|
+
const lines = [
|
|
174
|
+
`Agent: ${agent.name} (${agent.source})`,
|
|
175
|
+
`Path: ${agent.filePath}`,
|
|
176
|
+
`Description: ${agent.description}`,
|
|
177
|
+
];
|
|
178
|
+
if (agent.packageName) {
|
|
179
|
+
lines.push(`Local name: ${frontmatterNameForConfig(agent)}`);
|
|
180
|
+
lines.push(`Package: ${agent.packageName}`);
|
|
181
|
+
}
|
|
182
|
+
lines.push(`Model: ${agent.model ?? "default / inherit"}`);
|
|
183
|
+
if (agent.fallbackModels?.length) lines.push(`Fallback models: ${agent.fallbackModels.join(", ")}`);
|
|
184
|
+
if (agent.thinking !== undefined) lines.push(`Thinking: ${agent.thinking === false ? "off" : agent.thinking}`);
|
|
185
|
+
if (tools.length) lines.push(`Tools: ${tools.join(", ")}`);
|
|
186
|
+
if (agent.skills?.length) lines.push(`Skills: ${agent.skills.join(", ")}`);
|
|
187
|
+
lines.push(`System prompt mode: ${agent.systemPromptMode}`);
|
|
188
|
+
lines.push(`Inherit project context: ${agent.inheritProjectContext ? "true" : "false"}`);
|
|
189
|
+
lines.push(`Inherit skills: ${agent.inheritSkills ? "true" : "false"}`);
|
|
190
|
+
if (agent.defaultContext) lines.push(`Default context: ${agent.defaultContext}`);
|
|
191
|
+
if (agent.output) lines.push(`Output: ${agent.output}`);
|
|
192
|
+
if (agent.defaultReads?.length) lines.push(`Reads: ${agent.defaultReads.join(", ")}`);
|
|
193
|
+
if (agent.defaultProgress) lines.push("Progress: true");
|
|
194
|
+
if (agent.maxSubagentDepth !== undefined) lines.push(`Max subagent depth: ${agent.maxSubagentDepth}`);
|
|
195
|
+
if (agent.source === "builtin") lines.push(`Disabled: ${agent.disabled ? "true" : "false"}`);
|
|
196
|
+
if (agent.override) lines.push(`Override: ${agent.override.scope} (${agent.override.path})`);
|
|
197
|
+
if (agent.systemPrompt.trim()) lines.push("", "System Prompt:", agent.systemPrompt);
|
|
198
|
+
return lines.join("\n");
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
async function chooseModel(ctx: ExtensionContext, agent: AgentConfig): Promise<string | undefined | null> {
|
|
202
|
+
const models = liveAvailableModels(ctx).map((model) => modelFullId(model));
|
|
203
|
+
const current = agent.model ?? INHERIT_MODEL_CHOICE;
|
|
204
|
+
const choices = [INHERIT_MODEL_CHOICE, ...models.filter((model) => model !== agent.model)];
|
|
205
|
+
if (agent.model && !choices.includes(agent.model)) choices.splice(1, 0, agent.model);
|
|
206
|
+
const choice = await ctx.ui.select(`Select model for ${agent.name}\nCurrent: ${current}`, choices);
|
|
207
|
+
if (!choice) return null;
|
|
208
|
+
return choice === INHERIT_MODEL_CHOICE ? undefined : choice;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
async function chooseThinking(ctx: ExtensionContext, agent: AgentConfig): Promise<string | undefined | null> {
|
|
212
|
+
const availableModels = liveAvailableModels(ctx).map(toModelInfo);
|
|
213
|
+
const effectiveModel = agent.model ?? (ctx.model ? modelFullId(ctx.model) : undefined);
|
|
214
|
+
const modelInfo = findModelInfo(effectiveModel, availableModels, ctx.model?.provider);
|
|
215
|
+
const levels = getSupportedThinkingLevels(modelInfo);
|
|
216
|
+
const current = agent.thinking === false ? "off" : agent.thinking ?? INHERIT_THINKING_CHOICE;
|
|
217
|
+
const choices: string[] = [INHERIT_THINKING_CHOICE, ...levels];
|
|
218
|
+
if (current !== INHERIT_THINKING_CHOICE && !choices.includes(current)) choices.splice(1, 0, current);
|
|
219
|
+
const modelNote = agent.model
|
|
220
|
+
? `Model: ${agent.model}`
|
|
221
|
+
: effectiveModel
|
|
222
|
+
? `Session model: ${effectiveModel}`
|
|
223
|
+
: "Model: default / inherit";
|
|
224
|
+
const choice = await ctx.ui.select(
|
|
225
|
+
`Select thinking level for ${agent.name}\n${modelNote} · Current: ${current}`,
|
|
226
|
+
choices,
|
|
227
|
+
);
|
|
228
|
+
if (!choice) return null;
|
|
229
|
+
return choice === INHERIT_THINKING_CHOICE ? undefined : choice;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
async function chooseOverrideScope(ctx: ExtensionContext, agent: AgentConfig): Promise<"user" | "project" | undefined> {
|
|
233
|
+
if (agent.override?.scope) return agent.override.scope;
|
|
234
|
+
const d = discoverAgentsAll(ctx.cwd);
|
|
235
|
+
if (!d.projectSettingsPath || !ctx.hasUI) return "user";
|
|
236
|
+
const choice = await ctx.ui.select(`Save builtin override for ${agent.name}`, ["user", "project"]);
|
|
237
|
+
return choice === "user" || choice === "project" ? choice : undefined;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
function persistSettingsField(
|
|
241
|
+
ctx: ExtensionContext,
|
|
242
|
+
agent: AgentConfig,
|
|
243
|
+
scope: "user" | "project",
|
|
244
|
+
field: EditableOverrideField,
|
|
245
|
+
value: string | undefined,
|
|
246
|
+
): { filePath: string; overridden: boolean } {
|
|
247
|
+
const base = agent.override?.base ?? buildBuiltinBase(agent);
|
|
248
|
+
if (value === undefined || value === base[field]) {
|
|
249
|
+
return {
|
|
250
|
+
filePath: removeBuiltinAgentOverrideFields(ctx.cwd, agent.name, scope, [field]).path,
|
|
251
|
+
overridden: false,
|
|
252
|
+
};
|
|
253
|
+
}
|
|
254
|
+
return {
|
|
255
|
+
filePath: mergeBuiltinAgentOverride(ctx.cwd, agent.name, scope, { [field]: value }),
|
|
256
|
+
overridden: true,
|
|
257
|
+
};
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
async function saveAgentModel(ctx: ExtensionContext, agent: AgentConfig, selectedModel: string | undefined): Promise<string | null> {
|
|
261
|
+
if (savesThroughSettings(agent, "model")) {
|
|
262
|
+
const scope = await chooseOverrideScope(ctx, agent);
|
|
263
|
+
if (!scope) return null;
|
|
264
|
+
const { filePath, overridden } = persistSettingsField(ctx, agent, scope, "model", selectedModel);
|
|
265
|
+
return overridden
|
|
266
|
+
? `Saved ${scope} settings override for '${agent.name}' with model '${selectedModel}' in ${filePath}.`
|
|
267
|
+
: `Cleared model settings override for '${agent.name}' in ${filePath}.`;
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
const readOnlyMessage = readOnlyAgentMessage(agent, "model");
|
|
271
|
+
if (readOnlyMessage) return readOnlyMessage;
|
|
272
|
+
const updated: AgentConfig = { ...editableAgentConfig(agent), model: selectedModel };
|
|
273
|
+
fs.writeFileSync(updated.filePath, serializeAgent(updated, {
|
|
274
|
+
preserveFrontmatterFields: preservedAgentFrontmatterFields(agent, { model: selectedModel }),
|
|
275
|
+
}), "utf-8");
|
|
276
|
+
return selectedModel
|
|
277
|
+
? `Updated '${agent.name}' model to '${selectedModel}' in ${updated.filePath}.`
|
|
278
|
+
: `Cleared '${agent.name}' model in ${updated.filePath}.`;
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
async function saveAgentThinking(ctx: ExtensionContext, agent: AgentConfig, selectedThinking: string | undefined): Promise<string | null> {
|
|
282
|
+
if (savesThroughSettings(agent, "thinking")) {
|
|
283
|
+
const scope = await chooseOverrideScope(ctx, agent);
|
|
284
|
+
if (!scope) return null;
|
|
285
|
+
const { filePath, overridden } = persistSettingsField(ctx, agent, scope, "thinking", selectedThinking);
|
|
286
|
+
return overridden
|
|
287
|
+
? `Saved ${scope} settings override for '${agent.name}' with thinking '${selectedThinking}' in ${filePath}.`
|
|
288
|
+
: `Cleared thinking settings override for '${agent.name}' in ${filePath}.`;
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
const readOnlyMessage = readOnlyAgentMessage(agent, "thinking");
|
|
292
|
+
if (readOnlyMessage) return readOnlyMessage;
|
|
293
|
+
const updated: AgentConfig = { ...editableAgentConfig(agent), thinking: selectedThinking };
|
|
294
|
+
fs.writeFileSync(updated.filePath, serializeAgent(updated, {
|
|
295
|
+
preserveFrontmatterFields: preservedAgentFrontmatterFields(agent, { thinking: selectedThinking }),
|
|
296
|
+
}), "utf-8");
|
|
297
|
+
return selectedThinking
|
|
298
|
+
? `Updated '${agent.name}' thinking to '${selectedThinking}' in ${updated.filePath}.`
|
|
299
|
+
: `Cleared '${agent.name}' thinking in ${updated.filePath}.`;
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
/** Compact one-line summary shown in the interactive picker (no full system prompt dump). */
|
|
303
|
+
function metadataSummary(agent: AgentConfig): string {
|
|
304
|
+
return [
|
|
305
|
+
`Source: ${agent.source}`,
|
|
306
|
+
`Model: ${agent.model ?? "default / inherit"}`,
|
|
307
|
+
`Thinking: ${agent.thinking === false ? "off" : agent.thinking ?? "default / inherit"}`,
|
|
308
|
+
].join(" · ");
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
async function saveAgentSystemPrompt(ctx: ExtensionContext, agent: AgentConfig, systemPrompt: string): Promise<string | null> {
|
|
312
|
+
const nextPrompt = systemPrompt.replace(/\s+$/, "");
|
|
313
|
+
if (savesThroughSettings(agent, "systemPrompt")) {
|
|
314
|
+
const scope = await chooseOverrideScope(ctx, agent);
|
|
315
|
+
if (!scope) return null;
|
|
316
|
+
const { filePath, overridden } = persistSettingsField(ctx, agent, scope, "systemPrompt", nextPrompt);
|
|
317
|
+
return overridden
|
|
318
|
+
? `Saved ${scope} settings override for '${agent.name}' system prompt in ${filePath}.`
|
|
319
|
+
: `Cleared system prompt settings override for '${agent.name}' in ${filePath}.`;
|
|
320
|
+
}
|
|
321
|
+
const readOnlyMessage = readOnlyAgentMessage(agent, "systemPrompt");
|
|
322
|
+
if (readOnlyMessage) return readOnlyMessage;
|
|
323
|
+
const updated: AgentConfig = { ...editableAgentConfig(agent), systemPrompt: nextPrompt };
|
|
324
|
+
fs.writeFileSync(updated.filePath, serializeAgent(updated, {
|
|
325
|
+
preserveFrontmatterFields: preservedAgentFrontmatterFields(agent, { systemPrompt: nextPrompt }),
|
|
326
|
+
}), "utf-8");
|
|
327
|
+
return `Updated '${agent.name}' system prompt in ${updated.filePath}.`;
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
/**
|
|
331
|
+
* Round-trip the agent's system prompt through the user's external editor: write it to a
|
|
332
|
+
* temp .md file, open the editor and wait for it to close, then persist the result.
|
|
333
|
+
* Returns the status message, or null when nothing should be posted.
|
|
334
|
+
*/
|
|
335
|
+
async function editSystemPrompt(ctx: ExtensionContext, agent: AgentConfig): Promise<string | null> {
|
|
336
|
+
if (!savesThroughSettings(agent, "systemPrompt")) {
|
|
337
|
+
const readOnlyMessage = readOnlyAgentMessage(agent, "systemPrompt");
|
|
338
|
+
if (readOnlyMessage) return readOnlyMessage;
|
|
339
|
+
}
|
|
340
|
+
const editor = resolveEditorCommand();
|
|
341
|
+
if (!editor) {
|
|
342
|
+
return "No editor configured. Set $VISUAL or $EDITOR (e.g. 'open -W -n -a MarkEdit').";
|
|
343
|
+
}
|
|
344
|
+
const label = editorLabel(editor);
|
|
345
|
+
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "pi-subagent-prompt-"));
|
|
346
|
+
const safeName = path.basename(agent.name.replace(/[^a-z0-9._-]/gi, "_")) || "agent";
|
|
347
|
+
const tmpPath = path.join(dir, `${safeName}.md`);
|
|
348
|
+
try {
|
|
349
|
+
fs.writeFileSync(tmpPath, agent.systemPrompt ?? "", "utf-8");
|
|
350
|
+
ctx.ui.notify(`Editing '${agent.name}' system prompt in ${label}. Save and close the editor window to apply.`, "info");
|
|
351
|
+
ctx.ui.setStatus("subagents-edit", `Waiting for ${label} to close…`);
|
|
352
|
+
await runEditorAndWait(editor, tmpPath);
|
|
353
|
+
const edited = fs.readFileSync(tmpPath, "utf-8");
|
|
354
|
+
if (edited.replace(/\s+$/, "") === (agent.systemPrompt ?? "").replace(/\s+$/, "")) {
|
|
355
|
+
return `System prompt for '${agent.name}' left unchanged.`;
|
|
356
|
+
}
|
|
357
|
+
return await saveAgentSystemPrompt(ctx, agent, edited);
|
|
358
|
+
} finally {
|
|
359
|
+
ctx.ui.setStatus("subagents-edit", undefined);
|
|
360
|
+
try { fs.rmSync(dir, { recursive: true, force: true }); } catch { /* best effort cleanup */ }
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
export async function openSubagentsAdmin(pi: ExtensionAPI, ctx: ExtensionContext, args = ""): Promise<void> {
|
|
365
|
+
const selection = await selectAgent(ctx, args);
|
|
366
|
+
if (selection.kind === "cancelled") return;
|
|
367
|
+
if (selection.kind === "ambiguous") {
|
|
368
|
+
sendAdminMessage(pi, `Subagent '${selection.requestedName}' is ambiguous. Choose a scope in interactive mode:\n${selection.matches.map((agent) => `- ${agent.source}: ${agent.filePath}`).join("\n")}`);
|
|
369
|
+
return;
|
|
370
|
+
}
|
|
371
|
+
if (selection.kind === "not-found") {
|
|
372
|
+
const text = selection.requestedName
|
|
373
|
+
? `Subagent '${selection.requestedName}' not found.\n\nAvailable subagents:\n${selection.agents.map((agent) => `- ${agent.name} (${agent.source})`).join("\n") || "- (none)"}`
|
|
374
|
+
: `Available subagents:\n${selection.agents.map((agent) => `- ${agent.name} (${agent.source})`).join("\n") || "- (none)"}`;
|
|
375
|
+
sendAdminMessage(pi, text);
|
|
376
|
+
return;
|
|
377
|
+
}
|
|
378
|
+
const agent = selection.agent;
|
|
379
|
+
|
|
380
|
+
if (!ctx.hasUI) {
|
|
381
|
+
// Non-interactive (tool/headless): emit full metadata as the inspection result.
|
|
382
|
+
sendAdminMessage(pi, metadataFor(agent));
|
|
383
|
+
return;
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
const requestedAction = args.trim().split(/\s+/)[1]?.toLowerCase();
|
|
387
|
+
let action: string | undefined;
|
|
388
|
+
if (requestedAction === "model") action = "Change model";
|
|
389
|
+
else if (requestedAction === "thinking") action = "Change thinking level";
|
|
390
|
+
else if (requestedAction === "prompt" || requestedAction === "system-prompt" || requestedAction === "edit") action = "Edit system prompt";
|
|
391
|
+
else if (requestedAction === "details" || requestedAction === "info") action = "Show details";
|
|
392
|
+
if (!action) {
|
|
393
|
+
action = await ctx.ui.select(
|
|
394
|
+
`Administer ${agent.name}\n${metadataSummary(agent)}`,
|
|
395
|
+
["Change model", "Change thinking level", "Edit system prompt", "Show details", "Done"],
|
|
396
|
+
);
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
try {
|
|
400
|
+
if (action === "Change model") {
|
|
401
|
+
const selectedModel = await chooseModel(ctx, agent);
|
|
402
|
+
if (selectedModel === null) return;
|
|
403
|
+
const message = await saveAgentModel(ctx, agent, selectedModel);
|
|
404
|
+
if (message === null) return;
|
|
405
|
+
ctx.ui.notify(message, "info");
|
|
406
|
+
sendAdminMessage(pi, message);
|
|
407
|
+
} else if (action === "Change thinking level") {
|
|
408
|
+
const selectedThinking = await chooseThinking(ctx, agent);
|
|
409
|
+
if (selectedThinking === null) return;
|
|
410
|
+
const message = await saveAgentThinking(ctx, agent, selectedThinking);
|
|
411
|
+
if (message === null) return;
|
|
412
|
+
ctx.ui.notify(message, "info");
|
|
413
|
+
sendAdminMessage(pi, message);
|
|
414
|
+
} else if (action === "Edit system prompt") {
|
|
415
|
+
const message = await editSystemPrompt(ctx, agent);
|
|
416
|
+
if (message === null) return;
|
|
417
|
+
ctx.ui.notify(message, "info");
|
|
418
|
+
sendAdminMessage(pi, message);
|
|
419
|
+
} else if (action === "Show details") {
|
|
420
|
+
// Full metadata is now opt-in (previously always posted to the thread).
|
|
421
|
+
sendAdminMessage(pi, metadataFor(agent));
|
|
422
|
+
}
|
|
423
|
+
} catch (error) {
|
|
424
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
425
|
+
ctx.ui.notify(message, "error");
|
|
426
|
+
sendAdminMessage(pi, `Failed to update '${agent.name}': ${message}`);
|
|
427
|
+
}
|
|
428
|
+
}
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
|
+
import * as path from "node:path";
|
|
3
|
+
|
|
4
|
+
export interface EditorSpec {
|
|
5
|
+
command: string;
|
|
6
|
+
args: string[];
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
/** Resolve a blocking graphical editor command. Pi owns the terminal, so terminal editors are unsupported. */
|
|
10
|
+
export function resolveEditorCommand(): EditorSpec | undefined {
|
|
11
|
+
const raw = (process.env.VISUAL || process.env.EDITOR || "").trim()
|
|
12
|
+
|| (process.platform === "darwin" ? "open -W -n -a MarkEdit" : "");
|
|
13
|
+
if (!raw) return undefined;
|
|
14
|
+
|
|
15
|
+
const parts: string[] = [];
|
|
16
|
+
let current = "";
|
|
17
|
+
let quote: "'" | '"' | undefined;
|
|
18
|
+
for (let index = 0; index < raw.length; index++) {
|
|
19
|
+
const char = raw[index]!;
|
|
20
|
+
if (quote) {
|
|
21
|
+
if (char === quote) quote = undefined;
|
|
22
|
+
else if (char === "\\" && raw[index + 1] === quote) current += raw[++index]!;
|
|
23
|
+
else current += char;
|
|
24
|
+
} else if (char === "'" || char === '"') {
|
|
25
|
+
quote = char;
|
|
26
|
+
} else if (/\s/.test(char)) {
|
|
27
|
+
if (current) {
|
|
28
|
+
parts.push(current);
|
|
29
|
+
current = "";
|
|
30
|
+
}
|
|
31
|
+
} else if (char === "\\" && raw[index + 1] && /[\s'"\\]/.test(raw[index + 1]!)) {
|
|
32
|
+
current += raw[++index]!;
|
|
33
|
+
} else {
|
|
34
|
+
current += char;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
if (quote) throw new Error("The configured editor command contains an unmatched quote.");
|
|
38
|
+
if (current) parts.push(current);
|
|
39
|
+
if (parts.length === 0) return undefined;
|
|
40
|
+
return { command: parts[0]!, args: parts.slice(1) };
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function editorLabel(editor: EditorSpec): string {
|
|
44
|
+
const appIdx = editor.args.indexOf("-a");
|
|
45
|
+
if (editor.command === "open" && appIdx !== -1 && editor.args[appIdx + 1]) return editor.args[appIdx + 1]!;
|
|
46
|
+
return editor.command;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function escapeWindowsCommand(value: string): string {
|
|
50
|
+
return value.replace(/([()\][%!^"`<>&|;, *?])/g, "^$1");
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function escapeWindowsArgument(value: string, doubleEscapeMetaChars: boolean): string {
|
|
54
|
+
let escaped = value
|
|
55
|
+
.replace(/(?=(\\+?)?)\1"/g, "$1$1\\\"")
|
|
56
|
+
.replace(/(?=(\\+?)?)\1$/, "$1$1");
|
|
57
|
+
escaped = `"${escaped}"`.replace(/([()\][%!^"`<>&|;, *?])/g, "^$1");
|
|
58
|
+
return doubleEscapeMetaChars
|
|
59
|
+
? escaped.replace(/([()\][%!^"`<>&|;, *?])/g, "^$1")
|
|
60
|
+
: escaped;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export function runEditorAndWait(editor: EditorSpec, filePath: string): Promise<void> {
|
|
64
|
+
return new Promise((resolve, reject) => {
|
|
65
|
+
const editorArgs = [...editor.args, filePath];
|
|
66
|
+
const child = process.platform === "win32" && !/\.(?:com|exe)$/i.test(editor.command)
|
|
67
|
+
? (() => {
|
|
68
|
+
const doubleEscape = /node_modules[\\/]\.bin[\\/][^\\/]+\.cmd$/i.test(editor.command);
|
|
69
|
+
const commandLine = [
|
|
70
|
+
escapeWindowsCommand(path.normalize(editor.command)),
|
|
71
|
+
...editorArgs.map((arg) => escapeWindowsArgument(arg, doubleEscape)),
|
|
72
|
+
].join(" ");
|
|
73
|
+
return spawn(process.env.ComSpec || process.env.COMSPEC || "cmd.exe", ["/d", "/s", "/c", `"${commandLine}"`], {
|
|
74
|
+
stdio: "ignore",
|
|
75
|
+
windowsVerbatimArguments: true,
|
|
76
|
+
});
|
|
77
|
+
})()
|
|
78
|
+
: spawn(editor.command, editorArgs, { stdio: "ignore" });
|
|
79
|
+
child.once("error", reject);
|
|
80
|
+
child.once("close", (code, signal) => {
|
|
81
|
+
if (code === 0) resolve();
|
|
82
|
+
else if (signal) reject(new Error(`${editor.command} terminated by ${signal}`));
|
|
83
|
+
else reject(new Error(`${editor.command} exited with code ${code ?? "unknown"}`));
|
|
84
|
+
});
|
|
85
|
+
});
|
|
86
|
+
}
|