@tintinweb/pi-subagents 0.6.0 → 0.6.2

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,21 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.6.2] - 2026-04-28
11
+
12
+ ### Fixed
13
+ - **`Agent` tool fails on Windows with `ENOENT` creating output directory** ([#27](https://github.com/tintinweb/pi-subagents/issues/27) — thanks [@sixnathan](https://github.com/sixnathan) for the diagnosis). The cwd-encoding regex in `output-file.ts` only handled POSIX `/` separators, so on Windows `cwd = "C:\\Users\\foo\\project"` survived unchanged and `path.join(tmpRoot, encoded, …)` produced an invalid nested-absolute path. Now extracts a small `encodeCwd()` helper that handles both `/` and `\\` separators, strips the Windows drive-letter prefix, and preserves UNC server/share segments. The `chmodSync(root, 0o700)` call is also wrapped in a try/catch that swallows errors only on Windows (where chmod is a no-op and can throw on some filesystems); on Unix the error still propagates so umask-defeating `0o700` enforcement is preserved.
14
+
15
+ ## [0.6.1] - 2026-04-25
16
+
17
+ ### Added
18
+ - **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.
19
+ - **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).
20
+
21
+ ### Fixed
22
+ - **`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.
23
+ - **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.
24
+
10
25
  ## [0.6.0] - 2026-04-24
11
26
 
12
27
  > **⚠️ 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.
@@ -359,6 +374,9 @@ Initial release.
359
374
  - **Thinking level** — per-agent extended thinking control
360
375
  - **`/agent` and `/agents` commands**
361
376
 
377
+ [0.6.2]: https://github.com/tintinweb/pi-subagents/compare/v0.6.1...v0.6.2
378
+ [0.6.1]: https://github.com/tintinweb/pi-subagents/compare/v0.6.0...v0.6.1
379
+ [0.6.0]: https://github.com/tintinweb/pi-subagents/compare/v0.5.2...v0.6.0
362
380
  [0.5.2]: https://github.com/tintinweb/pi-subagents/compare/v0.5.1...v0.5.2
363
381
  [0.5.1]: https://github.com/tintinweb/pi-subagents/compare/v0.5.0...v0.5.1
364
382
  [0.5.0]: https://github.com/tintinweb/pi-subagents/compare/v0.4.9...v0.5.0
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,10 +163,9 @@ 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
- | `isolation` | — | `worktree`: run in a temporary git worktree for full repo isolation |
170
169
  | `isolated` | `false` | No extension/MCP tools, only built-in |
171
170
  | `enabled` | `true` | Set to `false` to disable an agent (useful for hiding a default agent per-project) |
172
171
 
@@ -272,6 +271,31 @@ When background agents complete, they notify the main agent. The **join mode** c
272
271
  **Configuration:**
273
272
  - Configure join mode in `/agents` → Settings → Join mode
274
273
 
274
+ ## Persistent Settings
275
+
276
+ 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:
277
+
278
+ - **Global:** `~/.pi/agent/subagents.json` — your machine-wide defaults. Edit by hand; the `/agents` menu never writes here.
279
+ - **Project:** `<cwd>/.pi/subagents.json` — per-project overrides. Written by `/agents` → Settings.
280
+
281
+ **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`).
282
+
283
+ **Example — global defaults for a beefy machine:**
284
+
285
+ ```bash
286
+ mkdir -p ~/.pi/agent
287
+ cat > ~/.pi/agent/subagents.json <<'EOF'
288
+ {
289
+ "maxConcurrent": 16,
290
+ "graceTurns": 10
291
+ }
292
+ EOF
293
+ ```
294
+
295
+ Every project now starts with concurrency 16 and grace 10, without ever touching the menu. Individual projects can still override via `/agents` → Settings.
296
+
297
+ **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)`.
298
+
275
299
  ## Events
276
300
 
277
301
  Agent lifecycle events are emitted via `pi.events.emit()` so other extensions can react:
@@ -284,6 +308,8 @@ Agent lifecycle events are emitted via `pi.events.emit()` so other extensions ca
284
308
  | `subagents:failed` | Agent errored, stopped, or aborted | same as completed + `error`, `status` |
285
309
  | `subagents:steered` | Steering message sent | `id`, `message` |
286
310
  | `subagents:ready` | Extension loaded and RPC handlers registered | — |
311
+ | `subagents:settings_loaded` | Persisted settings applied at extension init | `settings` (merged global + project) |
312
+ | `subagents:settings_changed` | `/agents` → Settings mutation was applied | `settings`, `persisted` (`boolean` — `false` on write failure) |
287
313
 
288
314
  ## Cross-Extension RPC
289
315
 
@@ -157,7 +157,12 @@ export async function runAgent(ctx, type, prompt, options) {
157
157
  // Still pass noSkills: true since we don't need the skill loader to load them again.
158
158
  const noSkills = skills === false || Array.isArray(skills);
159
159
  const agentDir = getAgentDir();
160
- // Load extensions/skills: true or string[] → load; false → don't
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).
161
166
  const loader = new DefaultResourceLoader({
162
167
  cwd: effectiveCwd,
163
168
  agentDir,
@@ -165,7 +170,9 @@ export async function runAgent(ctx, type, prompt, options) {
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
@@ -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.js CHANGED
@@ -10,9 +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";
15
- import { defineTool } from "@mariozechner/pi-coding-agent";
14
+ import { defineTool, getAgentDir } from "@mariozechner/pi-coding-agent";
16
15
  import { Text } from "@mariozechner/pi-tui";
17
16
  import { Type } from "@sinclair/typebox";
18
17
  import { AgentManager } from "./agent-manager.js";
@@ -24,6 +23,7 @@ import { GroupJoinManager } from "./group-join.js";
24
23
  import { resolveAgentInvocationConfig, resolveJoinMode } from "./invocation-config.js";
25
24
  import { resolveModel } from "./model-resolver.js";
26
25
  import { createOutputFilePath, streamToOutputFile, writeInitialEntry } from "./output-file.js";
26
+ import { applyAndEmitLoaded, saveAndEmitChanged } from "./settings.js";
27
27
  import { AgentWidget, describeActivity, formatDuration, formatMs, formatTokens, formatTurns, getDisplayName, getPromptModeLabel, SPINNER, } from "./ui/agent-widget.js";
28
28
  // ---- Shared helpers ----
29
29
  /** Tool execute return value for a text response. */
@@ -478,7 +478,7 @@ export default function (pi) {
478
478
  ...defaultDescs,
479
479
  ...(customDescs.length > 0 ? ["", "Custom agents:", ...customDescs] : []),
480
480
  "",
481
- "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.`,
482
482
  ].join("\n");
483
483
  };
484
484
  /** Derive a short model label from a model string. */
@@ -489,6 +489,15 @@ export default function (pi) {
489
489
  return name.replace(/-\d{8}$/, "");
490
490
  }
491
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));
492
501
  // ---- Agent tool ----
493
502
  pi.registerTool(defineTool({
494
503
  name: "Agent",
@@ -522,7 +531,7 @@ Guidelines:
522
531
  description: "A short (3-5 word) description of the task (shown in UI).",
523
532
  }),
524
533
  subagent_type: Type.String({
525
- 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.`,
526
535
  }),
527
536
  model: Type.Optional(Type.String({
528
537
  description: 'Optional model override. Accepts "provider/modelId" or fuzzy name (e.g. "haiku", "sonnet"). Omit to use the agent type\'s default.',
@@ -952,7 +961,7 @@ Guidelines:
952
961
  }));
953
962
  // ---- /agents interactive menu ----
954
963
  const projectAgentsDir = () => join(process.cwd(), ".pi", "agents");
955
- const personalAgentsDir = () => join(homedir(), ".pi", "agent", "agents");
964
+ const personalAgentsDir = () => join(getAgentDir(), "agents");
956
965
  /** Find the file path of a custom agent by name (project first, then global). */
957
966
  function findAgentFile(name) {
958
967
  const projectPath = join(projectAgentsDir(), `${name}.md`);
@@ -1180,7 +1189,7 @@ Guidelines:
1180
1189
  async function ejectAgent(ctx, name, cfg) {
1181
1190
  const location = await ctx.ui.select("Choose location", [
1182
1191
  "Project (.pi/agents/)",
1183
- "Personal (~/.pi/agent/agents/)",
1192
+ `Personal (${personalAgentsDir()})`,
1184
1193
  ]);
1185
1194
  if (!location)
1186
1195
  return;
@@ -1251,7 +1260,7 @@ Guidelines:
1251
1260
  // No file (built-in default) — create a stub
1252
1261
  const location = await ctx.ui.select("Choose location", [
1253
1262
  "Project (.pi/agents/)",
1254
- "Personal (~/.pi/agent/agents/)",
1263
+ `Personal (${personalAgentsDir()})`,
1255
1264
  ]);
1256
1265
  if (!location)
1257
1266
  return;
@@ -1286,7 +1295,7 @@ Guidelines:
1286
1295
  async function showCreateWizard(ctx) {
1287
1296
  const location = await ctx.ui.select("Choose location", [
1288
1297
  "Project (.pi/agents/)",
1289
- "Personal (~/.pi/agent/agents/)",
1298
+ `Personal (${personalAgentsDir()})`,
1290
1299
  ]);
1291
1300
  if (!location)
1292
1301
  return;
@@ -1463,6 +1472,16 @@ ${systemPrompt}
1463
1472
  reloadCustomAgents();
1464
1473
  ctx.ui.notify(`Created ${targetPath}`, "info");
1465
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
+ }
1466
1485
  async function showSettings(ctx) {
1467
1486
  const choice = await ctx.ui.select("Settings", [
1468
1487
  `Max concurrency (current: ${manager.getMaxConcurrent()})`,
@@ -1478,7 +1497,7 @@ ${systemPrompt}
1478
1497
  const n = parseInt(val, 10);
1479
1498
  if (n >= 1) {
1480
1499
  manager.setMaxConcurrent(n);
1481
- ctx.ui.notify(`Max concurrency set to ${n}`, "info");
1500
+ notifyApplied(ctx, `Max concurrency set to ${n}`);
1482
1501
  }
1483
1502
  else {
1484
1503
  ctx.ui.notify("Must be a positive integer.", "warning");
@@ -1491,11 +1510,11 @@ ${systemPrompt}
1491
1510
  const n = parseInt(val, 10);
1492
1511
  if (n === 0) {
1493
1512
  setDefaultMaxTurns(undefined);
1494
- ctx.ui.notify("Default max turns set to unlimited", "info");
1513
+ notifyApplied(ctx, "Default max turns set to unlimited");
1495
1514
  }
1496
1515
  else if (n >= 1) {
1497
1516
  setDefaultMaxTurns(n);
1498
- ctx.ui.notify(`Default max turns set to ${n}`, "info");
1517
+ notifyApplied(ctx, `Default max turns set to ${n}`);
1499
1518
  }
1500
1519
  else {
1501
1520
  ctx.ui.notify("Must be 0 (unlimited) or a positive integer.", "warning");
@@ -1508,7 +1527,7 @@ ${systemPrompt}
1508
1527
  const n = parseInt(val, 10);
1509
1528
  if (n >= 1) {
1510
1529
  setGraceTurns(n);
1511
- ctx.ui.notify(`Grace turns set to ${n}`, "info");
1530
+ notifyApplied(ctx, `Grace turns set to ${n}`);
1512
1531
  }
1513
1532
  else {
1514
1533
  ctx.ui.notify("Must be a positive integer.", "warning");
@@ -1524,10 +1543,18 @@ ${systemPrompt}
1524
1543
  if (val) {
1525
1544
  const mode = val.split(" ")[0];
1526
1545
  setDefaultJoinMode(mode);
1527
- ctx.ui.notify(`Default join mode set to ${mode}`, "info");
1546
+ notifyApplied(ctx, `Default join mode set to ${mode}`);
1528
1547
  }
1529
1548
  }
1530
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
+ }
1531
1558
  pi.registerCommand("agents", {
1532
1559
  description: "Manage agents",
1533
1560
  handler: async (_args, ctx) => { await showAgentsMenu(ctx); },
@@ -5,6 +5,13 @@
5
5
  * matching Claude Code's task output file format.
6
6
  */
7
7
  import type { AgentSession } from "@mariozechner/pi-coding-agent";
8
+ /**
9
+ * Encode a cwd path as a filesystem-safe directory name. Handles:
10
+ * - POSIX: "/home/user/project" → "home-user-project"
11
+ * - Windows: "C:\Users\foo\project" → "Users-foo-project"
12
+ * - UNC: "\\\\server\\share\\project" → "server-share-project"
13
+ */
14
+ export declare function encodeCwd(cwd: string): string;
8
15
  /** Create the output file path, ensuring the directory exists.
9
16
  * Mirrors Claude Code's layout: /tmp/{prefix}-{uid}/{encoded-cwd}/{sessionId}/tasks/{agentId}.output */
10
17
  export declare function createOutputFilePath(cwd: string, agentId: string, sessionId: string): string;
@@ -7,13 +7,33 @@
7
7
  import { appendFileSync, chmodSync, mkdirSync, writeFileSync } from "node:fs";
8
8
  import { tmpdir } from "node:os";
9
9
  import { join } from "node:path";
10
+ /**
11
+ * Encode a cwd path as a filesystem-safe directory name. Handles:
12
+ * - POSIX: "/home/user/project" → "home-user-project"
13
+ * - Windows: "C:\Users\foo\project" → "Users-foo-project"
14
+ * - UNC: "\\\\server\\share\\project" → "server-share-project"
15
+ */
16
+ export function encodeCwd(cwd) {
17
+ return cwd
18
+ .replace(/[/\\]/g, "-") // both separators → dash
19
+ .replace(/^[A-Za-z]:-/, "") // strip Windows drive prefix ("C:-")
20
+ .replace(/^-+/, ""); // strip leading dashes (POSIX root, UNC)
21
+ }
10
22
  /** Create the output file path, ensuring the directory exists.
11
23
  * Mirrors Claude Code's layout: /tmp/{prefix}-{uid}/{encoded-cwd}/{sessionId}/tasks/{agentId}.output */
12
24
  export function createOutputFilePath(cwd, agentId, sessionId) {
13
- const encoded = cwd.replace(/\//g, "-").replace(/^-/, "");
25
+ const encoded = encodeCwd(cwd);
14
26
  const root = join(tmpdir(), `pi-subagents-${process.getuid?.() ?? 0}`);
15
27
  mkdirSync(root, { recursive: true, mode: 0o700 });
16
- chmodSync(root, 0o700);
28
+ // chmod is a no-op on Windows and throws on some Windows filesystems.
29
+ // On Unix we still want to enforce 0o700 past umask, so only swallow on Windows.
30
+ try {
31
+ chmodSync(root, 0o700);
32
+ }
33
+ catch (err) {
34
+ if (process.platform !== "win32")
35
+ throw err;
36
+ }
17
37
  const dir = join(root, encoded, sessionId, "tasks");
18
38
  mkdirSync(dir, { recursive: true });
19
39
  return join(dir, `${agentId}.output`);
@@ -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
+ };
@@ -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.6.0",
3
+ "version": "0.6.2",
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.70.2",
25
- "@mariozechner/pi-coding-agent": "^0.70.2",
26
- "@mariozechner/pi-tui": "^0.70.2",
24
+ "@mariozechner/pi-ai": "^0.70.5",
25
+ "@mariozechner/pi-coding-agent": "^0.70.5",
26
+ "@mariozechner/pi-tui": "^0.70.5",
27
27
  "@sinclair/typebox": "latest"
28
28
  },
29
29
  "scripts": {
@@ -38,7 +38,7 @@
38
38
  "devDependencies": {
39
39
  "@biomejs/biome": "^2.3.5",
40
40
  "@types/node": "^25.5.0",
41
- "typescript": "^5.0.0",
41
+ "typescript": "^6.0.0",
42
42
  "vitest": "^4.0.18"
43
43
  },
44
44
  "pi": {
@@ -233,7 +233,12 @@ export async function runAgent(
233
233
 
234
234
  const agentDir = getAgentDir();
235
235
 
236
- // Load extensions/skills: true or string[] → load; false → don't
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).
237
242
  const loader = new DefaultResourceLoader({
238
243
  cwd: effectiveCwd,
239
244
  agentDir,
@@ -241,7 +246,9 @@ export async function runAgent(
241
246
  noSkills,
242
247
  noPromptTemplates: true,
243
248
  noThemes: true,
249
+ noContextFiles: true,
244
250
  systemPromptOverride: () => systemPrompt,
251
+ appendSystemPromptOverride: () => [],
245
252
  });
246
253
  await loader.reload();
247
254
 
@@ -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 { defineTool, type ExtensionAPI, type ExtensionCommandContext, type 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,
@@ -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,6 +548,19 @@ 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
566
  pi.registerTool(defineTool({
@@ -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({
@@ -1085,7 +1098,7 @@ Guidelines:
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); },
@@ -10,13 +10,32 @@ import { tmpdir } from "node:os";
10
10
  import { join } from "node:path";
11
11
  import type { AgentSession, AgentSessionEvent } from "@mariozechner/pi-coding-agent";
12
12
 
13
+ /**
14
+ * Encode a cwd path as a filesystem-safe directory name. Handles:
15
+ * - POSIX: "/home/user/project" → "home-user-project"
16
+ * - Windows: "C:\Users\foo\project" → "Users-foo-project"
17
+ * - UNC: "\\\\server\\share\\project" → "server-share-project"
18
+ */
19
+ export function encodeCwd(cwd: string): string {
20
+ return cwd
21
+ .replace(/[/\\]/g, "-") // both separators → dash
22
+ .replace(/^[A-Za-z]:-/, "") // strip Windows drive prefix ("C:-")
23
+ .replace(/^-+/, ""); // strip leading dashes (POSIX root, UNC)
24
+ }
25
+
13
26
  /** Create the output file path, ensuring the directory exists.
14
27
  * Mirrors Claude Code's layout: /tmp/{prefix}-{uid}/{encoded-cwd}/{sessionId}/tasks/{agentId}.output */
15
28
  export function createOutputFilePath(cwd: string, agentId: string, sessionId: string): string {
16
- const encoded = cwd.replace(/\//g, "-").replace(/^-/, "");
29
+ const encoded = encodeCwd(cwd);
17
30
  const root = join(tmpdir(), `pi-subagents-${process.getuid?.() ?? 0}`);
18
31
  mkdirSync(root, { recursive: true, mode: 0o700 });
19
- chmodSync(root, 0o700);
32
+ // chmod is a no-op on Windows and throws on some Windows filesystems.
33
+ // On Unix we still want to enforce 0o700 past umask, so only swallow on Windows.
34
+ try {
35
+ chmodSync(root, 0o700);
36
+ } catch (err) {
37
+ if (process.platform !== "win32") throw err;
38
+ }
20
39
  const dir = join(root, encoded, sessionId, "tasks");
21
40
  mkdirSync(dir, { recursive: true });
22
41
  return join(dir, `${agentId}.output`);
@@ -0,0 +1,172 @@
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
+
5
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
6
+ import { dirname, join } from "node:path";
7
+ import { getAgentDir } from "@mariozechner/pi-coding-agent";
8
+ import type { JoinMode } from "./types.js";
9
+
10
+ export interface SubagentsSettings {
11
+ maxConcurrent?: number;
12
+ /**
13
+ * 0 = unlimited — the extension's single source of truth for that convention:
14
+ * `normalizeMaxTurns()` in agent-runner.ts treats 0 → `undefined`, and the
15
+ * `/agents` → Settings input prompt explicitly says "0 = unlimited".
16
+ */
17
+ defaultMaxTurns?: number;
18
+ graceTurns?: number;
19
+ defaultJoinMode?: JoinMode;
20
+ }
21
+
22
+ /** Setter hooks used by applySettings to wire persisted values into in-memory state. */
23
+ export interface SettingsAppliers {
24
+ setMaxConcurrent: (n: number) => void;
25
+ setDefaultMaxTurns: (n: number) => void;
26
+ setGraceTurns: (n: number) => void;
27
+ setDefaultJoinMode: (mode: JoinMode) => void;
28
+ }
29
+
30
+ /** Emit callback — a subset of `pi.events.emit` to keep helpers testable. */
31
+ export type SettingsEmit = (event: string, payload: unknown) => void;
32
+
33
+ const VALID_JOIN_MODES: ReadonlySet<string> = new Set<JoinMode>(["async", "group", "smart"]);
34
+
35
+ // Sanity ceilings — prevent hand-edited configs from asking for values that
36
+ // make no operational sense (e.g. 1e6 concurrent subagents). Permissive enough
37
+ // that any realistic power-user setting passes through.
38
+ const MAX_CONCURRENT_CEILING = 1024;
39
+ const MAX_TURNS_CEILING = 10_000;
40
+ const GRACE_TURNS_CEILING = 1_000;
41
+
42
+ /** Drop fields that don't match the expected shape. Silent — garbage becomes absent. */
43
+ function sanitize(raw: unknown): SubagentsSettings {
44
+ if (!raw || typeof raw !== "object") return {};
45
+ const r = raw as Record<string, unknown>;
46
+ const out: SubagentsSettings = {};
47
+ if (
48
+ Number.isInteger(r.maxConcurrent) &&
49
+ (r.maxConcurrent as number) >= 1 &&
50
+ (r.maxConcurrent as number) <= MAX_CONCURRENT_CEILING
51
+ ) {
52
+ out.maxConcurrent = r.maxConcurrent as number;
53
+ }
54
+ if (
55
+ Number.isInteger(r.defaultMaxTurns) &&
56
+ (r.defaultMaxTurns as number) >= 0 &&
57
+ (r.defaultMaxTurns as number) <= MAX_TURNS_CEILING
58
+ ) {
59
+ out.defaultMaxTurns = r.defaultMaxTurns as number;
60
+ }
61
+ if (
62
+ Number.isInteger(r.graceTurns) &&
63
+ (r.graceTurns as number) >= 1 &&
64
+ (r.graceTurns as number) <= GRACE_TURNS_CEILING
65
+ ) {
66
+ out.graceTurns = r.graceTurns as number;
67
+ }
68
+ if (typeof r.defaultJoinMode === "string" && VALID_JOIN_MODES.has(r.defaultJoinMode)) {
69
+ out.defaultJoinMode = r.defaultJoinMode as JoinMode;
70
+ }
71
+ return out;
72
+ }
73
+
74
+ function globalPath(): string {
75
+ return join(getAgentDir(), "subagents.json");
76
+ }
77
+
78
+ function projectPath(cwd: string): string {
79
+ return join(cwd, ".pi", "subagents.json");
80
+ }
81
+
82
+ /**
83
+ * Read a settings file. Missing file is silent (returns `{}`). A file that
84
+ * exists but can't be parsed emits a warning to stderr so users aren't
85
+ * silently reverted to defaults — and still returns `{}` so startup proceeds.
86
+ */
87
+ function readSettingsFile(path: string): SubagentsSettings {
88
+ if (!existsSync(path)) return {};
89
+ try {
90
+ return sanitize(JSON.parse(readFileSync(path, "utf-8")));
91
+ } catch (err) {
92
+ const reason = err instanceof Error ? err.message : String(err);
93
+ console.warn(`[pi-subagents] Ignoring malformed settings at ${path}: ${reason}`);
94
+ return {};
95
+ }
96
+ }
97
+
98
+ /** Load merged settings: global provides defaults, project overrides. */
99
+ export function loadSettings(cwd: string = process.cwd()): SubagentsSettings {
100
+ return { ...readSettingsFile(globalPath()), ...readSettingsFile(projectPath(cwd)) };
101
+ }
102
+
103
+ /**
104
+ * Write project-local settings. Global is never touched from code.
105
+ * Returns `true` on success, `false` if the write (or mkdir) failed so the
106
+ * caller can surface a warning — persistence isn't fatal but isn't silent.
107
+ */
108
+ export function saveSettings(s: SubagentsSettings, cwd: string = process.cwd()): boolean {
109
+ const path = projectPath(cwd);
110
+ try {
111
+ mkdirSync(dirname(path), { recursive: true });
112
+ writeFileSync(path, JSON.stringify(s, null, 2), "utf-8");
113
+ return true;
114
+ } catch {
115
+ return false;
116
+ }
117
+ }
118
+
119
+ /** Apply persisted settings to the in-memory state via caller-supplied setters. */
120
+ export function applySettings(s: SubagentsSettings, appliers: SettingsAppliers): void {
121
+ if (typeof s.maxConcurrent === "number") appliers.setMaxConcurrent(s.maxConcurrent);
122
+ if (typeof s.defaultMaxTurns === "number") appliers.setDefaultMaxTurns(s.defaultMaxTurns);
123
+ if (typeof s.graceTurns === "number") appliers.setGraceTurns(s.graceTurns);
124
+ if (s.defaultJoinMode) appliers.setDefaultJoinMode(s.defaultJoinMode);
125
+ }
126
+
127
+ /**
128
+ * Format the user-facing toast for a settings mutation. Pure function —
129
+ * routes the success/failure of `saveSettings` into the right message + level
130
+ * so the UI layer (index.ts) stays a thin wire between input and notification.
131
+ */
132
+ export function persistToastFor(
133
+ successMsg: string,
134
+ persisted: boolean,
135
+ ): { message: string; level: "info" | "warning" } {
136
+ return persisted
137
+ ? { message: successMsg, level: "info" }
138
+ : { message: `${successMsg} (session only; failed to persist)`, level: "warning" };
139
+ }
140
+
141
+ /**
142
+ * Load merged settings, apply them to in-memory state, and emit the
143
+ * `subagents:settings_loaded` lifecycle event. Returns the loaded settings so
144
+ * callers can log/inspect. Extension init wires this once.
145
+ */
146
+ export function applyAndEmitLoaded(
147
+ appliers: SettingsAppliers,
148
+ emit: SettingsEmit,
149
+ cwd: string = process.cwd(),
150
+ ): SubagentsSettings {
151
+ const settings = loadSettings(cwd);
152
+ applySettings(settings, appliers);
153
+ emit("subagents:settings_loaded", { settings });
154
+ return settings;
155
+ }
156
+
157
+ /**
158
+ * Persist a settings snapshot, emit the `subagents:settings_changed` event
159
+ * (regardless of persist outcome so listeners see the in-memory change), and
160
+ * return the toast the UI should display. Event payload carries the `persisted`
161
+ * flag so listeners can react to write failures.
162
+ */
163
+ export function saveAndEmitChanged(
164
+ snapshot: SubagentsSettings,
165
+ successMsg: string,
166
+ emit: SettingsEmit,
167
+ cwd: string = process.cwd(),
168
+ ): { message: string; level: "info" | "warning" } {
169
+ const persisted = saveSettings(snapshot, cwd);
170
+ emit("subagents:settings_changed", { settings: snapshot, persisted });
171
+ return persistToastFor(successMsg, persisted);
172
+ }