@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.
package/CHANGELOG.md CHANGED
@@ -7,6 +7,30 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.6.1] - 2026-04-25
11
+
12
+ ### Added
13
+ - **Persistent `/agents` → Settings** ([#24](https://github.com/tintinweb/pi-subagents/issues/24)) — the four runtime tuning values (`maxConcurrent`, `defaultMaxTurns`, `graceTurns`, `defaultJoinMode`) now survive pi restarts via a two-file dual-scope model mirroring pi's own `SettingsManager`. Global `~/.pi/agent/subagents.json` provides machine-wide defaults (edit by hand; the menu never writes here); project `<cwd>/.pi/subagents.json` holds per-project overrides (written by `/agents` → Settings). Load merges both with project winning on conflicts. Invalid fields are silently dropped per field; malformed JSON emits a warning to stderr and falls back to defaults so startup always proceeds; write failures downgrade the settings toast to a warning with `(session only; failed to persist)` so changes aren't silently reverted on next restart.
14
+ - **New lifecycle events** — `subagents:settings_loaded` (emitted once at extension init with the merged settings) and `subagents:settings_changed` (emitted on each `/agents` → Settings mutation with the new snapshot and a `persisted: boolean` flag so listeners can react to write failures).
15
+
16
+ ### Fixed
17
+ - **`AGENTS.md` / `CLAUDE.md` / `APPEND_SYSTEM.md` no longer leak into sub-agent prompts** ([#26](https://github.com/tintinweb/pi-subagents/pull/26) — thanks [@mikeyobrien](https://github.com/mikeyobrien) for the diagnosis). Upstream `buildSystemPrompt()` re-appends `contextFiles` and `appendSystemPrompt` *after* our `systemPromptOverride` runs, which silently defeated `prompt_mode: replace` and `isolated: true` — parent project context (e.g. autoresearch-mode blocks) was bleeding into fresh `Explore` / custom sub-agents regardless of frontmatter. Fix uses upstream's `noContextFiles: true` flag (skips the load entirely, introduced in pi 0.68) plus `appendSystemPromptOverride: () => []` (no flag equivalent for append sources). **Behavior change:** subagents no longer implicitly inherit parent `AGENTS.md`/`CLAUDE.md`/`APPEND_SYSTEM.md`. To get parent project context into a subagent, use `prompt_mode: append` (parent's already-built system prompt flows in via `systemPromptOverride`), or `inherit_context: true` (parent conversation), or inline the content into the agent's own frontmatter.
18
+ - **Custom agent discovery respects `PI_CODING_AGENT_DIR`** ([#35](https://github.com/tintinweb/pi-subagents/pull/35), closes [#23](https://github.com/tintinweb/pi-subagents/issues/23) — thanks [@Amolith](https://github.com/Amolith) for the diagnosis). Two remaining hardcoded `~/.pi/agent/agents/` paths in `custom-agents.ts` and `index.ts` bypassed the env var, so users who relocated their agent directory (e.g. via `PI_CODING_AGENT_DIR`) still had global agents loaded from the default location and help text referencing the wrong path. Both now use upstream `getAgentDir()`, consistent with `agent-runner.ts` and `settings.ts`; tilde expansion is handled by upstream.
19
+
20
+ ## [0.6.0] - 2026-04-24
21
+
22
+ > **⚠️ Breaking: drops support for `pi` < 0.68.** The upstream `pi-coding-agent` package shipped breaking API changes in v0.68 (and further ones in v0.70). This release migrates to `^0.70.2` and is **not** backward-compatible with hosts on `pi` 0.62–0.67. Users on those versions must upgrade their `pi` installation (`npm install -g @mariozechner/pi-coding-agent@latest`) before updating this extension.
23
+
24
+ ### Changed
25
+ - **Bumped peer `@mariozechner/pi-coding-agent` to `^0.70.2`** ([#28](https://github.com/tintinweb/pi-subagents/pull/28)) — crosses the v0.68 breaking-change line upstream. Specifically: tools are now passed as `string[]` (was `Tool[]`); `cwd`/`agentDir` are mandatory on `SettingsManager.create()` and `DefaultResourceLoader`; `session_switch` event renamed to `session_before_switch`; `ToolDefinition.params` widens to `unknown` under contextual typing, requiring `defineTool(...)`.
26
+ - **Tool registrations wrapped with `defineTool(...)`** — preserves `TParams` inference so `execute` handlers get properly-typed `params` instead of `unknown`. Applies to the `Agent`, `get_subagent_result`, and `steer_subagent` tools.
27
+
28
+ ### Removed
29
+ - **Cwd-bound tool factory registry** — the internal `TOOL_FACTORIES` closure table and `create{Bash,Edit,Read,Write,Grep,Find,Ls}Tool` imports are gone. Exported helpers renamed: `getToolsForType(type, cwd)` → `getToolNamesForType(type)`, `getMemoryTools(cwd, set)` → `getMemoryToolNames(set)`, `getReadOnlyMemoryTools(cwd, set)` → `getReadOnlyMemoryToolNames(set)` — all returning `string[]` instead of `Tool[]`. The host binds cwd when resolving tool names, so the extension no longer instantiates tools directly.
30
+
31
+ ### Fixed
32
+ - **Subagent `SettingsManager` read wrong project settings in worktree mode** ([#30](https://github.com/tintinweb/pi-subagents/pull/30)) — `SettingsManager.create()` was called without arguments, defaulting `cwd` to `process.cwd()`. When the subagent's effective cwd differed (worktree isolation or explicit `cwd` override), its settings manager read `.pi/settings.json` from the parent's cwd rather than its own, diverging from the loader and session manager. Now passes `effectiveCwd` and `agentDir` explicitly, keeping all three managers consistent.
33
+
10
34
  ## [0.5.2] - 2026-03-26
11
35
 
12
36
  ### Fixed
package/README.md CHANGED
@@ -116,9 +116,9 @@ Agents are discovered from two locations (higher priority wins):
116
116
  | Priority | Location | Scope |
117
117
  |----------|----------|-------|
118
118
  | 1 (highest) | `.pi/agents/<name>.md` | Project — per-repo agents |
119
- | 2 | `~/.pi/agent/agents/<name>.md` | Global — available everywhere |
119
+ | 2 | `$PI_CODING_AGENT_DIR/agents/<name>.md` (default `~/.pi/agent/agents/<name>.md`) | Global — available everywhere |
120
120
 
121
- Project-level agents override global ones with the same name, so you can customize a global agent for a specific project.
121
+ Project-level agents override global ones with the same name, so you can customize a global agent for a specific project. The global location follows the upstream `PI_CODING_AGENT_DIR` env var — set it to relocate all pi-coding-agent state (agents, skills, settings) to a custom directory.
122
122
 
123
123
  ### Example: `.pi/agents/auditor.md`
124
124
 
@@ -163,7 +163,7 @@ All fields are optional — sensible defaults for everything.
163
163
  | `model` | inherit parent | Model — `provider/modelId` or fuzzy name (`"haiku"`, `"sonnet"`) |
164
164
  | `thinking` | inherit | off, minimal, low, medium, high, xhigh |
165
165
  | `max_turns` | unlimited | Max agentic turns before graceful shutdown. `0` or omit for unlimited |
166
- | `prompt_mode` | `replace` | `replace`: body is the full system prompt. `append`: body appended to parent's prompt (agent acts as a "parent twin" with optional extra instructions) |
166
+ | `prompt_mode` | `replace` | `replace`: body is the full system prompt (no AGENTS.md / CLAUDE.md inheritance). `append`: body appended to parent's prompt (agent acts as a "parent twin" inherits parent's AGENTS.md / CLAUDE.md) |
167
167
  | `inherit_context` | `false` | Fork parent conversation into agent |
168
168
  | `run_in_background` | `false` | Run in background by default |
169
169
  | `isolation` | — | `worktree`: run in a temporary git worktree for full repo isolation |
@@ -272,6 +272,31 @@ When background agents complete, they notify the main agent. The **join mode** c
272
272
  **Configuration:**
273
273
  - Configure join mode in `/agents` → Settings → Join mode
274
274
 
275
+ ## Persistent Settings
276
+
277
+ Runtime tuning values set via `/agents` → Settings (max concurrency, default max turns, grace turns, default join mode) persist across pi restarts. Two files, merged on load:
278
+
279
+ - **Global:** `~/.pi/agent/subagents.json` — your machine-wide defaults. Edit by hand; the `/agents` menu never writes here.
280
+ - **Project:** `<cwd>/.pi/subagents.json` — per-project overrides. Written by `/agents` → Settings.
281
+
282
+ **Precedence:** project overrides global on any field present in both. Missing fields fall back to the hardcoded defaults (max concurrency `4`, default max turns unlimited, grace turns `5`, join mode `smart`).
283
+
284
+ **Example — global defaults for a beefy machine:**
285
+
286
+ ```bash
287
+ mkdir -p ~/.pi/agent
288
+ cat > ~/.pi/agent/subagents.json <<'EOF'
289
+ {
290
+ "maxConcurrent": 16,
291
+ "graceTurns": 10
292
+ }
293
+ EOF
294
+ ```
295
+
296
+ Every project now starts with concurrency 16 and grace 10, without ever touching the menu. Individual projects can still override via `/agents` → Settings.
297
+
298
+ **Failure behavior:** missing file is silent; malformed JSON logs a `[pi-subagents] Ignoring malformed settings at …` warning to stderr; invalid/out-of-range field values are dropped per-field; write failures downgrade the `/agents` toast to a warning with `(session only; failed to persist)`.
299
+
275
300
  ## Events
276
301
 
277
302
  Agent lifecycle events are emitted via `pi.events.emit()` so other extensions can react:
@@ -284,6 +309,8 @@ Agent lifecycle events are emitted via `pi.events.emit()` so other extensions ca
284
309
  | `subagents:failed` | Agent errored, stopped, or aborted | same as completed + `error`, `status` |
285
310
  | `subagents:steered` | Steering message sent | `id`, `message` |
286
311
  | `subagents:ready` | Extension loaded and RPC handlers registered | — |
312
+ | `subagents:settings_loaded` | Persisted settings applied at extension init | `settings` (merged global + project) |
313
+ | `subagents:settings_changed` | `/agents` → Settings mutation was applied | `settings`, `persisted` (`boolean` — `false` on write failure) |
287
314
 
288
315
  ## Cross-Extension RPC
289
316
 
@@ -417,7 +444,7 @@ src/
417
444
  index.ts # Extension entry: tool/command registration, rendering
418
445
  types.ts # Type definitions (AgentConfig, AgentRecord, etc.)
419
446
  default-agents.ts # Embedded default agent configs (general-purpose, Explore, Plan)
420
- agent-types.ts # Unified agent registry (defaults + user), tool factories
447
+ agent-types.ts # Unified agent registry (defaults + user), tool name resolution
421
448
  agent-runner.ts # Session creation, execution, graceful max_turns, steer/resume
422
449
  agent-manager.ts # Agent lifecycle, concurrency queue, completion notifications
423
450
  cross-extension-rpc.ts # RPC handlers for cross-extension spawn/ping via pi.events
@@ -1,8 +1,8 @@
1
1
  /**
2
2
  * agent-runner.ts — Core execution engine: creates sessions, runs agents, collects results.
3
3
  */
4
- import { createAgentSession, DefaultResourceLoader, SessionManager, SettingsManager, } from "@mariozechner/pi-coding-agent";
5
- import { getAgentConfig, getConfig, getMemoryTools, getReadOnlyMemoryTools, getToolsForType } from "./agent-types.js";
4
+ import { createAgentSession, DefaultResourceLoader, getAgentDir, SessionManager, SettingsManager, } from "@mariozechner/pi-coding-agent";
5
+ import { getAgentConfig, getConfig, getMemoryToolNames, getReadOnlyMemoryToolNames, getToolNamesForType } from "./agent-types.js";
6
6
  import { buildParentContext, extractText } from "./context.js";
7
7
  import { detectEnv } from "./env.js";
8
8
  import { buildMemoryBlock, buildReadOnlyMemoryBlock } from "./memory.js";
@@ -110,28 +110,26 @@ export async function runAgent(ctx, type, prompt, options) {
110
110
  extras.skillBlocks = loaded;
111
111
  }
112
112
  }
113
- let tools = getToolsForType(type, effectiveCwd);
113
+ let toolNames = getToolNamesForType(type);
114
114
  // Persistent memory: detect write capability and branch accordingly.
115
115
  // Account for disallowedTools — a tool in the base set but on the denylist is not truly available.
116
116
  if (agentConfig?.memory) {
117
- const existingNames = new Set(tools.map(t => t.name));
117
+ const existingNames = new Set(toolNames);
118
118
  const denied = agentConfig.disallowedTools ? new Set(agentConfig.disallowedTools) : undefined;
119
119
  const effectivelyHas = (name) => existingNames.has(name) && !denied?.has(name);
120
120
  const hasWriteTools = effectivelyHas("write") || effectivelyHas("edit");
121
121
  if (hasWriteTools) {
122
- // Read-write memory: add any missing memory tools (read/write/edit)
123
- const memTools = getMemoryTools(effectiveCwd, existingNames);
124
- if (memTools.length > 0)
125
- tools = [...tools, ...memTools];
122
+ // Read-write memory: add any missing memory tool names (read/write/edit)
123
+ const extraNames = getMemoryToolNames(existingNames);
124
+ if (extraNames.length > 0)
125
+ toolNames = [...toolNames, ...extraNames];
126
126
  extras.memoryBlock = buildMemoryBlock(agentConfig.name, agentConfig.memory, effectiveCwd);
127
127
  }
128
128
  else {
129
- // Read-only memory: only add read tool, use read-only prompt
130
- if (!existingNames.has("read")) {
131
- const readTools = getReadOnlyMemoryTools(effectiveCwd, existingNames);
132
- if (readTools.length > 0)
133
- tools = [...tools, ...readTools];
134
- }
129
+ // Read-only memory: only add read tool name, use read-only prompt
130
+ const extraNames = getReadOnlyMemoryToolNames(existingNames);
131
+ if (extraNames.length > 0)
132
+ toolNames = [...toolNames, ...extraNames];
135
133
  extras.memoryBlock = buildReadOnlyMemoryBlock(agentConfig.name, agentConfig.memory, effectiveCwd);
136
134
  }
137
135
  }
@@ -158,14 +156,23 @@ export async function runAgent(ctx, type, prompt, options) {
158
156
  // When skills is string[], we've already preloaded them into the prompt.
159
157
  // Still pass noSkills: true since we don't need the skill loader to load them again.
160
158
  const noSkills = skills === false || Array.isArray(skills);
161
- // Load extensions/skills: true or string[] → load; false → don't
159
+ const agentDir = getAgentDir();
160
+ // Load extensions/skills: true or string[] → load; false → don't.
161
+ // Suppress AGENTS.md/CLAUDE.md and APPEND_SYSTEM.md — upstream's
162
+ // buildSystemPrompt() re-appends both AFTER systemPromptOverride, which
163
+ // would defeat prompt_mode: replace and isolated: true. Parent context, if
164
+ // wanted, reaches the subagent via prompt_mode: append (parentSystemPrompt
165
+ // is embedded in systemPromptOverride) or inherit_context (conversation).
162
166
  const loader = new DefaultResourceLoader({
163
167
  cwd: effectiveCwd,
168
+ agentDir,
164
169
  noExtensions: extensions === false,
165
170
  noSkills,
166
171
  noPromptTemplates: true,
167
172
  noThemes: true,
173
+ noContextFiles: true,
168
174
  systemPromptOverride: () => systemPrompt,
175
+ appendSystemPromptOverride: () => [],
169
176
  });
170
177
  await loader.reload();
171
178
  // Resolve model: explicit option > config.model > parent model
@@ -174,17 +181,17 @@ export async function runAgent(ctx, type, prompt, options) {
174
181
  const thinkingLevel = options.thinkingLevel ?? agentConfig?.thinking;
175
182
  const sessionOpts = {
176
183
  cwd: effectiveCwd,
184
+ agentDir,
177
185
  sessionManager: SessionManager.inMemory(effectiveCwd),
178
- settingsManager: SettingsManager.create(),
186
+ settingsManager: SettingsManager.create(effectiveCwd, agentDir),
179
187
  modelRegistry: ctx.modelRegistry,
180
188
  model,
181
- tools,
189
+ tools: toolNames,
182
190
  resourceLoader: loader,
183
191
  };
184
192
  if (thinkingLevel) {
185
193
  sessionOpts.thinkingLevel = thinkingLevel;
186
194
  }
187
- // createAgentSession's type signature may not include thinkingLevel yet
188
195
  const { session } = await createAgentSession(sessionOpts);
189
196
  // Build disallowed tools set from agent config
190
197
  const disallowedSet = agentConfig?.disallowedTools
@@ -193,13 +200,13 @@ export async function runAgent(ctx, type, prompt, options) {
193
200
  // Filter active tools: remove our own tools to prevent nesting,
194
201
  // apply extension allowlist if specified, and apply disallowedTools denylist
195
202
  if (extensions !== false) {
196
- const builtinToolNames = new Set(tools.map(t => t.name));
203
+ const builtinToolNameSet = new Set(toolNames);
197
204
  const activeTools = session.getActiveToolNames().filter((t) => {
198
205
  if (EXCLUDED_TOOL_NAMES.includes(t))
199
206
  return false;
200
207
  if (disallowedSet?.has(t))
201
208
  return false;
202
- if (builtinToolNames.has(t))
209
+ if (builtinToolNameSet.has(t))
203
210
  return true;
204
211
  if (Array.isArray(extensions)) {
205
212
  return extensions.some(ext => t.startsWith(ext) || t.includes(ext));
@@ -4,9 +4,8 @@
4
4
  * Merges embedded default agents with user-defined agents from .pi/agents/*.md.
5
5
  * User agents override defaults with the same name. Disabled agents are kept but excluded from spawning.
6
6
  */
7
- import type { AgentTool } from "@mariozechner/pi-agent-core";
8
7
  import type { AgentConfig } from "./types.js";
9
- /** All known built-in tool names, derived from the factory registry. */
8
+ /** All known built-in tool names. */
10
9
  export declare const BUILTIN_TOOL_NAMES: string[];
11
10
  /**
12
11
  * Register agents into the unified registry.
@@ -29,17 +28,15 @@ export declare function getUserAgentNames(): string[];
29
28
  /** Check if a type is valid and enabled (case-insensitive). */
30
29
  export declare function isValidType(type: string): boolean;
31
30
  /**
32
- * Get the tools needed for memory management (read, write, edit).
33
- * Only returns tools that are NOT already in the provided set.
31
+ * Get memory tool names (read/write/edit) not already in the provided set.
34
32
  */
35
- export declare function getMemoryTools(cwd: string, existingToolNames: Set<string>): AgentTool<any>[];
33
+ export declare function getMemoryToolNames(existingToolNames: Set<string>): string[];
36
34
  /**
37
- * Get only the read tool for read-only memory access.
38
- * Only returns tools that are NOT already in the provided set.
35
+ * Get read-only memory tool names not already in the provided set.
39
36
  */
40
- export declare function getReadOnlyMemoryTools(cwd: string, existingToolNames: Set<string>): AgentTool<any>[];
41
- /** Get built-in tools for a type (case-insensitive). */
42
- export declare function getToolsForType(type: string, cwd: string): AgentTool<any>[];
37
+ export declare function getReadOnlyMemoryToolNames(existingToolNames: Set<string>): string[];
38
+ /** Get built-in tool names for a type (case-insensitive). */
39
+ export declare function getToolNamesForType(type: string): string[];
43
40
  /** Get config for a type (case-insensitive, returns a SubagentTypeConfig-compatible object). Falls back to general-purpose. */
44
41
  export declare function getConfig(type: string): {
45
42
  displayName: string;
@@ -4,19 +4,9 @@
4
4
  * Merges embedded default agents with user-defined agents from .pi/agents/*.md.
5
5
  * User agents override defaults with the same name. Disabled agents are kept but excluded from spawning.
6
6
  */
7
- import { createBashTool, createEditTool, createFindTool, createGrepTool, createLsTool, createReadTool, createWriteTool, } from "@mariozechner/pi-coding-agent";
8
7
  import { DEFAULT_AGENTS } from "./default-agents.js";
9
- const TOOL_FACTORIES = {
10
- read: (cwd) => createReadTool(cwd),
11
- bash: (cwd) => createBashTool(cwd),
12
- edit: (cwd) => createEditTool(cwd),
13
- write: (cwd) => createWriteTool(cwd),
14
- grep: (cwd) => createGrepTool(cwd),
15
- find: (cwd) => createFindTool(cwd),
16
- ls: (cwd) => createLsTool(cwd),
17
- };
18
- /** All known built-in tool names, derived from the factory registry. */
19
- export const BUILTIN_TOOL_NAMES = Object.keys(TOOL_FACTORIES);
8
+ /** All known built-in tool names. */
9
+ export const BUILTIN_TOOL_NAMES = ["read", "bash", "edit", "write", "grep", "find", "ls"];
20
10
  /** Unified runtime registry of all agents (defaults + user-defined). */
21
11
  const agents = new Map();
22
12
  /**
@@ -87,32 +77,26 @@ export function isValidType(type) {
87
77
  /** Tool names required for memory management. */
88
78
  const MEMORY_TOOL_NAMES = ["read", "write", "edit"];
89
79
  /**
90
- * Get the tools needed for memory management (read, write, edit).
91
- * Only returns tools that are NOT already in the provided set.
80
+ * Get memory tool names (read/write/edit) not already in the provided set.
92
81
  */
93
- export function getMemoryTools(cwd, existingToolNames) {
94
- return MEMORY_TOOL_NAMES
95
- .filter(n => !existingToolNames.has(n) && n in TOOL_FACTORIES)
96
- .map(n => TOOL_FACTORIES[n](cwd));
82
+ export function getMemoryToolNames(existingToolNames) {
83
+ return MEMORY_TOOL_NAMES.filter(n => !existingToolNames.has(n));
97
84
  }
98
85
  /** Tool names needed for read-only memory access. */
99
86
  const READONLY_MEMORY_TOOL_NAMES = ["read"];
100
87
  /**
101
- * Get only the read tool for read-only memory access.
102
- * Only returns tools that are NOT already in the provided set.
88
+ * Get read-only memory tool names not already in the provided set.
103
89
  */
104
- export function getReadOnlyMemoryTools(cwd, existingToolNames) {
105
- return READONLY_MEMORY_TOOL_NAMES
106
- .filter(n => !existingToolNames.has(n) && n in TOOL_FACTORIES)
107
- .map(n => TOOL_FACTORIES[n](cwd));
90
+ export function getReadOnlyMemoryToolNames(existingToolNames) {
91
+ return READONLY_MEMORY_TOOL_NAMES.filter(n => !existingToolNames.has(n));
108
92
  }
109
- /** Get built-in tools for a type (case-insensitive). */
110
- export function getToolsForType(type, cwd) {
93
+ /** Get built-in tool names for a type (case-insensitive). */
94
+ export function getToolNamesForType(type) {
111
95
  const key = resolveKey(type);
112
96
  const raw = key ? agents.get(key) : undefined;
113
97
  const config = raw?.enabled !== false ? raw : undefined;
114
- const toolNames = config?.builtinToolNames?.length ? config.builtinToolNames : BUILTIN_TOOL_NAMES;
115
- return toolNames.filter((n) => n in TOOL_FACTORIES).map((n) => TOOL_FACTORIES[n](cwd));
98
+ const names = config?.builtinToolNames?.length ? config.builtinToolNames : [...BUILTIN_TOOL_NAMES];
99
+ return names;
116
100
  }
117
101
  /** Get config for a type (case-insensitive, returns a SubagentTypeConfig-compatible object). Falls back to general-purpose. */
118
102
  export function getConfig(type) {
@@ -1,12 +1,12 @@
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
  import type { AgentConfig } from "./types.js";
5
5
  /**
6
6
  * Scan for custom agent .md files from multiple locations.
7
7
  * Discovery hierarchy (higher priority wins):
8
8
  * 1. Project: <cwd>/.pi/agents/*.md
9
- * 2. Global: ~/.pi/agent/agents/*.md
9
+ * 2. Global: $PI_CODING_AGENT_DIR/agents/*.md (default: ~/.pi/agent/agents/*.md)
10
10
  *
11
11
  * Project-level agents override global ones with the same name.
12
12
  * Any name is allowed — names matching defaults (e.g. "Explore") override them.
@@ -1,22 +1,21 @@
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
  import { existsSync, readdirSync, readFileSync } from "node:fs";
5
- import { homedir } from "node:os";
6
5
  import { basename, join } from "node:path";
7
- import { parseFrontmatter } from "@mariozechner/pi-coding-agent";
6
+ import { getAgentDir, parseFrontmatter } from "@mariozechner/pi-coding-agent";
8
7
  import { BUILTIN_TOOL_NAMES } from "./agent-types.js";
9
8
  /**
10
9
  * Scan for custom agent .md files from multiple locations.
11
10
  * Discovery hierarchy (higher priority wins):
12
11
  * 1. Project: <cwd>/.pi/agents/*.md
13
- * 2. Global: ~/.pi/agent/agents/*.md
12
+ * 2. Global: $PI_CODING_AGENT_DIR/agents/*.md (default: ~/.pi/agent/agents/*.md)
14
13
  *
15
14
  * Project-level agents override global ones with the same name.
16
15
  * Any name is allowed — names matching defaults (e.g. "Explore") override them.
17
16
  */
18
17
  export function loadCustomAgents(cwd) {
19
- const globalDir = join(homedir(), ".pi", "agent", "agents");
18
+ const globalDir = join(getAgentDir(), "agents");
20
19
  const projectDir = join(cwd, ".pi", "agents");
21
20
  const agents = new Map();
22
21
  loadFromDir(globalDir, agents, "global"); // lower priority
package/dist/index.d.ts CHANGED
@@ -9,5 +9,5 @@
9
9
  * Commands:
10
10
  * /agents — Interactive agent management menu
11
11
  */
12
- import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
12
+ import { type ExtensionAPI } from "@mariozechner/pi-coding-agent";
13
13
  export default function (pi: ExtensionAPI): void;
package/dist/index.js CHANGED
@@ -10,8 +10,8 @@
10
10
  * /agents — Interactive agent management menu
11
11
  */
12
12
  import { existsSync, mkdirSync, readFileSync, unlinkSync } from "node:fs";
13
- import { homedir } from "node:os";
14
13
  import { join } from "node:path";
14
+ import { defineTool, getAgentDir } from "@mariozechner/pi-coding-agent";
15
15
  import { Text } from "@mariozechner/pi-tui";
16
16
  import { Type } from "@sinclair/typebox";
17
17
  import { AgentManager } from "./agent-manager.js";
@@ -23,6 +23,7 @@ import { GroupJoinManager } from "./group-join.js";
23
23
  import { resolveAgentInvocationConfig, resolveJoinMode } from "./invocation-config.js";
24
24
  import { resolveModel } from "./model-resolver.js";
25
25
  import { createOutputFilePath, streamToOutputFile, writeInitialEntry } from "./output-file.js";
26
+ import { applyAndEmitLoaded, saveAndEmitChanged } from "./settings.js";
26
27
  import { AgentWidget, describeActivity, formatDuration, formatMs, formatTokens, formatTurns, getDisplayName, getPromptModeLabel, SPINNER, } from "./ui/agent-widget.js";
27
28
  // ---- Shared helpers ----
28
29
  /** Tool execute return value for a text response. */
@@ -383,7 +384,7 @@ export default function (pi) {
383
384
  currentCtx = ctx;
384
385
  manager.clearCompleted(); // preserve existing behavior
385
386
  });
386
- pi.on("session_switch", () => { manager.clearCompleted(); });
387
+ pi.on("session_before_switch", () => { manager.clearCompleted(); });
387
388
  const { unsubPing: unsubPingRpc, unsubSpawn: unsubSpawnRpc, unsubStop: unsubStopRpc } = registerRpcHandlers({
388
389
  events: pi.events,
389
390
  pi,
@@ -477,7 +478,7 @@ export default function (pi) {
477
478
  ...defaultDescs,
478
479
  ...(customDescs.length > 0 ? ["", "Custom agents:", ...customDescs] : []),
479
480
  "",
480
- "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.",
481
+ `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.`,
481
482
  ].join("\n");
482
483
  };
483
484
  /** Derive a short model label from a model string. */
@@ -488,8 +489,17 @@ export default function (pi) {
488
489
  return name.replace(/-\d{8}$/, "");
489
490
  }
490
491
  const typeListText = buildTypeListText();
492
+ // Apply persisted settings on startup and emit `subagents:settings_loaded`.
493
+ // Global + project merged; missing → defaults; corrupt file emits a warning
494
+ // to stderr and falls back to defaults.
495
+ applyAndEmitLoaded({
496
+ setMaxConcurrent: (n) => manager.setMaxConcurrent(n),
497
+ setDefaultMaxTurns,
498
+ setGraceTurns,
499
+ setDefaultJoinMode,
500
+ }, (event, payload) => pi.events.emit(event, payload));
491
501
  // ---- Agent tool ----
492
- pi.registerTool({
502
+ pi.registerTool(defineTool({
493
503
  name: "Agent",
494
504
  label: "Agent",
495
505
  description: `Launch a new agent to handle complex, multi-step tasks autonomously.
@@ -521,7 +531,7 @@ Guidelines:
521
531
  description: "A short (3-5 word) description of the task (shown in UI).",
522
532
  }),
523
533
  subagent_type: Type.String({
524
- 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.`,
534
+ 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.`,
525
535
  }),
526
536
  model: Type.Optional(Type.String({
527
537
  description: 'Optional model override. Accepts "provider/modelId" or fuzzy name (e.g. "haiku", "sonnet"). Omit to use the agent type\'s default.',
@@ -847,9 +857,9 @@ Guidelines:
847
857
  return textResult(`${fallbackNote}Agent completed in ${formatMs(durationMs)} (${statsParts.join(", ")})${getStatusNote(record.status)}.\n\n` +
848
858
  (record.result?.trim() || "No output."), details);
849
859
  },
850
- });
860
+ }));
851
861
  // ---- get_subagent_result tool ----
852
- pi.registerTool({
862
+ pi.registerTool(defineTool({
853
863
  name: "get_subagent_result",
854
864
  label: "Get Agent Result",
855
865
  description: "Check status and retrieve results from a background agent. Use the agent ID returned by Agent with run_in_background.",
@@ -908,9 +918,9 @@ Guidelines:
908
918
  }
909
919
  return textResult(output);
910
920
  },
911
- });
921
+ }));
912
922
  // ---- steer_subagent tool ----
913
- pi.registerTool({
923
+ pi.registerTool(defineTool({
914
924
  name: "steer_subagent",
915
925
  label: "Steer Agent",
916
926
  description: "Send a steering message to a running agent. The message will interrupt the agent after its current tool execution " +
@@ -948,10 +958,10 @@ Guidelines:
948
958
  return textResult(`Failed to steer agent: ${err instanceof Error ? err.message : String(err)}`);
949
959
  }
950
960
  },
951
- });
961
+ }));
952
962
  // ---- /agents interactive menu ----
953
963
  const projectAgentsDir = () => join(process.cwd(), ".pi", "agents");
954
- const personalAgentsDir = () => join(homedir(), ".pi", "agent", "agents");
964
+ const personalAgentsDir = () => join(getAgentDir(), "agents");
955
965
  /** Find the file path of a custom agent by name (project first, then global). */
956
966
  function findAgentFile(name) {
957
967
  const projectPath = join(projectAgentsDir(), `${name}.md`);
@@ -1179,7 +1189,7 @@ Guidelines:
1179
1189
  async function ejectAgent(ctx, name, cfg) {
1180
1190
  const location = await ctx.ui.select("Choose location", [
1181
1191
  "Project (.pi/agents/)",
1182
- "Personal (~/.pi/agent/agents/)",
1192
+ `Personal (${personalAgentsDir()})`,
1183
1193
  ]);
1184
1194
  if (!location)
1185
1195
  return;
@@ -1250,7 +1260,7 @@ Guidelines:
1250
1260
  // No file (built-in default) — create a stub
1251
1261
  const location = await ctx.ui.select("Choose location", [
1252
1262
  "Project (.pi/agents/)",
1253
- "Personal (~/.pi/agent/agents/)",
1263
+ `Personal (${personalAgentsDir()})`,
1254
1264
  ]);
1255
1265
  if (!location)
1256
1266
  return;
@@ -1285,7 +1295,7 @@ Guidelines:
1285
1295
  async function showCreateWizard(ctx) {
1286
1296
  const location = await ctx.ui.select("Choose location", [
1287
1297
  "Project (.pi/agents/)",
1288
- "Personal (~/.pi/agent/agents/)",
1298
+ `Personal (${personalAgentsDir()})`,
1289
1299
  ]);
1290
1300
  if (!location)
1291
1301
  return;
@@ -1462,6 +1472,16 @@ ${systemPrompt}
1462
1472
  reloadCustomAgents();
1463
1473
  ctx.ui.notify(`Created ${targetPath}`, "info");
1464
1474
  }
1475
+ function snapshotSettings() {
1476
+ return {
1477
+ maxConcurrent: manager.getMaxConcurrent(),
1478
+ // 0 = unlimited — per SubagentsSettings.defaultMaxTurns docstring and
1479
+ // normalizeMaxTurns() in agent-runner.ts (which maps 0 → undefined).
1480
+ defaultMaxTurns: getDefaultMaxTurns() ?? 0,
1481
+ graceTurns: getGraceTurns(),
1482
+ defaultJoinMode: getDefaultJoinMode(),
1483
+ };
1484
+ }
1465
1485
  async function showSettings(ctx) {
1466
1486
  const choice = await ctx.ui.select("Settings", [
1467
1487
  `Max concurrency (current: ${manager.getMaxConcurrent()})`,
@@ -1477,7 +1497,7 @@ ${systemPrompt}
1477
1497
  const n = parseInt(val, 10);
1478
1498
  if (n >= 1) {
1479
1499
  manager.setMaxConcurrent(n);
1480
- ctx.ui.notify(`Max concurrency set to ${n}`, "info");
1500
+ notifyApplied(ctx, `Max concurrency set to ${n}`);
1481
1501
  }
1482
1502
  else {
1483
1503
  ctx.ui.notify("Must be a positive integer.", "warning");
@@ -1490,11 +1510,11 @@ ${systemPrompt}
1490
1510
  const n = parseInt(val, 10);
1491
1511
  if (n === 0) {
1492
1512
  setDefaultMaxTurns(undefined);
1493
- ctx.ui.notify("Default max turns set to unlimited", "info");
1513
+ notifyApplied(ctx, "Default max turns set to unlimited");
1494
1514
  }
1495
1515
  else if (n >= 1) {
1496
1516
  setDefaultMaxTurns(n);
1497
- ctx.ui.notify(`Default max turns set to ${n}`, "info");
1517
+ notifyApplied(ctx, `Default max turns set to ${n}`);
1498
1518
  }
1499
1519
  else {
1500
1520
  ctx.ui.notify("Must be 0 (unlimited) or a positive integer.", "warning");
@@ -1507,7 +1527,7 @@ ${systemPrompt}
1507
1527
  const n = parseInt(val, 10);
1508
1528
  if (n >= 1) {
1509
1529
  setGraceTurns(n);
1510
- ctx.ui.notify(`Grace turns set to ${n}`, "info");
1530
+ notifyApplied(ctx, `Grace turns set to ${n}`);
1511
1531
  }
1512
1532
  else {
1513
1533
  ctx.ui.notify("Must be a positive integer.", "warning");
@@ -1523,10 +1543,18 @@ ${systemPrompt}
1523
1543
  if (val) {
1524
1544
  const mode = val.split(" ")[0];
1525
1545
  setDefaultJoinMode(mode);
1526
- ctx.ui.notify(`Default join mode set to ${mode}`, "info");
1546
+ notifyApplied(ctx, `Default join mode set to ${mode}`);
1527
1547
  }
1528
1548
  }
1529
1549
  }
1550
+ // Persist the current snapshot, emit `subagents:settings_changed`, and surface
1551
+ // the right toast. Successful saves show info; persistence failures downgrade
1552
+ // to warning so users aren't silently reverted on restart. Event fires regardless
1553
+ // of outcome so listeners see the in-memory change.
1554
+ function notifyApplied(ctx, successMsg) {
1555
+ const { message, level } = saveAndEmitChanged(snapshotSettings(), successMsg, (event, payload) => pi.events.emit(event, payload));
1556
+ ctx.ui.notify(message, level);
1557
+ }
1530
1558
  pi.registerCommand("agents", {
1531
1559
  description: "Manage agents",
1532
1560
  handler: async (_args, ctx) => { await showAgentsMenu(ctx); },
@@ -0,0 +1,56 @@
1
+ import type { JoinMode } from "./types.js";
2
+ export interface SubagentsSettings {
3
+ maxConcurrent?: number;
4
+ /**
5
+ * 0 = unlimited — the extension's single source of truth for that convention:
6
+ * `normalizeMaxTurns()` in agent-runner.ts treats 0 → `undefined`, and the
7
+ * `/agents` → Settings input prompt explicitly says "0 = unlimited".
8
+ */
9
+ defaultMaxTurns?: number;
10
+ graceTurns?: number;
11
+ defaultJoinMode?: JoinMode;
12
+ }
13
+ /** Setter hooks used by applySettings to wire persisted values into in-memory state. */
14
+ export interface SettingsAppliers {
15
+ setMaxConcurrent: (n: number) => void;
16
+ setDefaultMaxTurns: (n: number) => void;
17
+ setGraceTurns: (n: number) => void;
18
+ setDefaultJoinMode: (mode: JoinMode) => void;
19
+ }
20
+ /** Emit callback — a subset of `pi.events.emit` to keep helpers testable. */
21
+ export type SettingsEmit = (event: string, payload: unknown) => void;
22
+ /** Load merged settings: global provides defaults, project overrides. */
23
+ export declare function loadSettings(cwd?: string): SubagentsSettings;
24
+ /**
25
+ * Write project-local settings. Global is never touched from code.
26
+ * Returns `true` on success, `false` if the write (or mkdir) failed so the
27
+ * caller can surface a warning — persistence isn't fatal but isn't silent.
28
+ */
29
+ export declare function saveSettings(s: SubagentsSettings, cwd?: string): boolean;
30
+ /** Apply persisted settings to the in-memory state via caller-supplied setters. */
31
+ export declare function applySettings(s: SubagentsSettings, appliers: SettingsAppliers): void;
32
+ /**
33
+ * Format the user-facing toast for a settings mutation. Pure function —
34
+ * routes the success/failure of `saveSettings` into the right message + level
35
+ * so the UI layer (index.ts) stays a thin wire between input and notification.
36
+ */
37
+ export declare function persistToastFor(successMsg: string, persisted: boolean): {
38
+ message: string;
39
+ level: "info" | "warning";
40
+ };
41
+ /**
42
+ * Load merged settings, apply them to in-memory state, and emit the
43
+ * `subagents:settings_loaded` lifecycle event. Returns the loaded settings so
44
+ * callers can log/inspect. Extension init wires this once.
45
+ */
46
+ export declare function applyAndEmitLoaded(appliers: SettingsAppliers, emit: SettingsEmit, cwd?: string): SubagentsSettings;
47
+ /**
48
+ * Persist a settings snapshot, emit the `subagents:settings_changed` event
49
+ * (regardless of persist outcome so listeners see the in-memory change), and
50
+ * return the toast the UI should display. Event payload carries the `persisted`
51
+ * flag so listeners can react to write failures.
52
+ */
53
+ export declare function saveAndEmitChanged(snapshot: SubagentsSettings, successMsg: string, emit: SettingsEmit, cwd?: string): {
54
+ message: string;
55
+ level: "info" | "warning";
56
+ };