@gotgenes/pi-subagents 4.0.0 → 4.1.1

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.
@@ -0,0 +1,685 @@
1
+ import { existsSync, mkdirSync, readFileSync, unlinkSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ import {
4
+ getDefaultMaxTurns,
5
+ getGraceTurns,
6
+ setDefaultMaxTurns,
7
+ setGraceTurns,
8
+ } from "../agent-runner.js";
9
+ import {
10
+ BUILTIN_TOOL_NAMES,
11
+ getAgentConfig,
12
+ getAllTypes,
13
+ } from "../agent-types.js";
14
+ import type { AgentConfig, AgentRecord } from "../types.js";
15
+ import type { AgentActivity } from "./agent-widget.js";
16
+ import { formatDuration, getDisplayName } from "./agent-widget.js";
17
+
18
+ // ---- Deps interface ----
19
+
20
+ /** Narrow manager interface for menu operations. */
21
+ export interface AgentMenuManager {
22
+ listAgents: () => AgentRecord[];
23
+ getRecord: (id: string) => AgentRecord | undefined;
24
+ /** Used by generate wizard to spawn an agent that writes the .md file. */
25
+ spawnAndWait: (pi: unknown, ctx: unknown, type: string, prompt: string, opts: object) => Promise<AgentRecord>;
26
+ getMaxConcurrent: () => number;
27
+ setMaxConcurrent: (n: number) => void;
28
+ }
29
+
30
+ export interface AgentMenuDeps {
31
+ manager: AgentMenuManager;
32
+ reloadCustomAgents: () => void;
33
+ agentActivity: Map<string, AgentActivity>;
34
+ /** Resolve model label for a given agent type + registry. */
35
+ getModelLabel: (type: string, registry?: unknown) => string;
36
+ /** Snapshot current settings for persistence. */
37
+ snapshotSettings: () => { maxConcurrent: number; defaultMaxTurns: number; graceTurns: number };
38
+ /** Save settings and return a notification result. */
39
+ saveSettings: (
40
+ settings: { maxConcurrent: number; defaultMaxTurns: number; graceTurns: number },
41
+ successMsg: string,
42
+ ) => { message: string; level: string };
43
+ emitEvent: (name: string, data: unknown) => void;
44
+ personalAgentsDir: string;
45
+ }
46
+
47
+ // ---- Narrow UI context types ----
48
+
49
+ interface MenuUI {
50
+ select: (title: string, options: string[]) => Promise<string | undefined>;
51
+ input: (prompt: string, defaultValue?: string) => Promise<string | undefined>;
52
+ confirm: (title: string, message: string) => Promise<boolean>;
53
+ editor: (title: string, content: string) => Promise<string | undefined>;
54
+ notify: (message: string, level: string) => void;
55
+ custom: <T>(factory: any, options: any) => Promise<T>;
56
+ }
57
+
58
+ interface MenuContext {
59
+ ui: MenuUI;
60
+ modelRegistry?: unknown;
61
+ }
62
+
63
+ // ---- Factory ----
64
+
65
+ /**
66
+ * Create the `/agents` command handler.
67
+ * Returns a function suitable for `pi.registerCommand("agents", { handler })`.
68
+ */
69
+ export function createAgentsMenuHandler(deps: AgentMenuDeps) {
70
+ const projectAgentsDir = () => join(process.cwd(), ".pi", "agents");
71
+
72
+ function findAgentFile(
73
+ name: string,
74
+ ): { path: string; location: "project" | "personal" } | undefined {
75
+ const projectPath = join(projectAgentsDir(), `${name}.md`);
76
+ if (existsSync(projectPath)) return { path: projectPath, location: "project" };
77
+ const personalPath = join(deps.personalAgentsDir, `${name}.md`);
78
+ if (existsSync(personalPath)) return { path: personalPath, location: "personal" };
79
+ return undefined;
80
+ }
81
+
82
+ async function showAgentsMenu(ctx: MenuContext) {
83
+ deps.reloadCustomAgents();
84
+ const allNames = getAllTypes();
85
+
86
+ const options: string[] = [];
87
+
88
+ const agents = deps.manager.listAgents();
89
+ if (agents.length > 0) {
90
+ const running = agents.filter(
91
+ (a) => a.status === "running" || a.status === "queued",
92
+ ).length;
93
+ const done = agents.filter(
94
+ (a) => a.status === "completed" || a.status === "steered",
95
+ ).length;
96
+ options.push(
97
+ `Running agents (${agents.length}) — ${running} running, ${done} done`,
98
+ );
99
+ }
100
+
101
+ if (allNames.length > 0) {
102
+ options.push(`Agent types (${allNames.length})`);
103
+ }
104
+
105
+ options.push("Create new agent");
106
+ options.push("Settings");
107
+
108
+ const noAgentsMsg =
109
+ allNames.length === 0 && agents.length === 0
110
+ ? "No agents found. Create specialized subagents that can be delegated to.\n\n" +
111
+ "Each subagent has its own context window, custom system prompt, and specific tools.\n\n" +
112
+ "Try creating: Code Reviewer, Security Auditor, Test Writer, or Documentation Writer.\n\n"
113
+ : "";
114
+
115
+ if (noAgentsMsg) {
116
+ ctx.ui.notify(noAgentsMsg, "info");
117
+ }
118
+
119
+ const choice = await ctx.ui.select("Agents", options);
120
+ if (!choice) return;
121
+
122
+ if (choice.startsWith("Running agents (")) {
123
+ await showRunningAgents(ctx);
124
+ await showAgentsMenu(ctx);
125
+ } else if (choice.startsWith("Agent types (")) {
126
+ await showAllAgentsList(ctx);
127
+ await showAgentsMenu(ctx);
128
+ } else if (choice === "Create new agent") {
129
+ await showCreateWizard(ctx);
130
+ } else if (choice === "Settings") {
131
+ await showSettings(ctx);
132
+ await showAgentsMenu(ctx);
133
+ }
134
+ }
135
+
136
+ async function showAllAgentsList(ctx: MenuContext) {
137
+ const allNames = getAllTypes();
138
+ if (allNames.length === 0) {
139
+ ctx.ui.notify("No agents.", "info");
140
+ return;
141
+ }
142
+
143
+ const sourceIndicator = (cfg: AgentConfig | undefined) => {
144
+ const disabled = cfg?.enabled === false;
145
+ if (cfg?.source === "project") return disabled ? "✕• " : "• ";
146
+ if (cfg?.source === "global") return disabled ? "✕◦ " : "◦ ";
147
+ if (disabled) return "✕ ";
148
+ return " ";
149
+ };
150
+
151
+ const entries = allNames.map((name) => {
152
+ const cfg = getAgentConfig(name);
153
+ const disabled = cfg?.enabled === false;
154
+ const model = deps.getModelLabel(name, ctx.modelRegistry);
155
+ const indicator = sourceIndicator(cfg);
156
+ const prefix = `${indicator}${name} · ${model}`;
157
+ const desc = disabled ? "(disabled)" : (cfg?.description ?? name);
158
+ return { name, prefix, desc };
159
+ });
160
+ const maxPrefix = Math.max(...entries.map((e) => e.prefix.length));
161
+
162
+ const hasCustom = allNames.some((n) => {
163
+ const c = getAgentConfig(n);
164
+ return c && !c.isDefault && c.enabled !== false;
165
+ });
166
+ const hasDisabled = allNames.some((n) => getAgentConfig(n)?.enabled === false);
167
+ const legendParts: string[] = [];
168
+ if (hasCustom) legendParts.push("• = project ◦ = global");
169
+ if (hasDisabled) legendParts.push("✕ = disabled");
170
+ const legend = legendParts.length ? "\n" + legendParts.join(" ") : "";
171
+
172
+ const options = entries.map(
173
+ ({ prefix, desc }) => `${prefix.padEnd(maxPrefix)} — ${desc}`,
174
+ );
175
+ if (legend) options.push(legend);
176
+
177
+ const choice = await ctx.ui.select("Agent types", options);
178
+ if (!choice) return;
179
+
180
+ const agentName = choice
181
+ .split(" · ")[0]
182
+ .replace(/^[•◦✕\s]+/, "")
183
+ .trim();
184
+ if (getAgentConfig(agentName)) {
185
+ await showAgentDetail(ctx, agentName);
186
+ await showAllAgentsList(ctx);
187
+ }
188
+ }
189
+
190
+ async function showRunningAgents(ctx: MenuContext) {
191
+ const agents = deps.manager.listAgents();
192
+ if (agents.length === 0) {
193
+ ctx.ui.notify("No agents.", "info");
194
+ return;
195
+ }
196
+
197
+ const options = agents.map((a) => {
198
+ const dn = getDisplayName(a.type);
199
+ const dur = formatDuration(a.startedAt, a.completedAt);
200
+ return `${dn} (${a.description}) · ${a.toolUses} tools · ${a.status} · ${dur}`;
201
+ });
202
+
203
+ const choice = await ctx.ui.select("Running agents", options);
204
+ if (!choice) return;
205
+
206
+ const idx = options.indexOf(choice);
207
+ if (idx < 0) return;
208
+ const record = agents[idx];
209
+
210
+ await viewAgentConversation(ctx, record);
211
+ await showRunningAgents(ctx);
212
+ }
213
+
214
+ async function viewAgentConversation(ctx: MenuContext, record: AgentRecord) {
215
+ if (!record.session) {
216
+ ctx.ui.notify(
217
+ `Agent is ${record.status === "queued" ? "queued" : "expired"} — no session available.`,
218
+ "info",
219
+ );
220
+ return;
221
+ }
222
+
223
+ const { ConversationViewer, VIEWPORT_HEIGHT_PCT } = await import(
224
+ "./conversation-viewer.js"
225
+ );
226
+ const session = record.session;
227
+ const activity = deps.agentActivity.get(record.id);
228
+
229
+ await ctx.ui.custom<undefined>(
230
+ (tui: any, theme: any, _keybindings: any, done: any) => {
231
+ return new ConversationViewer(tui, session, record, activity, theme, done);
232
+ },
233
+ {
234
+ overlay: true,
235
+ overlayOptions: {
236
+ anchor: "center",
237
+ width: "90%",
238
+ maxHeight: `${VIEWPORT_HEIGHT_PCT}%`,
239
+ },
240
+ },
241
+ );
242
+ }
243
+
244
+ async function showAgentDetail(ctx: MenuContext, name: string) {
245
+ const cfg = getAgentConfig(name);
246
+ if (!cfg) {
247
+ ctx.ui.notify(`Agent config not found for "${name}".`, "warning");
248
+ return;
249
+ }
250
+
251
+ const file = findAgentFile(name);
252
+ const isDefault = cfg.isDefault === true;
253
+ const disabled = cfg.enabled === false;
254
+
255
+ let menuOptions: string[];
256
+ if (disabled && file) {
257
+ menuOptions = isDefault
258
+ ? ["Enable", "Edit", "Reset to default", "Delete", "Back"]
259
+ : ["Enable", "Edit", "Delete", "Back"];
260
+ } else if (isDefault && !file) {
261
+ menuOptions = ["Eject (export as .md)", "Disable", "Back"];
262
+ } else if (isDefault && file) {
263
+ menuOptions = ["Edit", "Disable", "Reset to default", "Delete", "Back"];
264
+ } else {
265
+ menuOptions = ["Edit", "Disable", "Delete", "Back"];
266
+ }
267
+
268
+ const choice = await ctx.ui.select(name, menuOptions);
269
+ if (!choice || choice === "Back") return;
270
+
271
+ if (choice === "Edit" && file) {
272
+ const content = readFileSync(file.path, "utf-8");
273
+ const edited = await ctx.ui.editor(`Edit ${name}`, content);
274
+ if (edited !== undefined && edited !== content) {
275
+ const { writeFileSync } = await import("node:fs");
276
+ writeFileSync(file.path, edited, "utf-8");
277
+ deps.reloadCustomAgents();
278
+ ctx.ui.notify(`Updated ${file.path}`, "info");
279
+ }
280
+ } else if (choice === "Delete") {
281
+ if (file) {
282
+ const confirmed = await ctx.ui.confirm(
283
+ "Delete agent",
284
+ `Delete ${name} from ${file.location} (${file.path})?`,
285
+ );
286
+ if (confirmed) {
287
+ unlinkSync(file.path);
288
+ deps.reloadCustomAgents();
289
+ ctx.ui.notify(`Deleted ${file.path}`, "info");
290
+ }
291
+ }
292
+ } else if (choice === "Reset to default" && file) {
293
+ const confirmed = await ctx.ui.confirm(
294
+ "Reset to default",
295
+ `Delete override ${file.path} and restore embedded default?`,
296
+ );
297
+ if (confirmed) {
298
+ unlinkSync(file.path);
299
+ deps.reloadCustomAgents();
300
+ ctx.ui.notify(`Restored default ${name}`, "info");
301
+ }
302
+ } else if (choice.startsWith("Eject")) {
303
+ await ejectAgent(ctx, name, cfg);
304
+ } else if (choice === "Disable") {
305
+ await disableAgent(ctx, name);
306
+ } else if (choice === "Enable") {
307
+ await enableAgent(ctx, name);
308
+ }
309
+ }
310
+
311
+ async function ejectAgent(ctx: MenuContext, name: string, cfg: AgentConfig) {
312
+ const location = await ctx.ui.select("Choose location", [
313
+ "Project (.pi/agents/)",
314
+ `Personal (${deps.personalAgentsDir})`,
315
+ ]);
316
+ if (!location) return;
317
+
318
+ const targetDir = location.startsWith("Project")
319
+ ? projectAgentsDir()
320
+ : deps.personalAgentsDir;
321
+ mkdirSync(targetDir, { recursive: true });
322
+
323
+ const targetPath = join(targetDir, `${name}.md`);
324
+ if (existsSync(targetPath)) {
325
+ const overwrite = await ctx.ui.confirm(
326
+ "Overwrite",
327
+ `${targetPath} already exists. Overwrite?`,
328
+ );
329
+ if (!overwrite) return;
330
+ }
331
+
332
+ const fmFields: string[] = [];
333
+ fmFields.push(`description: ${cfg.description}`);
334
+ if (cfg.displayName) fmFields.push(`display_name: ${cfg.displayName}`);
335
+ fmFields.push(`tools: ${cfg.builtinToolNames?.join(", ") || "all"}`);
336
+ if (cfg.model) fmFields.push(`model: ${cfg.model}`);
337
+ if (cfg.thinking) fmFields.push(`thinking: ${cfg.thinking}`);
338
+ if (cfg.maxTurns) fmFields.push(`max_turns: ${cfg.maxTurns}`);
339
+ fmFields.push(`prompt_mode: ${cfg.promptMode}`);
340
+ if (cfg.extensions === false) fmFields.push("extensions: false");
341
+ else if (Array.isArray(cfg.extensions))
342
+ fmFields.push(`extensions: ${cfg.extensions.join(", ")}`);
343
+ if (cfg.skills === false) fmFields.push("skills: false");
344
+ else if (Array.isArray(cfg.skills))
345
+ fmFields.push(`skills: ${cfg.skills.join(", ")}`);
346
+ if (cfg.disallowedTools?.length)
347
+ fmFields.push(`disallowed_tools: ${cfg.disallowedTools.join(", ")}`);
348
+ if (cfg.inheritContext) fmFields.push("inherit_context: true");
349
+ if (cfg.runInBackground) fmFields.push("run_in_background: true");
350
+ if (cfg.isolated) fmFields.push("isolated: true");
351
+ if (cfg.memory) fmFields.push(`memory: ${cfg.memory}`);
352
+ if (cfg.isolation) fmFields.push(`isolation: ${cfg.isolation}`);
353
+
354
+ const content = `---\n${fmFields.join("\n")}\n---\n\n${cfg.systemPrompt}\n`;
355
+
356
+ const { writeFileSync } = await import("node:fs");
357
+ writeFileSync(targetPath, content, "utf-8");
358
+ deps.reloadCustomAgents();
359
+ ctx.ui.notify(`Ejected ${name} to ${targetPath}`, "info");
360
+ }
361
+
362
+ async function disableAgent(ctx: MenuContext, name: string) {
363
+ const file = findAgentFile(name);
364
+ if (file) {
365
+ const content = readFileSync(file.path, "utf-8");
366
+ if (content.includes("\nenabled: false\n")) {
367
+ ctx.ui.notify(`${name} is already disabled.`, "info");
368
+ return;
369
+ }
370
+ const updated = content.replace(/^---\n/, "---\nenabled: false\n");
371
+ const { writeFileSync } = await import("node:fs");
372
+ writeFileSync(file.path, updated, "utf-8");
373
+ deps.reloadCustomAgents();
374
+ ctx.ui.notify(`Disabled ${name} (${file.path})`, "info");
375
+ return;
376
+ }
377
+
378
+ const location = await ctx.ui.select("Choose location", [
379
+ "Project (.pi/agents/)",
380
+ `Personal (${deps.personalAgentsDir})`,
381
+ ]);
382
+ if (!location) return;
383
+
384
+ const targetDir = location.startsWith("Project")
385
+ ? projectAgentsDir()
386
+ : deps.personalAgentsDir;
387
+ mkdirSync(targetDir, { recursive: true });
388
+
389
+ const targetPath = join(targetDir, `${name}.md`);
390
+ const { writeFileSync } = await import("node:fs");
391
+ writeFileSync(targetPath, "---\nenabled: false\n---\n", "utf-8");
392
+ deps.reloadCustomAgents();
393
+ ctx.ui.notify(`Disabled ${name} (${targetPath})`, "info");
394
+ }
395
+
396
+ async function enableAgent(ctx: MenuContext, name: string) {
397
+ const file = findAgentFile(name);
398
+ if (!file) return;
399
+
400
+ const content = readFileSync(file.path, "utf-8");
401
+ const updated = content.replace(/^(---\n)enabled: false\n/, "$1");
402
+ const { writeFileSync } = await import("node:fs");
403
+
404
+ if (updated.trim() === "---\n---" || updated.trim() === "---\n---\n") {
405
+ unlinkSync(file.path);
406
+ deps.reloadCustomAgents();
407
+ ctx.ui.notify(`Enabled ${name} (removed ${file.path})`, "info");
408
+ } else {
409
+ writeFileSync(file.path, updated, "utf-8");
410
+ deps.reloadCustomAgents();
411
+ ctx.ui.notify(`Enabled ${name} (${file.path})`, "info");
412
+ }
413
+ }
414
+
415
+ async function showCreateWizard(ctx: MenuContext) {
416
+ const location = await ctx.ui.select("Choose location", [
417
+ "Project (.pi/agents/)",
418
+ `Personal (${deps.personalAgentsDir})`,
419
+ ]);
420
+ if (!location) return;
421
+
422
+ const targetDir = location.startsWith("Project")
423
+ ? projectAgentsDir()
424
+ : deps.personalAgentsDir;
425
+
426
+ const method = await ctx.ui.select("Creation method", [
427
+ "Generate with Claude (recommended)",
428
+ "Manual configuration",
429
+ ]);
430
+ if (!method) return;
431
+
432
+ if (method.startsWith("Generate")) {
433
+ await showGenerateWizard(ctx, targetDir);
434
+ } else {
435
+ await showManualWizard(ctx, targetDir);
436
+ }
437
+ }
438
+
439
+ async function showGenerateWizard(ctx: MenuContext, targetDir: string) {
440
+ const description = await ctx.ui.input("Describe what this agent should do");
441
+ if (!description) return;
442
+
443
+ const name = await ctx.ui.input("Agent name (filename, no spaces)");
444
+ if (!name) return;
445
+
446
+ mkdirSync(targetDir, { recursive: true });
447
+
448
+ const targetPath = join(targetDir, `${name}.md`);
449
+ if (existsSync(targetPath)) {
450
+ const overwrite = await ctx.ui.confirm(
451
+ "Overwrite",
452
+ `${targetPath} already exists. Overwrite?`,
453
+ );
454
+ if (!overwrite) return;
455
+ }
456
+
457
+ ctx.ui.notify("Generating agent definition...", "info");
458
+
459
+ const generatePrompt = `Create a custom pi sub-agent definition file based on this description: "${description}"
460
+
461
+ Write a markdown file to: ${targetPath}
462
+
463
+ The file format is a markdown file with YAML frontmatter and a system prompt body:
464
+
465
+ \`\`\`markdown
466
+ ---
467
+ description: <one-line description shown in UI>
468
+ tools: <comma-separated built-in tools: read, bash, edit, write, grep, find, ls. Use "none" for no tools. Omit for all tools>
469
+ model: <optional model as "provider/modelId", e.g. "anthropic/claude-haiku-4-5-20251001". Omit to inherit parent model>
470
+ thinking: <optional thinking level: off, minimal, low, medium, high, xhigh. Omit to inherit>
471
+ max_turns: <optional max agentic turns. 0 or omit for unlimited (default)>
472
+ prompt_mode: <"replace" (body IS the full system prompt) or "append" (body is appended to default prompt). Default: replace>
473
+ extensions: <true (inherit all MCP/extension tools), false (none), or comma-separated names. Default: true>
474
+ skills: <true (inherit all), false (none), or comma-separated skill names to preload into prompt. Default: true>
475
+ disallowed_tools: <comma-separated tool names to block, even if otherwise available. Omit for none>
476
+ inherit_context: <true to fork parent conversation into agent so it sees chat history. Default: false>
477
+ run_in_background: <true to run in background by default. Default: false>
478
+ isolated: <true for no extension/MCP tools, only built-in tools. Default: false>
479
+ memory: <"user" (global), "project" (per-project), or "local" (gitignored per-project) for persistent memory. Omit for none>
480
+ isolation: <"worktree" to run in isolated git worktree. Omit for normal>
481
+ ---
482
+
483
+ <system prompt body — instructions for the agent>
484
+ \`\`\`
485
+
486
+ Guidelines for choosing settings:
487
+ - For read-only tasks (review, analysis): tools: read, bash, grep, find, ls
488
+ - For code modification tasks: include edit, write
489
+ - Use prompt_mode: append if the agent should keep the default system prompt and add specialization on top
490
+ - Use prompt_mode: replace for fully custom agents with their own personality/instructions
491
+ - Set inherit_context: true if the agent needs to know what was discussed in the parent conversation
492
+ - Set isolated: true if the agent should NOT have access to MCP servers or other extensions
493
+ - Only include frontmatter fields that differ from defaults — omit fields where the default is fine
494
+
495
+ Write the file using the write tool. Only write the file, nothing else.`;
496
+
497
+ const record = await deps.manager.spawnAndWait(
498
+ null,
499
+ ctx,
500
+ "general-purpose",
501
+ generatePrompt,
502
+ {
503
+ description: `Generate ${name} agent`,
504
+ maxTurns: 5,
505
+ },
506
+ );
507
+
508
+ if (record.status === "error") {
509
+ ctx.ui.notify(`Generation failed: ${record.error}`, "warning");
510
+ return;
511
+ }
512
+
513
+ deps.reloadCustomAgents();
514
+
515
+ if (existsSync(targetPath)) {
516
+ ctx.ui.notify(`Created ${targetPath}`, "info");
517
+ } else {
518
+ ctx.ui.notify(
519
+ "Agent generation completed but file was not created. Check the agent output.",
520
+ "warning",
521
+ );
522
+ }
523
+ }
524
+
525
+ async function showManualWizard(ctx: MenuContext, targetDir: string) {
526
+ const name = await ctx.ui.input("Agent name (filename, no spaces)");
527
+ if (!name) return;
528
+
529
+ const description = await ctx.ui.input("Description (one line)");
530
+ if (!description) return;
531
+
532
+ const toolChoice = await ctx.ui.select("Tools", [
533
+ "all",
534
+ "none",
535
+ "read-only (read, bash, grep, find, ls)",
536
+ "custom...",
537
+ ]);
538
+ if (!toolChoice) return;
539
+
540
+ let tools: string;
541
+ if (toolChoice === "all") {
542
+ tools = BUILTIN_TOOL_NAMES.join(", ");
543
+ } else if (toolChoice === "none") {
544
+ tools = "none";
545
+ } else if (toolChoice.startsWith("read-only")) {
546
+ tools = "read, bash, grep, find, ls";
547
+ } else {
548
+ const customTools = await ctx.ui.input(
549
+ "Tools (comma-separated)",
550
+ BUILTIN_TOOL_NAMES.join(", "),
551
+ );
552
+ if (!customTools) return;
553
+ tools = customTools;
554
+ }
555
+
556
+ const modelChoice = await ctx.ui.select("Model", [
557
+ "inherit (parent model)",
558
+ "haiku",
559
+ "sonnet",
560
+ "opus",
561
+ "custom...",
562
+ ]);
563
+ if (!modelChoice) return;
564
+
565
+ let modelLine = "";
566
+ if (modelChoice === "haiku")
567
+ modelLine = "\nmodel: anthropic/claude-haiku-4-5-20251001";
568
+ else if (modelChoice === "sonnet")
569
+ modelLine = "\nmodel: anthropic/claude-sonnet-4-6";
570
+ else if (modelChoice === "opus")
571
+ modelLine = "\nmodel: anthropic/claude-opus-4-6";
572
+ else if (modelChoice === "custom...") {
573
+ const customModel = await ctx.ui.input("Model (provider/modelId)");
574
+ if (customModel) modelLine = `\nmodel: ${customModel}`;
575
+ }
576
+
577
+ const thinkingChoice = await ctx.ui.select("Thinking level", [
578
+ "inherit",
579
+ "off",
580
+ "minimal",
581
+ "low",
582
+ "medium",
583
+ "high",
584
+ "xhigh",
585
+ ]);
586
+ if (!thinkingChoice) return;
587
+
588
+ let thinkingLine = "";
589
+ if (thinkingChoice !== "inherit") thinkingLine = `\nthinking: ${thinkingChoice}`;
590
+
591
+ const systemPrompt = await ctx.ui.editor("System prompt", "");
592
+ if (systemPrompt === undefined) return;
593
+
594
+ const content = `---
595
+ description: ${description}
596
+ tools: ${tools}${modelLine}${thinkingLine}
597
+ prompt_mode: replace
598
+ ---
599
+
600
+ ${systemPrompt}
601
+ `;
602
+
603
+ mkdirSync(targetDir, { recursive: true });
604
+ const targetPath = join(targetDir, `${name}.md`);
605
+
606
+ if (existsSync(targetPath)) {
607
+ const overwrite = await ctx.ui.confirm(
608
+ "Overwrite",
609
+ `${targetPath} already exists. Overwrite?`,
610
+ );
611
+ if (!overwrite) return;
612
+ }
613
+
614
+ const { writeFileSync } = await import("node:fs");
615
+ writeFileSync(targetPath, content, "utf-8");
616
+ deps.reloadCustomAgents();
617
+ ctx.ui.notify(`Created ${targetPath}`, "info");
618
+ }
619
+
620
+ async function showSettings(ctx: MenuContext) {
621
+ const choice = await ctx.ui.select("Settings", [
622
+ `Max concurrency (current: ${deps.manager.getMaxConcurrent()})`,
623
+ `Default max turns (current: ${getDefaultMaxTurns() ?? "unlimited"})`,
624
+ `Grace turns (current: ${getGraceTurns()})`,
625
+ ]);
626
+ if (!choice) return;
627
+
628
+ if (choice.startsWith("Max concurrency")) {
629
+ const val = await ctx.ui.input(
630
+ "Max concurrent background agents",
631
+ String(deps.manager.getMaxConcurrent()),
632
+ );
633
+ if (val) {
634
+ const n = parseInt(val, 10);
635
+ if (n >= 1) {
636
+ deps.manager.setMaxConcurrent(n);
637
+ notifyApplied(ctx, `Max concurrency set to ${n}`);
638
+ } else {
639
+ ctx.ui.notify("Must be a positive integer.", "warning");
640
+ }
641
+ }
642
+ } else if (choice.startsWith("Default max turns")) {
643
+ const val = await ctx.ui.input(
644
+ "Default max turns before wrap-up (0 = unlimited)",
645
+ String(getDefaultMaxTurns() ?? 0),
646
+ );
647
+ if (val) {
648
+ const n = parseInt(val, 10);
649
+ if (n === 0) {
650
+ setDefaultMaxTurns(undefined);
651
+ notifyApplied(ctx, "Default max turns set to unlimited");
652
+ } else if (n >= 1) {
653
+ setDefaultMaxTurns(n);
654
+ notifyApplied(ctx, `Default max turns set to ${n}`);
655
+ } else {
656
+ ctx.ui.notify("Must be 0 (unlimited) or a positive integer.", "warning");
657
+ }
658
+ }
659
+ } else if (choice.startsWith("Grace turns")) {
660
+ const val = await ctx.ui.input(
661
+ "Grace turns after wrap-up steer",
662
+ String(getGraceTurns()),
663
+ );
664
+ if (val) {
665
+ const n = parseInt(val, 10);
666
+ if (n >= 1) {
667
+ setGraceTurns(n);
668
+ notifyApplied(ctx, `Grace turns set to ${n}`);
669
+ } else {
670
+ ctx.ui.notify("Must be a positive integer.", "warning");
671
+ }
672
+ }
673
+ }
674
+ }
675
+
676
+ function notifyApplied(ctx: MenuContext, successMsg: string) {
677
+ const { message, level } = deps.saveSettings(deps.snapshotSettings(), successMsg);
678
+ ctx.ui.notify(message, level);
679
+ }
680
+
681
+ // Return the handler function
682
+ return async (ctx: MenuContext) => {
683
+ await showAgentsMenu(ctx);
684
+ };
685
+ }