@tintinweb/pi-subagents 0.5.2 → 0.6.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,125 @@
1
+ // Persistence for pi-subagents operational settings.
2
+ // - Global: ~/.pi/agent/subagents.json (via getAgentDir()) — manual defaults, never written here
3
+ // - Project: <cwd>/.pi/subagents.json — written by /agents → Settings; overrides global on load
4
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
5
+ import { dirname, join } from "node:path";
6
+ import { getAgentDir } from "@mariozechner/pi-coding-agent";
7
+ const VALID_JOIN_MODES = new Set(["async", "group", "smart"]);
8
+ // Sanity ceilings — prevent hand-edited configs from asking for values that
9
+ // make no operational sense (e.g. 1e6 concurrent subagents). Permissive enough
10
+ // that any realistic power-user setting passes through.
11
+ const MAX_CONCURRENT_CEILING = 1024;
12
+ const MAX_TURNS_CEILING = 10_000;
13
+ const GRACE_TURNS_CEILING = 1_000;
14
+ /** Drop fields that don't match the expected shape. Silent — garbage becomes absent. */
15
+ function sanitize(raw) {
16
+ if (!raw || typeof raw !== "object")
17
+ return {};
18
+ const r = raw;
19
+ const out = {};
20
+ if (Number.isInteger(r.maxConcurrent) &&
21
+ r.maxConcurrent >= 1 &&
22
+ r.maxConcurrent <= MAX_CONCURRENT_CEILING) {
23
+ out.maxConcurrent = r.maxConcurrent;
24
+ }
25
+ if (Number.isInteger(r.defaultMaxTurns) &&
26
+ r.defaultMaxTurns >= 0 &&
27
+ r.defaultMaxTurns <= MAX_TURNS_CEILING) {
28
+ out.defaultMaxTurns = r.defaultMaxTurns;
29
+ }
30
+ if (Number.isInteger(r.graceTurns) &&
31
+ r.graceTurns >= 1 &&
32
+ r.graceTurns <= GRACE_TURNS_CEILING) {
33
+ out.graceTurns = r.graceTurns;
34
+ }
35
+ if (typeof r.defaultJoinMode === "string" && VALID_JOIN_MODES.has(r.defaultJoinMode)) {
36
+ out.defaultJoinMode = r.defaultJoinMode;
37
+ }
38
+ return out;
39
+ }
40
+ function globalPath() {
41
+ return join(getAgentDir(), "subagents.json");
42
+ }
43
+ function projectPath(cwd) {
44
+ return join(cwd, ".pi", "subagents.json");
45
+ }
46
+ /**
47
+ * Read a settings file. Missing file is silent (returns `{}`). A file that
48
+ * exists but can't be parsed emits a warning to stderr so users aren't
49
+ * silently reverted to defaults — and still returns `{}` so startup proceeds.
50
+ */
51
+ function readSettingsFile(path) {
52
+ if (!existsSync(path))
53
+ return {};
54
+ try {
55
+ return sanitize(JSON.parse(readFileSync(path, "utf-8")));
56
+ }
57
+ catch (err) {
58
+ const reason = err instanceof Error ? err.message : String(err);
59
+ console.warn(`[pi-subagents] Ignoring malformed settings at ${path}: ${reason}`);
60
+ return {};
61
+ }
62
+ }
63
+ /** Load merged settings: global provides defaults, project overrides. */
64
+ export function loadSettings(cwd = process.cwd()) {
65
+ return { ...readSettingsFile(globalPath()), ...readSettingsFile(projectPath(cwd)) };
66
+ }
67
+ /**
68
+ * Write project-local settings. Global is never touched from code.
69
+ * Returns `true` on success, `false` if the write (or mkdir) failed so the
70
+ * caller can surface a warning — persistence isn't fatal but isn't silent.
71
+ */
72
+ export function saveSettings(s, cwd = process.cwd()) {
73
+ const path = projectPath(cwd);
74
+ try {
75
+ mkdirSync(dirname(path), { recursive: true });
76
+ writeFileSync(path, JSON.stringify(s, null, 2), "utf-8");
77
+ return true;
78
+ }
79
+ catch {
80
+ return false;
81
+ }
82
+ }
83
+ /** Apply persisted settings to the in-memory state via caller-supplied setters. */
84
+ export function applySettings(s, appliers) {
85
+ if (typeof s.maxConcurrent === "number")
86
+ appliers.setMaxConcurrent(s.maxConcurrent);
87
+ if (typeof s.defaultMaxTurns === "number")
88
+ appliers.setDefaultMaxTurns(s.defaultMaxTurns);
89
+ if (typeof s.graceTurns === "number")
90
+ appliers.setGraceTurns(s.graceTurns);
91
+ if (s.defaultJoinMode)
92
+ appliers.setDefaultJoinMode(s.defaultJoinMode);
93
+ }
94
+ /**
95
+ * Format the user-facing toast for a settings mutation. Pure function —
96
+ * routes the success/failure of `saveSettings` into the right message + level
97
+ * so the UI layer (index.ts) stays a thin wire between input and notification.
98
+ */
99
+ export function persistToastFor(successMsg, persisted) {
100
+ return persisted
101
+ ? { message: successMsg, level: "info" }
102
+ : { message: `${successMsg} (session only; failed to persist)`, level: "warning" };
103
+ }
104
+ /**
105
+ * Load merged settings, apply them to in-memory state, and emit the
106
+ * `subagents:settings_loaded` lifecycle event. Returns the loaded settings so
107
+ * callers can log/inspect. Extension init wires this once.
108
+ */
109
+ export function applyAndEmitLoaded(appliers, emit, cwd = process.cwd()) {
110
+ const settings = loadSettings(cwd);
111
+ applySettings(settings, appliers);
112
+ emit("subagents:settings_loaded", { settings });
113
+ return settings;
114
+ }
115
+ /**
116
+ * Persist a settings snapshot, emit the `subagents:settings_changed` event
117
+ * (regardless of persist outcome so listeners see the in-memory change), and
118
+ * return the toast the UI should display. Event payload carries the `persisted`
119
+ * flag so listeners can react to write failures.
120
+ */
121
+ export function saveAndEmitChanged(snapshot, successMsg, emit, cwd = process.cwd()) {
122
+ const persisted = saveSettings(snapshot, cwd);
123
+ emit("subagents:settings_changed", { settings: snapshot, persisted });
124
+ return persistToastFor(successMsg, persisted);
125
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tintinweb/pi-subagents",
3
- "version": "0.5.2",
3
+ "version": "0.6.1",
4
4
  "description": "A pi extension extension that brings smart Claude Code-style autonomous sub-agents to pi.",
5
5
  "author": "tintinweb",
6
6
  "license": "MIT",
@@ -21,9 +21,9 @@
21
21
  "autonomous"
22
22
  ],
23
23
  "dependencies": {
24
- "@mariozechner/pi-ai": "^0.62.0",
25
- "@mariozechner/pi-coding-agent": "^0.62.0",
26
- "@mariozechner/pi-tui": "^0.62.0",
24
+ "@mariozechner/pi-ai": "^0.70.2",
25
+ "@mariozechner/pi-coding-agent": "^0.70.2",
26
+ "@mariozechner/pi-tui": "^0.70.2",
27
27
  "@sinclair/typebox": "latest"
28
28
  },
29
29
  "scripts": {
@@ -10,10 +10,11 @@ import {
10
10
  createAgentSession,
11
11
  DefaultResourceLoader,
12
12
  type ExtensionAPI,
13
+ getAgentDir,
13
14
  SessionManager,
14
15
  SettingsManager,
15
16
  } from "@mariozechner/pi-coding-agent";
16
- import { getAgentConfig, getConfig, getMemoryTools, getReadOnlyMemoryTools, getToolsForType } from "./agent-types.js";
17
+ import { getAgentConfig, getConfig, getMemoryToolNames, getReadOnlyMemoryToolNames, getToolNamesForType } from "./agent-types.js";
17
18
  import { buildParentContext, extractText } from "./context.js";
18
19
  import { detectEnv } from "./env.js";
19
20
  import { buildMemoryBlock, buildReadOnlyMemoryBlock } from "./memory.js";
@@ -183,27 +184,25 @@ export async function runAgent(
183
184
  }
184
185
  }
185
186
 
186
- let tools = getToolsForType(type, effectiveCwd);
187
+ let toolNames = getToolNamesForType(type);
187
188
 
188
189
  // Persistent memory: detect write capability and branch accordingly.
189
190
  // Account for disallowedTools — a tool in the base set but on the denylist is not truly available.
190
191
  if (agentConfig?.memory) {
191
- const existingNames = new Set(tools.map(t => t.name));
192
+ const existingNames = new Set(toolNames);
192
193
  const denied = agentConfig.disallowedTools ? new Set(agentConfig.disallowedTools) : undefined;
193
194
  const effectivelyHas = (name: string) => existingNames.has(name) && !denied?.has(name);
194
195
  const hasWriteTools = effectivelyHas("write") || effectivelyHas("edit");
195
196
 
196
197
  if (hasWriteTools) {
197
- // Read-write memory: add any missing memory tools (read/write/edit)
198
- const memTools = getMemoryTools(effectiveCwd, existingNames);
199
- if (memTools.length > 0) tools = [...tools, ...memTools];
198
+ // Read-write memory: add any missing memory tool names (read/write/edit)
199
+ const extraNames = getMemoryToolNames(existingNames);
200
+ if (extraNames.length > 0) toolNames = [...toolNames, ...extraNames];
200
201
  extras.memoryBlock = buildMemoryBlock(agentConfig.name, agentConfig.memory, effectiveCwd);
201
202
  } else {
202
- // Read-only memory: only add read tool, use read-only prompt
203
- if (!existingNames.has("read")) {
204
- const readTools = getReadOnlyMemoryTools(effectiveCwd, existingNames);
205
- if (readTools.length > 0) tools = [...tools, ...readTools];
206
- }
203
+ // Read-only memory: only add read tool name, use read-only prompt
204
+ const extraNames = getReadOnlyMemoryToolNames(existingNames);
205
+ if (extraNames.length > 0) toolNames = [...toolNames, ...extraNames];
207
206
  extras.memoryBlock = buildReadOnlyMemoryBlock(agentConfig.name, agentConfig.memory, effectiveCwd);
208
207
  }
209
208
  }
@@ -232,14 +231,24 @@ export async function runAgent(
232
231
  // Still pass noSkills: true since we don't need the skill loader to load them again.
233
232
  const noSkills = skills === false || Array.isArray(skills);
234
233
 
235
- // Load extensions/skills: true or string[] → load; false → don't
234
+ const agentDir = getAgentDir();
235
+
236
+ // Load extensions/skills: true or string[] → load; false → don't.
237
+ // Suppress AGENTS.md/CLAUDE.md and APPEND_SYSTEM.md — upstream's
238
+ // buildSystemPrompt() re-appends both AFTER systemPromptOverride, which
239
+ // would defeat prompt_mode: replace and isolated: true. Parent context, if
240
+ // wanted, reaches the subagent via prompt_mode: append (parentSystemPrompt
241
+ // is embedded in systemPromptOverride) or inherit_context (conversation).
236
242
  const loader = new DefaultResourceLoader({
237
243
  cwd: effectiveCwd,
244
+ agentDir,
238
245
  noExtensions: extensions === false,
239
246
  noSkills,
240
247
  noPromptTemplates: true,
241
248
  noThemes: true,
249
+ noContextFiles: true,
242
250
  systemPromptOverride: () => systemPrompt,
251
+ appendSystemPromptOverride: () => [],
243
252
  });
244
253
  await loader.reload();
245
254
 
@@ -251,21 +260,21 @@ export async function runAgent(
251
260
  // Resolve thinking level: explicit option > agent config > undefined (inherit)
252
261
  const thinkingLevel = options.thinkingLevel ?? agentConfig?.thinking;
253
262
 
254
- const sessionOpts: Record<string, unknown> = {
263
+ const sessionOpts: Parameters<typeof createAgentSession>[0] = {
255
264
  cwd: effectiveCwd,
265
+ agentDir,
256
266
  sessionManager: SessionManager.inMemory(effectiveCwd),
257
- settingsManager: SettingsManager.create(),
267
+ settingsManager: SettingsManager.create(effectiveCwd, agentDir),
258
268
  modelRegistry: ctx.modelRegistry,
259
269
  model,
260
- tools,
270
+ tools: toolNames,
261
271
  resourceLoader: loader,
262
272
  };
263
273
  if (thinkingLevel) {
264
274
  sessionOpts.thinkingLevel = thinkingLevel;
265
275
  }
266
276
 
267
- // createAgentSession's type signature may not include thinkingLevel yet
268
- const { session } = await createAgentSession(sessionOpts as Parameters<typeof createAgentSession>[0]);
277
+ const { session } = await createAgentSession(sessionOpts);
269
278
 
270
279
  // Build disallowed tools set from agent config
271
280
  const disallowedSet = agentConfig?.disallowedTools
@@ -275,11 +284,11 @@ export async function runAgent(
275
284
  // Filter active tools: remove our own tools to prevent nesting,
276
285
  // apply extension allowlist if specified, and apply disallowedTools denylist
277
286
  if (extensions !== false) {
278
- const builtinToolNames = new Set(tools.map(t => t.name));
287
+ const builtinToolNameSet = new Set(toolNames);
279
288
  const activeTools = session.getActiveToolNames().filter((t) => {
280
289
  if (EXCLUDED_TOOL_NAMES.includes(t)) return false;
281
290
  if (disallowedSet?.has(t)) return false;
282
- if (builtinToolNames.has(t)) return true;
291
+ if (builtinToolNameSet.has(t)) return true;
283
292
  if (Array.isArray(extensions)) {
284
293
  return extensions.some(ext => t.startsWith(ext) || t.includes(ext));
285
294
  }
@@ -5,33 +5,11 @@
5
5
  * User agents override defaults with the same name. Disabled agents are kept but excluded from spawning.
6
6
  */
7
7
 
8
- import type { AgentTool } from "@mariozechner/pi-agent-core";
9
- import {
10
- createBashTool,
11
- createEditTool,
12
- createFindTool,
13
- createGrepTool,
14
- createLsTool,
15
- createReadTool,
16
- createWriteTool,
17
- } from "@mariozechner/pi-coding-agent";
18
8
  import { DEFAULT_AGENTS } from "./default-agents.js";
19
9
  import type { AgentConfig } from "./types.js";
20
10
 
21
- type ToolFactory = (cwd: string) => AgentTool<any>;
22
-
23
- const TOOL_FACTORIES: Record<string, ToolFactory> = {
24
- read: (cwd) => createReadTool(cwd),
25
- bash: (cwd) => createBashTool(cwd),
26
- edit: (cwd) => createEditTool(cwd),
27
- write: (cwd) => createWriteTool(cwd),
28
- grep: (cwd) => createGrepTool(cwd),
29
- find: (cwd) => createFindTool(cwd),
30
- ls: (cwd) => createLsTool(cwd),
31
- };
32
-
33
- /** All known built-in tool names, derived from the factory registry. */
34
- export const BUILTIN_TOOL_NAMES = Object.keys(TOOL_FACTORIES);
11
+ /** All known built-in tool names. */
12
+ export const BUILTIN_TOOL_NAMES: string[] = ["read", "bash", "edit", "write", "grep", "find", "ls"];
35
13
 
36
14
  /** Unified runtime registry of all agents (defaults + user-defined). */
37
15
  const agents = new Map<string, AgentConfig>();
@@ -113,35 +91,29 @@ export function isValidType(type: string): boolean {
113
91
  const MEMORY_TOOL_NAMES = ["read", "write", "edit"];
114
92
 
115
93
  /**
116
- * Get the tools needed for memory management (read, write, edit).
117
- * Only returns tools that are NOT already in the provided set.
94
+ * Get memory tool names (read/write/edit) not already in the provided set.
118
95
  */
119
- export function getMemoryTools(cwd: string, existingToolNames: Set<string>): AgentTool<any>[] {
120
- return MEMORY_TOOL_NAMES
121
- .filter(n => !existingToolNames.has(n) && n in TOOL_FACTORIES)
122
- .map(n => TOOL_FACTORIES[n](cwd));
96
+ export function getMemoryToolNames(existingToolNames: Set<string>): string[] {
97
+ return MEMORY_TOOL_NAMES.filter(n => !existingToolNames.has(n));
123
98
  }
124
99
 
125
100
  /** Tool names needed for read-only memory access. */
126
101
  const READONLY_MEMORY_TOOL_NAMES = ["read"];
127
102
 
128
103
  /**
129
- * Get only the read tool for read-only memory access.
130
- * Only returns tools that are NOT already in the provided set.
104
+ * Get read-only memory tool names not already in the provided set.
131
105
  */
132
- export function getReadOnlyMemoryTools(cwd: string, existingToolNames: Set<string>): AgentTool<any>[] {
133
- return READONLY_MEMORY_TOOL_NAMES
134
- .filter(n => !existingToolNames.has(n) && n in TOOL_FACTORIES)
135
- .map(n => TOOL_FACTORIES[n](cwd));
106
+ export function getReadOnlyMemoryToolNames(existingToolNames: Set<string>): string[] {
107
+ return READONLY_MEMORY_TOOL_NAMES.filter(n => !existingToolNames.has(n));
136
108
  }
137
109
 
138
- /** Get built-in tools for a type (case-insensitive). */
139
- export function getToolsForType(type: string, cwd: string): AgentTool<any>[] {
110
+ /** Get built-in tool names for a type (case-insensitive). */
111
+ export function getToolNamesForType(type: string): string[] {
140
112
  const key = resolveKey(type);
141
113
  const raw = key ? agents.get(key) : undefined;
142
114
  const config = raw?.enabled !== false ? raw : undefined;
143
- const toolNames = config?.builtinToolNames?.length ? config.builtinToolNames : BUILTIN_TOOL_NAMES;
144
- return toolNames.filter((n) => n in TOOL_FACTORIES).map((n) => TOOL_FACTORIES[n](cwd));
115
+ const names = config?.builtinToolNames?.length ? config.builtinToolNames : [...BUILTIN_TOOL_NAMES];
116
+ return names;
145
117
  }
146
118
 
147
119
  /** Get config for a type (case-insensitive, returns a SubagentTypeConfig-compatible object). Falls back to general-purpose. */
@@ -1,11 +1,10 @@
1
1
  /**
2
- * custom-agents.ts — Load user-defined agents from project (.pi/agents/) and global (~/.pi/agent/agents/) locations.
2
+ * custom-agents.ts — Load user-defined agents from project (.pi/agents/) and global ($PI_CODING_AGENT_DIR/agents/, default ~/.pi/agent/agents/) locations.
3
3
  */
4
4
 
5
5
  import { existsSync, readdirSync, readFileSync } from "node:fs";
6
- import { homedir } from "node:os";
7
6
  import { basename, join } from "node:path";
8
- import { parseFrontmatter } from "@mariozechner/pi-coding-agent";
7
+ import { getAgentDir, parseFrontmatter } from "@mariozechner/pi-coding-agent";
9
8
  import { BUILTIN_TOOL_NAMES } from "./agent-types.js";
10
9
  import type { AgentConfig, MemoryScope, ThinkingLevel } from "./types.js";
11
10
 
@@ -13,13 +12,13 @@ import type { AgentConfig, MemoryScope, ThinkingLevel } from "./types.js";
13
12
  * Scan for custom agent .md files from multiple locations.
14
13
  * Discovery hierarchy (higher priority wins):
15
14
  * 1. Project: <cwd>/.pi/agents/*.md
16
- * 2. Global: ~/.pi/agent/agents/*.md
15
+ * 2. Global: $PI_CODING_AGENT_DIR/agents/*.md (default: ~/.pi/agent/agents/*.md)
17
16
  *
18
17
  * Project-level agents override global ones with the same name.
19
18
  * Any name is allowed — names matching defaults (e.g. "Explore") override them.
20
19
  */
21
20
  export function loadCustomAgents(cwd: string): Map<string, AgentConfig> {
22
- const globalDir = join(homedir(), ".pi", "agent", "agents");
21
+ const globalDir = join(getAgentDir(), "agents");
23
22
  const projectDir = join(cwd, ".pi", "agents");
24
23
 
25
24
  const agents = new Map<string, AgentConfig>();
package/src/index.ts CHANGED
@@ -11,9 +11,8 @@
11
11
  */
12
12
 
13
13
  import { existsSync, mkdirSync, readFileSync, unlinkSync } from "node:fs";
14
- import { homedir } from "node:os";
15
14
  import { join } from "node:path";
16
- import type { ExtensionAPI, ExtensionCommandContext, ExtensionContext } from "@mariozechner/pi-coding-agent";
15
+ import { defineTool, type ExtensionAPI, type ExtensionCommandContext, type ExtensionContext, getAgentDir } from "@mariozechner/pi-coding-agent";
17
16
  import { Text } from "@mariozechner/pi-tui";
18
17
  import { Type } from "@sinclair/typebox";
19
18
  import { AgentManager } from "./agent-manager.js";
@@ -25,6 +24,7 @@ import { GroupJoinManager } from "./group-join.js";
25
24
  import { resolveAgentInvocationConfig, resolveJoinMode } from "./invocation-config.js";
26
25
  import { type ModelRegistry, resolveModel } from "./model-resolver.js";
27
26
  import { createOutputFilePath, streamToOutputFile, writeInitialEntry } from "./output-file.js";
27
+ import { applyAndEmitLoaded, type SubagentsSettings, saveAndEmitChanged } from "./settings.js";
28
28
  import { type AgentConfig, type AgentRecord, type JoinMode, type NotificationDetails, type SubagentType } from "./types.js";
29
29
  import {
30
30
  type AgentActivity,
@@ -430,7 +430,7 @@ export default function (pi: ExtensionAPI) {
430
430
  manager.clearCompleted(); // preserve existing behavior
431
431
  });
432
432
 
433
- pi.on("session_switch", () => { manager.clearCompleted(); });
433
+ pi.on("session_before_switch", () => { manager.clearCompleted(); });
434
434
 
435
435
  const { unsubPing: unsubPingRpc, unsubSpawn: unsubSpawnRpc, unsubStop: unsubStopRpc } = registerRpcHandlers({
436
436
  events: pi.events,
@@ -534,7 +534,7 @@ export default function (pi: ExtensionAPI) {
534
534
  ...defaultDescs,
535
535
  ...(customDescs.length > 0 ? ["", "Custom agents:", ...customDescs] : []),
536
536
  "",
537
- "Custom agents can be defined in .pi/agents/<name>.md (project) or ~/.pi/agent/agents/<name>.md (global) — they are picked up automatically. Project-level agents override global ones. Creating a .md file with the same name as a default agent overrides it.",
537
+ `Custom agents can be defined in .pi/agents/<name>.md (project) or ${getAgentDir()}/agents/<name>.md (global) — they are picked up automatically. Project-level agents override global ones. Creating a .md file with the same name as a default agent overrides it.`,
538
538
  ].join("\n");
539
539
  };
540
540
 
@@ -548,9 +548,22 @@ export default function (pi: ExtensionAPI) {
548
548
 
549
549
  const typeListText = buildTypeListText();
550
550
 
551
+ // Apply persisted settings on startup and emit `subagents:settings_loaded`.
552
+ // Global + project merged; missing → defaults; corrupt file emits a warning
553
+ // to stderr and falls back to defaults.
554
+ applyAndEmitLoaded(
555
+ {
556
+ setMaxConcurrent: (n) => manager.setMaxConcurrent(n),
557
+ setDefaultMaxTurns,
558
+ setGraceTurns,
559
+ setDefaultJoinMode,
560
+ },
561
+ (event, payload) => pi.events.emit(event, payload),
562
+ );
563
+
551
564
  // ---- Agent tool ----
552
565
 
553
- pi.registerTool<any, AgentDetails>({
566
+ pi.registerTool(defineTool({
554
567
  name: "Agent",
555
568
  label: "Agent",
556
569
  description: `Launch a new agent to handle complex, multi-step tasks autonomously.
@@ -582,7 +595,7 @@ Guidelines:
582
595
  description: "A short (3-5 word) description of the task (shown in UI).",
583
596
  }),
584
597
  subagent_type: Type.String({
585
- description: `The type of specialized agent to use. Available types: ${getAvailableTypes().join(", ")}. Custom agents from .pi/agents/*.md (project) or ~/.pi/agent/agents/*.md (global) are also available.`,
598
+ 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.`,
586
599
  }),
587
600
  model: Type.Optional(
588
601
  Type.String({
@@ -964,11 +977,11 @@ Guidelines:
964
977
  details,
965
978
  );
966
979
  },
967
- });
980
+ }));
968
981
 
969
982
  // ---- get_subagent_result tool ----
970
983
 
971
- pi.registerTool({
984
+ pi.registerTool(defineTool({
972
985
  name: "get_subagent_result",
973
986
  label: "Get Agent Result",
974
987
  description:
@@ -1038,11 +1051,11 @@ Guidelines:
1038
1051
 
1039
1052
  return textResult(output);
1040
1053
  },
1041
- });
1054
+ }));
1042
1055
 
1043
1056
  // ---- steer_subagent tool ----
1044
1057
 
1045
- pi.registerTool({
1058
+ pi.registerTool(defineTool({
1046
1059
  name: "steer_subagent",
1047
1060
  label: "Steer Agent",
1048
1061
  description:
@@ -1080,12 +1093,12 @@ Guidelines:
1080
1093
  return textResult(`Failed to steer agent: ${err instanceof Error ? err.message : String(err)}`);
1081
1094
  }
1082
1095
  },
1083
- });
1096
+ }));
1084
1097
 
1085
1098
  // ---- /agents interactive menu ----
1086
1099
 
1087
1100
  const projectAgentsDir = () => join(process.cwd(), ".pi", "agents");
1088
- const personalAgentsDir = () => join(homedir(), ".pi", "agent", "agents");
1101
+ const personalAgentsDir = () => join(getAgentDir(), "agents");
1089
1102
 
1090
1103
  /** Find the file path of a custom agent by name (project first, then global). */
1091
1104
  function findAgentFile(name: string): { path: string; location: "project" | "personal" } | undefined {
@@ -1324,7 +1337,7 @@ Guidelines:
1324
1337
  async function ejectAgent(ctx: ExtensionCommandContext, name: string, cfg: AgentConfig) {
1325
1338
  const location = await ctx.ui.select("Choose location", [
1326
1339
  "Project (.pi/agents/)",
1327
- "Personal (~/.pi/agent/agents/)",
1340
+ `Personal (${personalAgentsDir()})`,
1328
1341
  ]);
1329
1342
  if (!location) return;
1330
1343
 
@@ -1386,7 +1399,7 @@ Guidelines:
1386
1399
  // No file (built-in default) — create a stub
1387
1400
  const location = await ctx.ui.select("Choose location", [
1388
1401
  "Project (.pi/agents/)",
1389
- "Personal (~/.pi/agent/agents/)",
1402
+ `Personal (${personalAgentsDir()})`,
1390
1403
  ]);
1391
1404
  if (!location) return;
1392
1405
 
@@ -1424,7 +1437,7 @@ Guidelines:
1424
1437
  async function showCreateWizard(ctx: ExtensionCommandContext) {
1425
1438
  const location = await ctx.ui.select("Choose location", [
1426
1439
  "Project (.pi/agents/)",
1427
- "Personal (~/.pi/agent/agents/)",
1440
+ `Personal (${personalAgentsDir()})`,
1428
1441
  ]);
1429
1442
  if (!location) return;
1430
1443
 
@@ -1605,6 +1618,17 @@ ${systemPrompt}
1605
1618
  ctx.ui.notify(`Created ${targetPath}`, "info");
1606
1619
  }
1607
1620
 
1621
+ function snapshotSettings(): SubagentsSettings {
1622
+ return {
1623
+ maxConcurrent: manager.getMaxConcurrent(),
1624
+ // 0 = unlimited — per SubagentsSettings.defaultMaxTurns docstring and
1625
+ // normalizeMaxTurns() in agent-runner.ts (which maps 0 → undefined).
1626
+ defaultMaxTurns: getDefaultMaxTurns() ?? 0,
1627
+ graceTurns: getGraceTurns(),
1628
+ defaultJoinMode: getDefaultJoinMode(),
1629
+ };
1630
+ }
1631
+
1608
1632
  async function showSettings(ctx: ExtensionCommandContext) {
1609
1633
  const choice = await ctx.ui.select("Settings", [
1610
1634
  `Max concurrency (current: ${manager.getMaxConcurrent()})`,
@@ -1620,7 +1644,7 @@ ${systemPrompt}
1620
1644
  const n = parseInt(val, 10);
1621
1645
  if (n >= 1) {
1622
1646
  manager.setMaxConcurrent(n);
1623
- ctx.ui.notify(`Max concurrency set to ${n}`, "info");
1647
+ notifyApplied(ctx, `Max concurrency set to ${n}`);
1624
1648
  } else {
1625
1649
  ctx.ui.notify("Must be a positive integer.", "warning");
1626
1650
  }
@@ -1631,10 +1655,10 @@ ${systemPrompt}
1631
1655
  const n = parseInt(val, 10);
1632
1656
  if (n === 0) {
1633
1657
  setDefaultMaxTurns(undefined);
1634
- ctx.ui.notify("Default max turns set to unlimited", "info");
1658
+ notifyApplied(ctx, "Default max turns set to unlimited");
1635
1659
  } else if (n >= 1) {
1636
1660
  setDefaultMaxTurns(n);
1637
- ctx.ui.notify(`Default max turns set to ${n}`, "info");
1661
+ notifyApplied(ctx, `Default max turns set to ${n}`);
1638
1662
  } else {
1639
1663
  ctx.ui.notify("Must be 0 (unlimited) or a positive integer.", "warning");
1640
1664
  }
@@ -1645,7 +1669,7 @@ ${systemPrompt}
1645
1669
  const n = parseInt(val, 10);
1646
1670
  if (n >= 1) {
1647
1671
  setGraceTurns(n);
1648
- ctx.ui.notify(`Grace turns set to ${n}`, "info");
1672
+ notifyApplied(ctx, `Grace turns set to ${n}`);
1649
1673
  } else {
1650
1674
  ctx.ui.notify("Must be a positive integer.", "warning");
1651
1675
  }
@@ -1659,11 +1683,24 @@ ${systemPrompt}
1659
1683
  if (val) {
1660
1684
  const mode = val.split(" ")[0] as JoinMode;
1661
1685
  setDefaultJoinMode(mode);
1662
- ctx.ui.notify(`Default join mode set to ${mode}`, "info");
1686
+ notifyApplied(ctx, `Default join mode set to ${mode}`);
1663
1687
  }
1664
1688
  }
1665
1689
  }
1666
1690
 
1691
+ // Persist the current snapshot, emit `subagents:settings_changed`, and surface
1692
+ // the right toast. Successful saves show info; persistence failures downgrade
1693
+ // to warning so users aren't silently reverted on restart. Event fires regardless
1694
+ // of outcome so listeners see the in-memory change.
1695
+ function notifyApplied(ctx: ExtensionCommandContext, successMsg: string) {
1696
+ const { message, level } = saveAndEmitChanged(
1697
+ snapshotSettings(),
1698
+ successMsg,
1699
+ (event, payload) => pi.events.emit(event, payload),
1700
+ );
1701
+ ctx.ui.notify(message, level);
1702
+ }
1703
+
1667
1704
  pi.registerCommand("agents", {
1668
1705
  description: "Manage agents",
1669
1706
  handler: async (_args, ctx) => { await showAgentsMenu(ctx); },