@gotgenes/pi-subagents 6.12.0 → 6.13.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 +23 -0
- package/docs/architecture/architecture.md +17 -18
- package/docs/plans/0135-extract-display-helpers.md +182 -0
- package/docs/plans/0136-decompose-agent-menu.md +300 -0
- package/docs/retro/0134-reduce-as-any-casts.md +56 -0
- package/docs/retro/0135-extract-display-helpers.md +38 -0
- package/package.json +1 -1
- package/src/index.ts +2 -0
- package/src/renderer.ts +1 -1
- package/src/tools/agent-tool.ts +2 -2
- package/src/tools/background-spawner.ts +1 -1
- package/src/tools/foreground-runner.ts +1 -1
- package/src/tools/get-result-tool.ts +1 -1
- package/src/tools/helpers.ts +1 -1
- package/src/ui/agent-config-editor.ts +202 -0
- package/src/ui/agent-creation-wizard.ts +246 -0
- package/src/ui/agent-file-ops.ts +59 -0
- package/src/ui/agent-menu.ts +22 -394
- package/src/ui/agent-widget.ts +13 -165
- package/src/ui/conversation-viewer.ts +1 -1
- package/src/ui/display.ts +178 -0
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
---
|
|
2
|
+
issue: 135
|
|
3
|
+
issue_title: "Extract display helpers from `agent-widget.ts`"
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Retro: #135 — Extract display helpers from agent-widget.ts
|
|
7
|
+
|
|
8
|
+
## Final Retrospective (2026-05-22T19:00:00Z)
|
|
9
|
+
|
|
10
|
+
### Session summary
|
|
11
|
+
|
|
12
|
+
Extracted 11 helper functions, 3 constants, and 2 types from `agent-widget.ts` into a new `ui/display.ts` module.
|
|
13
|
+
Updated 10 source consumers and 2 test consumers.
|
|
14
|
+
Pure code-motion refactoring — no behavior change, no test-count delta (714 tests throughout).
|
|
15
|
+
|
|
16
|
+
### Observations
|
|
17
|
+
|
|
18
|
+
#### What went well
|
|
19
|
+
|
|
20
|
+
- Plan accurately identified all 10 source and 2 test import sites with no misses — the consumer import table in the plan mapped 1:1 to actual changes.
|
|
21
|
+
- TDD execution was mechanical and smooth: create module → update source imports → rename test file → verify.
|
|
22
|
+
Zero surprises or deviations from the plan.
|
|
23
|
+
|
|
24
|
+
#### What caused friction (agent side)
|
|
25
|
+
|
|
26
|
+
- `rabbit-hole` — During `/ship-issue`, wasted ~6 tool calls investigating whether `pi-subagents-v6.12.0` was at HEAD.
|
|
27
|
+
Ran `git log --oneline HEAD --not --remotes=origin/main` which dumped the entire repo history (50KB truncation), then misread `git describe --tags --abbrev=0` (nearest ancestor tag) as confirming the tag was at HEAD.
|
|
28
|
+
`git tag --points-at HEAD` returned empty, disproving the assumption, but I still spent cycles reasoning about CI release-please behavior.
|
|
29
|
+
Impact: added friction but no rework — the close comment and release-please merge were correct.
|
|
30
|
+
|
|
31
|
+
#### What caused friction (user side)
|
|
32
|
+
|
|
33
|
+
- None observed.
|
|
34
|
+
The issue was unambiguous, the architecture doc prescribed the exact extraction set, and no user intervention was needed during implementation.
|
|
35
|
+
|
|
36
|
+
### Changes made
|
|
37
|
+
|
|
38
|
+
1. Created `packages/pi-subagents/docs/retro/0135-extract-display-helpers.md` (this file).
|
package/package.json
CHANGED
package/src/index.ts
CHANGED
|
@@ -41,6 +41,7 @@ import { createAgentTool } from "./tools/agent-tool.js";
|
|
|
41
41
|
import { createGetResultTool } from "./tools/get-result-tool.js";
|
|
42
42
|
import { getModelLabelFromConfig } from "./tools/helpers.js";
|
|
43
43
|
import { createSteerTool } from "./tools/steer-tool.js";
|
|
44
|
+
import { FsAgentFileOps } from "./ui/agent-file-ops.js";
|
|
44
45
|
import { createAgentsMenuHandler } from "./ui/agent-menu.js";
|
|
45
46
|
import {
|
|
46
47
|
AgentWidget,
|
|
@@ -248,6 +249,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
248
249
|
return getModelLabelFromConfig(cfg.model);
|
|
249
250
|
},
|
|
250
251
|
settings,
|
|
252
|
+
fileOps: new FsAgentFileOps(),
|
|
251
253
|
personalAgentsDir: join(getAgentDir(), 'agents'),
|
|
252
254
|
projectAgentsDir: join(process.cwd(), '.pi', 'agents'),
|
|
253
255
|
});
|
package/src/renderer.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { Text } from "@earendil-works/pi-tui";
|
|
2
2
|
import type { NotificationDetails } from "./notification.js";
|
|
3
|
-
import { formatMs, formatTokens, formatTurns } from "./ui/
|
|
3
|
+
import { formatMs, formatTokens, formatTurns } from "./ui/display.js";
|
|
4
4
|
|
|
5
5
|
/** Narrow theme interface — only the methods the renderer actually calls. */
|
|
6
6
|
interface RendererTheme {
|
package/src/tools/agent-tool.ts
CHANGED
|
@@ -9,6 +9,7 @@ import { resolveInvocationModel } from "../model-resolver.js";
|
|
|
9
9
|
|
|
10
10
|
import type { AgentInvocation, AgentRecord, SubagentType } from "../types.js";
|
|
11
11
|
import { AgentActivityTracker } from "../ui/agent-activity-tracker.js";
|
|
12
|
+
import { type UICtx } from "../ui/agent-widget.js";
|
|
12
13
|
import {
|
|
13
14
|
type AgentDetails,
|
|
14
15
|
buildInvocationTags,
|
|
@@ -17,8 +18,7 @@ import {
|
|
|
17
18
|
getDisplayName,
|
|
18
19
|
getPromptModeLabel,
|
|
19
20
|
SPINNER,
|
|
20
|
-
|
|
21
|
-
} from "../ui/agent-widget.js";
|
|
21
|
+
} from "../ui/display.js";
|
|
22
22
|
import { spawnBackground } from "./background-spawner.js";
|
|
23
23
|
import { runForeground } from "./foreground-runner.js";
|
|
24
24
|
import { buildDetails, buildTypeListText, textResult } from "./helpers.js";
|
|
@@ -2,7 +2,7 @@ import type { Model } from "@earendil-works/pi-ai";
|
|
|
2
2
|
import type { AgentSpawnConfig } from "../agent-manager.js";
|
|
3
3
|
import type { AgentInvocation, AgentRecord, IsolationMode, ThinkingLevel } from "../types.js";
|
|
4
4
|
import { AgentActivityTracker } from "../ui/agent-activity-tracker.js";
|
|
5
|
-
import type { AgentDetails } from "../ui/
|
|
5
|
+
import type { AgentDetails } from "../ui/display.js";
|
|
6
6
|
import { subscribeUIObserver } from "../ui/ui-observer.js";
|
|
7
7
|
import type { AgentActivityAccess } from "./agent-tool.js";
|
|
8
8
|
import { textResult } from "./helpers.js";
|
|
@@ -2,7 +2,7 @@ import type { AgentSession } from "@earendil-works/pi-coding-agent";
|
|
|
2
2
|
import { Type } from "@sinclair/typebox";
|
|
3
3
|
import type { AgentConfigLookup } from "../agent-types.js";
|
|
4
4
|
import type { AgentRecord } from "../types.js";
|
|
5
|
-
import { formatDuration, getDisplayName } from "../ui/
|
|
5
|
+
import { formatDuration, getDisplayName } from "../ui/display.js";
|
|
6
6
|
import { getSessionContextPercent } from "../usage.js";
|
|
7
7
|
import { formatLifetimeTokens, textResult } from "./helpers.js";
|
|
8
8
|
|
package/src/tools/helpers.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import type { AgentConfigLookup } from "../agent-types.js";
|
|
2
2
|
import { AgentActivityTracker } from "../ui/agent-activity-tracker.js";
|
|
3
|
-
import { type AgentDetails, formatTokens } from "../ui/
|
|
3
|
+
import { type AgentDetails, formatTokens } from "../ui/display.js";
|
|
4
4
|
import { getLifetimeTotal, type LifetimeUsage } from "../usage.js";
|
|
5
5
|
|
|
6
6
|
/** Parenthetical status note for completed agent result text. */
|
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* agent-config-editor.ts — Agent detail view with edit/delete/eject/disable/enable transitions.
|
|
3
|
+
*
|
|
4
|
+
* Extracted from agent-menu.ts to give each concern a single responsibility.
|
|
5
|
+
* Receives dependencies via injection — no direct `node:fs` imports.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { join } from "node:path";
|
|
9
|
+
|
|
10
|
+
import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
11
|
+
import type { AgentTypeRegistry } from "../agent-types.js";
|
|
12
|
+
import type { AgentConfig } from "../types.js";
|
|
13
|
+
import type { AgentFileOps } from "./agent-file-ops.js";
|
|
14
|
+
|
|
15
|
+
// ---- Deps interface ----
|
|
16
|
+
|
|
17
|
+
export interface AgentConfigEditorDeps {
|
|
18
|
+
fileOps: AgentFileOps;
|
|
19
|
+
registry: AgentTypeRegistry;
|
|
20
|
+
personalAgentsDir: string;
|
|
21
|
+
projectAgentsDir: string;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
// ---- Factory ----
|
|
25
|
+
|
|
26
|
+
export function createAgentConfigEditor(deps: AgentConfigEditorDeps) {
|
|
27
|
+
function agentDirs(): string[] {
|
|
28
|
+
return [deps.projectAgentsDir, deps.personalAgentsDir];
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
async function showAgentDetail(ctx: ExtensionContext, name: string) {
|
|
32
|
+
if (deps.registry.resolveType(name) == null) {
|
|
33
|
+
ctx.ui.notify(`Agent config not found for "${name}".`, "warning");
|
|
34
|
+
return;
|
|
35
|
+
}
|
|
36
|
+
const cfg = deps.registry.resolveAgentConfig(name);
|
|
37
|
+
|
|
38
|
+
const file = deps.fileOps.findAgentFile(name, agentDirs());
|
|
39
|
+
const isDefault = cfg.isDefault === true;
|
|
40
|
+
const disabled = cfg.enabled === false;
|
|
41
|
+
|
|
42
|
+
let menuOptions: string[];
|
|
43
|
+
if (disabled && file) {
|
|
44
|
+
menuOptions = isDefault
|
|
45
|
+
? ["Enable", "Edit", "Reset to default", "Delete", "Back"]
|
|
46
|
+
: ["Enable", "Edit", "Delete", "Back"];
|
|
47
|
+
} else if (isDefault && !file) {
|
|
48
|
+
menuOptions = ["Eject (export as .md)", "Disable", "Back"];
|
|
49
|
+
} else if (isDefault && file) {
|
|
50
|
+
menuOptions = ["Edit", "Disable", "Reset to default", "Delete", "Back"];
|
|
51
|
+
} else {
|
|
52
|
+
menuOptions = ["Edit", "Disable", "Delete", "Back"];
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const choice = await ctx.ui.select(name, menuOptions);
|
|
56
|
+
if (!choice || choice === "Back") return;
|
|
57
|
+
|
|
58
|
+
if (choice === "Edit" && file) {
|
|
59
|
+
const content = deps.fileOps.read(file);
|
|
60
|
+
if (content !== undefined) {
|
|
61
|
+
const edited = await ctx.ui.editor(`Edit ${name}`, content);
|
|
62
|
+
if (edited !== undefined && edited !== content) {
|
|
63
|
+
deps.fileOps.write(file, edited);
|
|
64
|
+
deps.registry.reload();
|
|
65
|
+
ctx.ui.notify(`Updated ${file}`, "info");
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
} else if (choice === "Delete") {
|
|
69
|
+
if (file) {
|
|
70
|
+
const confirmed = await ctx.ui.confirm(
|
|
71
|
+
"Delete agent",
|
|
72
|
+
`Delete ${name} (${file})?`,
|
|
73
|
+
);
|
|
74
|
+
if (confirmed) {
|
|
75
|
+
deps.fileOps.remove(file);
|
|
76
|
+
deps.registry.reload();
|
|
77
|
+
ctx.ui.notify(`Deleted ${file}`, "info");
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
} else if (choice === "Reset to default" && file) {
|
|
81
|
+
const confirmed = await ctx.ui.confirm(
|
|
82
|
+
"Reset to default",
|
|
83
|
+
`Delete override ${file} and restore embedded default?`,
|
|
84
|
+
);
|
|
85
|
+
if (confirmed) {
|
|
86
|
+
deps.fileOps.remove(file);
|
|
87
|
+
deps.registry.reload();
|
|
88
|
+
ctx.ui.notify(`Restored default ${name}`, "info");
|
|
89
|
+
}
|
|
90
|
+
} else if (choice.startsWith("Eject")) {
|
|
91
|
+
await ejectAgent(ctx, name, cfg);
|
|
92
|
+
} else if (choice === "Disable") {
|
|
93
|
+
await disableAgent(ctx, name);
|
|
94
|
+
} else if (choice === "Enable") {
|
|
95
|
+
await enableAgent(ctx, name);
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
async function ejectAgent(ctx: ExtensionContext, name: string, cfg: AgentConfig) {
|
|
100
|
+
const location = await ctx.ui.select("Choose location", [
|
|
101
|
+
"Project (.pi/agents/)",
|
|
102
|
+
`Personal (${deps.personalAgentsDir})`,
|
|
103
|
+
]);
|
|
104
|
+
if (!location) return;
|
|
105
|
+
|
|
106
|
+
const targetDir = location.startsWith("Project")
|
|
107
|
+
? deps.projectAgentsDir
|
|
108
|
+
: deps.personalAgentsDir;
|
|
109
|
+
|
|
110
|
+
const targetPath = join(targetDir, `${name}.md`);
|
|
111
|
+
if (deps.fileOps.exists(targetPath)) {
|
|
112
|
+
const overwrite = await ctx.ui.confirm(
|
|
113
|
+
"Overwrite",
|
|
114
|
+
`${targetPath} already exists. Overwrite?`,
|
|
115
|
+
);
|
|
116
|
+
if (!overwrite) return;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
const fmFields: string[] = [];
|
|
120
|
+
fmFields.push(`description: ${cfg.description}`);
|
|
121
|
+
if (cfg.displayName) fmFields.push(`display_name: ${cfg.displayName}`);
|
|
122
|
+
fmFields.push(`tools: ${cfg.builtinToolNames?.join(", ") || "all"}`);
|
|
123
|
+
if (cfg.model) fmFields.push(`model: ${cfg.model}`);
|
|
124
|
+
if (cfg.thinking) fmFields.push(`thinking: ${cfg.thinking}`);
|
|
125
|
+
if (cfg.maxTurns) fmFields.push(`max_turns: ${cfg.maxTurns}`);
|
|
126
|
+
fmFields.push(`prompt_mode: ${cfg.promptMode}`);
|
|
127
|
+
if (cfg.extensions === false) fmFields.push("extensions: false");
|
|
128
|
+
else if (Array.isArray(cfg.extensions))
|
|
129
|
+
fmFields.push(`extensions: ${cfg.extensions.join(", ")}`);
|
|
130
|
+
if (cfg.skills === false) fmFields.push("skills: false");
|
|
131
|
+
else if (Array.isArray(cfg.skills))
|
|
132
|
+
fmFields.push(`skills: ${cfg.skills.join(", ")}`);
|
|
133
|
+
if (cfg.disallowedTools?.length)
|
|
134
|
+
fmFields.push(`disallowed_tools: ${cfg.disallowedTools.join(", ")}`);
|
|
135
|
+
if (cfg.inheritContext) fmFields.push("inherit_context: true");
|
|
136
|
+
if (cfg.runInBackground) fmFields.push("run_in_background: true");
|
|
137
|
+
if (cfg.isolated) fmFields.push("isolated: true");
|
|
138
|
+
if (cfg.memory) fmFields.push(`memory: ${cfg.memory}`);
|
|
139
|
+
if (cfg.isolation) fmFields.push(`isolation: ${cfg.isolation}`);
|
|
140
|
+
|
|
141
|
+
const content = `---\n${fmFields.join("\n")}\n---\n\n${cfg.systemPrompt}\n`;
|
|
142
|
+
|
|
143
|
+
deps.fileOps.write(targetPath, content);
|
|
144
|
+
deps.registry.reload();
|
|
145
|
+
ctx.ui.notify(`Ejected ${name} to ${targetPath}`, "info");
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
async function disableAgent(ctx: ExtensionContext, name: string) {
|
|
149
|
+
const file = deps.fileOps.findAgentFile(name, agentDirs());
|
|
150
|
+
if (file) {
|
|
151
|
+
const content = deps.fileOps.read(file);
|
|
152
|
+
if (content?.includes("\nenabled: false\n")) {
|
|
153
|
+
ctx.ui.notify(`${name} is already disabled.`, "info");
|
|
154
|
+
return;
|
|
155
|
+
}
|
|
156
|
+
if (content) {
|
|
157
|
+
const updated = content.replace(/^---\n/, "---\nenabled: false\n");
|
|
158
|
+
deps.fileOps.write(file, updated);
|
|
159
|
+
deps.registry.reload();
|
|
160
|
+
ctx.ui.notify(`Disabled ${name} (${file})`, "info");
|
|
161
|
+
}
|
|
162
|
+
return;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
const location = await ctx.ui.select("Choose location", [
|
|
166
|
+
"Project (.pi/agents/)",
|
|
167
|
+
`Personal (${deps.personalAgentsDir})`,
|
|
168
|
+
]);
|
|
169
|
+
if (!location) return;
|
|
170
|
+
|
|
171
|
+
const targetDir = location.startsWith("Project")
|
|
172
|
+
? deps.projectAgentsDir
|
|
173
|
+
: deps.personalAgentsDir;
|
|
174
|
+
|
|
175
|
+
const targetPath = join(targetDir, `${name}.md`);
|
|
176
|
+
deps.fileOps.write(targetPath, "---\nenabled: false\n---\n");
|
|
177
|
+
deps.registry.reload();
|
|
178
|
+
ctx.ui.notify(`Disabled ${name} (${targetPath})`, "info");
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
async function enableAgent(ctx: ExtensionContext, name: string) {
|
|
182
|
+
const file = deps.fileOps.findAgentFile(name, agentDirs());
|
|
183
|
+
if (!file) return;
|
|
184
|
+
|
|
185
|
+
const content = deps.fileOps.read(file);
|
|
186
|
+
if (!content) return;
|
|
187
|
+
|
|
188
|
+
const updated = content.replace(/^(---\n)enabled: false\n/, "$1");
|
|
189
|
+
|
|
190
|
+
if (updated.trim() === "---\n---" || updated.trim() === "---\n---\n") {
|
|
191
|
+
deps.fileOps.remove(file);
|
|
192
|
+
deps.registry.reload();
|
|
193
|
+
ctx.ui.notify(`Enabled ${name} (removed ${file})`, "info");
|
|
194
|
+
} else {
|
|
195
|
+
deps.fileOps.write(file, updated);
|
|
196
|
+
deps.registry.reload();
|
|
197
|
+
ctx.ui.notify(`Enabled ${name} (${file})`, "info");
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
return { showAgentDetail };
|
|
202
|
+
}
|
|
@@ -0,0 +1,246 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* agent-creation-wizard.ts — AI-generation and manual-form agent creation flows.
|
|
3
|
+
*
|
|
4
|
+
* Extracted from agent-menu.ts to give each concern a single responsibility.
|
|
5
|
+
* Receives dependencies via injection — no direct `node:fs` imports.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { join } from "node:path";
|
|
9
|
+
|
|
10
|
+
import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
11
|
+
import { BUILTIN_TOOL_NAMES } from "../agent-types.js";
|
|
12
|
+
import type { AgentRecord } from "../types.js";
|
|
13
|
+
import type { AgentFileOps } from "./agent-file-ops.js";
|
|
14
|
+
|
|
15
|
+
// ---- Deps interface ----
|
|
16
|
+
|
|
17
|
+
/** Narrow manager interface for agent spawning (generate wizard). */
|
|
18
|
+
export interface WizardManager {
|
|
19
|
+
spawnAndWait: (
|
|
20
|
+
ctx: ExtensionContext,
|
|
21
|
+
type: string,
|
|
22
|
+
prompt: string,
|
|
23
|
+
opts: { description: string; maxTurns: number },
|
|
24
|
+
) => Promise<AgentRecord>;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** Narrow registry interface for reloading after creation. */
|
|
28
|
+
export interface WizardRegistry {
|
|
29
|
+
reload(): void;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export interface AgentCreationWizardDeps {
|
|
33
|
+
fileOps: AgentFileOps;
|
|
34
|
+
manager: WizardManager;
|
|
35
|
+
registry: WizardRegistry;
|
|
36
|
+
personalAgentsDir: string;
|
|
37
|
+
projectAgentsDir: string;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// ---- Factory ----
|
|
41
|
+
|
|
42
|
+
export function createAgentCreationWizard(deps: AgentCreationWizardDeps) {
|
|
43
|
+
async function showCreateWizard(ctx: ExtensionContext) {
|
|
44
|
+
const location = await ctx.ui.select("Choose location", [
|
|
45
|
+
"Project (.pi/agents/)",
|
|
46
|
+
`Personal (${deps.personalAgentsDir})`,
|
|
47
|
+
]);
|
|
48
|
+
if (!location) return;
|
|
49
|
+
|
|
50
|
+
const targetDir = location.startsWith("Project")
|
|
51
|
+
? deps.projectAgentsDir
|
|
52
|
+
: deps.personalAgentsDir;
|
|
53
|
+
|
|
54
|
+
const method = await ctx.ui.select("Creation method", [
|
|
55
|
+
"Generate with Claude (recommended)",
|
|
56
|
+
"Manual configuration",
|
|
57
|
+
]);
|
|
58
|
+
if (!method) return;
|
|
59
|
+
|
|
60
|
+
if (method.startsWith("Generate")) {
|
|
61
|
+
await showGenerateWizard(ctx, targetDir);
|
|
62
|
+
} else {
|
|
63
|
+
await showManualWizard(ctx, targetDir);
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
async function showGenerateWizard(ctx: ExtensionContext, targetDir: string) {
|
|
68
|
+
const description = await ctx.ui.input("Describe what this agent should do");
|
|
69
|
+
if (!description) return;
|
|
70
|
+
|
|
71
|
+
const name = await ctx.ui.input("Agent name (filename, no spaces)");
|
|
72
|
+
if (!name) return;
|
|
73
|
+
|
|
74
|
+
deps.fileOps.ensureDir(targetDir);
|
|
75
|
+
|
|
76
|
+
const targetPath = join(targetDir, `${name}.md`);
|
|
77
|
+
if (deps.fileOps.exists(targetPath)) {
|
|
78
|
+
const overwrite = await ctx.ui.confirm(
|
|
79
|
+
"Overwrite",
|
|
80
|
+
`${targetPath} already exists. Overwrite?`,
|
|
81
|
+
);
|
|
82
|
+
if (!overwrite) return;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
ctx.ui.notify("Generating agent definition...", "info");
|
|
86
|
+
|
|
87
|
+
const generatePrompt = `Create a custom pi sub-agent definition file based on this description: "${description}"
|
|
88
|
+
|
|
89
|
+
Write a markdown file to: ${targetPath}
|
|
90
|
+
|
|
91
|
+
The file format is a markdown file with YAML frontmatter and a system prompt body:
|
|
92
|
+
|
|
93
|
+
\`\`\`markdown
|
|
94
|
+
---
|
|
95
|
+
description: <one-line description shown in UI>
|
|
96
|
+
tools: <comma-separated built-in tools: read, bash, edit, write, grep, find, ls. Use "none" for no tools. Omit for all tools>
|
|
97
|
+
model: <optional model as "provider/modelId", e.g. "anthropic/claude-haiku-4-5-20251001". Omit to inherit parent model>
|
|
98
|
+
thinking: <optional thinking level: off, minimal, low, medium, high, xhigh. Omit to inherit>
|
|
99
|
+
max_turns: <optional max agentic turns. 0 or omit for unlimited (default)>
|
|
100
|
+
prompt_mode: <"replace" (body IS the full system prompt) or "append" (body is appended to default prompt). Default: replace>
|
|
101
|
+
extensions: <true (inherit all MCP/extension tools), false (none), or comma-separated names. Default: true>
|
|
102
|
+
skills: <true (inherit all), false (none), or comma-separated skill names to preload into prompt. Default: true>
|
|
103
|
+
disallowed_tools: <comma-separated tool names to block, even if otherwise available. Omit for none>
|
|
104
|
+
inherit_context: <true to fork parent conversation into agent so it sees chat history. Default: false>
|
|
105
|
+
run_in_background: <true to run in background by default. Default: false>
|
|
106
|
+
isolated: <true for no extension/MCP tools, only built-in tools. Default: false>
|
|
107
|
+
memory: <"user" (global), "project" (per-project), or "local" (gitignored per-project) for persistent memory. Omit for none>
|
|
108
|
+
isolation: <"worktree" to run in isolated git worktree. Omit for normal>
|
|
109
|
+
---
|
|
110
|
+
|
|
111
|
+
<system prompt body — instructions for the agent>
|
|
112
|
+
\`\`\`
|
|
113
|
+
|
|
114
|
+
Guidelines for choosing settings:
|
|
115
|
+
- For read-only tasks (review, analysis): tools: read, bash, grep, find, ls
|
|
116
|
+
- For code modification tasks: include edit, write
|
|
117
|
+
- Use prompt_mode: append if the agent should keep the default system prompt and add specialization on top
|
|
118
|
+
- Use prompt_mode: replace for fully custom agents with their own personality/instructions
|
|
119
|
+
- Set inherit_context: true if the agent needs to know what was discussed in the parent conversation
|
|
120
|
+
- Set isolated: true if the agent should NOT have access to MCP servers or other extensions
|
|
121
|
+
- Only include frontmatter fields that differ from defaults — omit fields where the default is fine
|
|
122
|
+
|
|
123
|
+
Write the file using the write tool. Only write the file, nothing else.`;
|
|
124
|
+
|
|
125
|
+
const record = await deps.manager.spawnAndWait(
|
|
126
|
+
ctx,
|
|
127
|
+
"general-purpose",
|
|
128
|
+
generatePrompt,
|
|
129
|
+
{
|
|
130
|
+
description: `Generate ${name} agent`,
|
|
131
|
+
maxTurns: 5,
|
|
132
|
+
},
|
|
133
|
+
);
|
|
134
|
+
|
|
135
|
+
if (record.status === "error") {
|
|
136
|
+
ctx.ui.notify(`Generation failed: ${record.error}`, "warning");
|
|
137
|
+
return;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
deps.registry.reload();
|
|
141
|
+
|
|
142
|
+
if (deps.fileOps.exists(targetPath)) {
|
|
143
|
+
ctx.ui.notify(`Created ${targetPath}`, "info");
|
|
144
|
+
} else {
|
|
145
|
+
ctx.ui.notify(
|
|
146
|
+
"Agent generation completed but file was not created. Check the agent output.",
|
|
147
|
+
"warning",
|
|
148
|
+
);
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
async function showManualWizard(ctx: ExtensionContext, targetDir: string) {
|
|
153
|
+
const name = await ctx.ui.input("Agent name (filename, no spaces)");
|
|
154
|
+
if (!name) return;
|
|
155
|
+
|
|
156
|
+
const description = await ctx.ui.input("Description (one line)");
|
|
157
|
+
if (!description) return;
|
|
158
|
+
|
|
159
|
+
const toolChoice = await ctx.ui.select("Tools", [
|
|
160
|
+
"all",
|
|
161
|
+
"none",
|
|
162
|
+
"read-only (read, bash, grep, find, ls)",
|
|
163
|
+
"custom...",
|
|
164
|
+
]);
|
|
165
|
+
if (!toolChoice) return;
|
|
166
|
+
|
|
167
|
+
let tools: string;
|
|
168
|
+
if (toolChoice === "all") {
|
|
169
|
+
tools = BUILTIN_TOOL_NAMES.join(", ");
|
|
170
|
+
} else if (toolChoice === "none") {
|
|
171
|
+
tools = "none";
|
|
172
|
+
} else if (toolChoice.startsWith("read-only")) {
|
|
173
|
+
tools = "read, bash, grep, find, ls";
|
|
174
|
+
} else {
|
|
175
|
+
const customTools = await ctx.ui.input(
|
|
176
|
+
"Tools (comma-separated)",
|
|
177
|
+
BUILTIN_TOOL_NAMES.join(", "),
|
|
178
|
+
);
|
|
179
|
+
if (!customTools) return;
|
|
180
|
+
tools = customTools;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
const modelChoice = await ctx.ui.select("Model", [
|
|
184
|
+
"inherit (parent model)",
|
|
185
|
+
"haiku",
|
|
186
|
+
"sonnet",
|
|
187
|
+
"opus",
|
|
188
|
+
"custom...",
|
|
189
|
+
]);
|
|
190
|
+
if (!modelChoice) return;
|
|
191
|
+
|
|
192
|
+
let modelLine = "";
|
|
193
|
+
if (modelChoice === "haiku")
|
|
194
|
+
modelLine = "\nmodel: anthropic/claude-haiku-4-5-20251001";
|
|
195
|
+
else if (modelChoice === "sonnet")
|
|
196
|
+
modelLine = "\nmodel: anthropic/claude-sonnet-4-6";
|
|
197
|
+
else if (modelChoice === "opus")
|
|
198
|
+
modelLine = "\nmodel: anthropic/claude-opus-4-6";
|
|
199
|
+
else if (modelChoice === "custom...") {
|
|
200
|
+
const customModel = await ctx.ui.input("Model (provider/modelId)");
|
|
201
|
+
if (customModel) modelLine = `\nmodel: ${customModel}`;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
const thinkingChoice = await ctx.ui.select("Thinking level", [
|
|
205
|
+
"inherit",
|
|
206
|
+
"off",
|
|
207
|
+
"minimal",
|
|
208
|
+
"low",
|
|
209
|
+
"medium",
|
|
210
|
+
"high",
|
|
211
|
+
"xhigh",
|
|
212
|
+
]);
|
|
213
|
+
if (!thinkingChoice) return;
|
|
214
|
+
|
|
215
|
+
let thinkingLine = "";
|
|
216
|
+
if (thinkingChoice !== "inherit") thinkingLine = `\nthinking: ${thinkingChoice}`;
|
|
217
|
+
|
|
218
|
+
const systemPrompt = await ctx.ui.editor("System prompt", "");
|
|
219
|
+
if (systemPrompt === undefined) return;
|
|
220
|
+
|
|
221
|
+
const content = `---
|
|
222
|
+
description: ${description}
|
|
223
|
+
tools: ${tools}${modelLine}${thinkingLine}
|
|
224
|
+
prompt_mode: replace
|
|
225
|
+
---
|
|
226
|
+
|
|
227
|
+
${systemPrompt}
|
|
228
|
+
`;
|
|
229
|
+
|
|
230
|
+
const targetPath = join(targetDir, `${name}.md`);
|
|
231
|
+
|
|
232
|
+
if (deps.fileOps.exists(targetPath)) {
|
|
233
|
+
const overwrite = await ctx.ui.confirm(
|
|
234
|
+
"Overwrite",
|
|
235
|
+
`${targetPath} already exists. Overwrite?`,
|
|
236
|
+
);
|
|
237
|
+
if (!overwrite) return;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
deps.fileOps.write(targetPath, content);
|
|
241
|
+
deps.registry.reload();
|
|
242
|
+
ctx.ui.notify(`Created ${targetPath}`, "info");
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
return { showCreateWizard };
|
|
246
|
+
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* agent-file-ops.ts — Filesystem abstraction for agent .md file operations.
|
|
3
|
+
*
|
|
4
|
+
* Decouples menu sub-modules from direct `node:fs` imports, making them
|
|
5
|
+
* testable via plain stub objects without `vi.mock()`.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { existsSync, mkdirSync, readFileSync, unlinkSync, writeFileSync } from "node:fs";
|
|
9
|
+
import { dirname, join } from "node:path";
|
|
10
|
+
|
|
11
|
+
// ---- Interface ----
|
|
12
|
+
|
|
13
|
+
/** Filesystem operations for agent `.md` files. */
|
|
14
|
+
export interface AgentFileOps {
|
|
15
|
+
exists(filePath: string): boolean;
|
|
16
|
+
read(filePath: string): string | undefined;
|
|
17
|
+
write(filePath: string, content: string): void;
|
|
18
|
+
remove(filePath: string): void;
|
|
19
|
+
ensureDir(dirPath: string): void;
|
|
20
|
+
findAgentFile(name: string, dirs: string[]): string | undefined;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
// ---- Production implementation ----
|
|
24
|
+
|
|
25
|
+
/** Production implementation wrapping `node:fs` synchronous APIs. */
|
|
26
|
+
export class FsAgentFileOps implements AgentFileOps {
|
|
27
|
+
exists(filePath: string): boolean {
|
|
28
|
+
return existsSync(filePath);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
read(filePath: string): string | undefined {
|
|
32
|
+
try {
|
|
33
|
+
return readFileSync(filePath, "utf-8");
|
|
34
|
+
} catch {
|
|
35
|
+
return undefined;
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
write(filePath: string, content: string): void {
|
|
40
|
+
mkdirSync(dirname(filePath), { recursive: true });
|
|
41
|
+
writeFileSync(filePath, content, "utf-8");
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
remove(filePath: string): void {
|
|
45
|
+
unlinkSync(filePath);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
ensureDir(dirPath: string): void {
|
|
49
|
+
mkdirSync(dirPath, { recursive: true });
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
findAgentFile(name: string, dirs: string[]): string | undefined {
|
|
53
|
+
for (const dir of dirs) {
|
|
54
|
+
const p = join(dir, `${name}.md`);
|
|
55
|
+
if (existsSync(p)) return p;
|
|
56
|
+
}
|
|
57
|
+
return undefined;
|
|
58
|
+
}
|
|
59
|
+
}
|