@yandy0725/pi-subagents 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (61) hide show
  1. package/README.md +155 -0
  2. package/README.zh.md +155 -0
  3. package/index.ts +1 -0
  4. package/package.json +49 -0
  5. package/src/config/agent-types.ts +127 -0
  6. package/src/config/custom-agents.ts +109 -0
  7. package/src/config/default-agents.ts +117 -0
  8. package/src/config/invocation-config.ts +30 -0
  9. package/src/debug.ts +14 -0
  10. package/src/handlers/index.ts +3 -0
  11. package/src/handlers/interrupt.ts +49 -0
  12. package/src/handlers/lifecycle.ts +63 -0
  13. package/src/handlers/tool-start.ts +32 -0
  14. package/src/index.ts +186 -0
  15. package/src/layered-settings.ts +105 -0
  16. package/src/lifecycle/child-lifecycle.ts +88 -0
  17. package/src/lifecycle/concurrency-limiter.ts +55 -0
  18. package/src/lifecycle/create-subagent-session.ts +240 -0
  19. package/src/lifecycle/parent-snapshot.ts +45 -0
  20. package/src/lifecycle/run-listeners.ts +37 -0
  21. package/src/lifecycle/subagent-manager.ts +353 -0
  22. package/src/lifecycle/subagent-session.ts +232 -0
  23. package/src/lifecycle/subagent-state.ts +216 -0
  24. package/src/lifecycle/subagent.ts +498 -0
  25. package/src/lifecycle/turn-limits.ts +13 -0
  26. package/src/lifecycle/usage.ts +65 -0
  27. package/src/lifecycle/workspace-bracket.ts +59 -0
  28. package/src/lifecycle/workspace.ts +47 -0
  29. package/src/observation/composite-subagent-observer.ts +49 -0
  30. package/src/observation/notification-state.ts +27 -0
  31. package/src/observation/notification.ts +186 -0
  32. package/src/observation/record-observer.ts +75 -0
  33. package/src/observation/renderer.ts +63 -0
  34. package/src/observation/subagent-events-observer.ts +94 -0
  35. package/src/runtime.ts +77 -0
  36. package/src/service/service-adapter.ts +131 -0
  37. package/src/service/service.ts +123 -0
  38. package/src/session/content-items.ts +51 -0
  39. package/src/session/context.ts +78 -0
  40. package/src/session/conversation.ts +44 -0
  41. package/src/session/env.ts +40 -0
  42. package/src/session/model-resolver.ts +121 -0
  43. package/src/session/prompts.ts +83 -0
  44. package/src/session/session-config.ts +172 -0
  45. package/src/session/session-dir.ts +38 -0
  46. package/src/settings.ts +227 -0
  47. package/src/tools/agent-tool.ts +220 -0
  48. package/src/tools/background-spawner.ts +66 -0
  49. package/src/tools/foreground-runner.ts +114 -0
  50. package/src/tools/get-result-tool.ts +120 -0
  51. package/src/tools/helpers.ts +105 -0
  52. package/src/tools/result-renderer.ts +109 -0
  53. package/src/tools/spawn-config.ts +150 -0
  54. package/src/tools/steer-tool.ts +90 -0
  55. package/src/types.ts +115 -0
  56. package/src/ui/agent-widget.ts +311 -0
  57. package/src/ui/display.ts +174 -0
  58. package/src/ui/session-navigation.ts +147 -0
  59. package/src/ui/session-navigator.ts +406 -0
  60. package/src/ui/subagents-settings.ts +77 -0
  61. package/src/ui/widget-renderer.ts +296 -0
@@ -0,0 +1,83 @@
1
+ /**
2
+ * prompts.ts — System prompt builder for agents.
3
+ */
4
+
5
+ import type { EnvInfo } from "../session/env";
6
+ import type { AgentPromptConfig } from "../types";
7
+
8
+ /**
9
+ * Build the system prompt for an agent from its config.
10
+ *
11
+ * Both modes place the shared/stable parent prompt (or `genericBase` when no
12
+ * parent is available) first so the LLM's KV cache can reuse the inherited
13
+ * prefix across all subagent invocations.
14
+ *
15
+ * - "replace" mode: parent/genericBase + active_agent tag + env header +
16
+ * config.systemPrompt. No `<sub_agent_context>` bridge and no
17
+ * `<agent_instructions>` wrapper — the custom prompt has full control and
18
+ * the final say.
19
+ * - "append" mode: parent/genericBase + sub-agent context bridge +
20
+ * active_agent tag + env header + config.systemPrompt (wrapped in
21
+ * `<agent_instructions>` when non-empty).
22
+ * - "append" with empty systemPrompt: pure parent clone.
23
+ *
24
+ * Both modes include an `<active_agent name="${config.name}"/>` tag so
25
+ * downstream extensions (e.g. `@yandy0725/pi-permission-system`) can resolve
26
+ * per-agent policy inside the child session by parsing the system prompt.
27
+ * The tag follows the cacheable parent prefix in both modes.
28
+ *
29
+ * @param parentSystemPrompt The parent agent's effective system prompt.
30
+ */
31
+ export function buildAgentPrompt(
32
+ config: AgentPromptConfig,
33
+ cwd: string,
34
+ env: EnvInfo,
35
+ parentSystemPrompt?: string,
36
+ ): string {
37
+ const activeAgentTag = `<active_agent name="${config.name}"/>\n\n`;
38
+
39
+ const envBlock = `# Environment
40
+ Working directory: ${cwd}
41
+ ${env.isGitRepo ? `Git repository: yes\nBranch: ${env.branch}` : "Not a git repository"}
42
+ Platform: ${env.platform}`;
43
+
44
+ const identity = parentSystemPrompt ?? genericBase;
45
+
46
+ if (config.promptMode === "append") {
47
+ const bridge = `<sub_agent_context>
48
+ You are operating as a sub-agent invoked to handle a specific task.
49
+ - Use the read tool instead of cat/head/tail
50
+ - Use the edit tool instead of sed/awk
51
+ - Use the write tool instead of echo/heredoc
52
+ - Use the find tool instead of bash find/ls for file search
53
+ - Use the grep tool instead of bash grep/rg for content search
54
+ - Make independent tool calls in parallel
55
+ - Use absolute file paths
56
+ - Do not use emojis
57
+ - Be concise but complete
58
+ </sub_agent_context>`;
59
+
60
+ const customSection = config.systemPrompt.trim()
61
+ ? `\n\n<agent_instructions>\n${config.systemPrompt}\n</agent_instructions>`
62
+ : "";
63
+
64
+ // Place shared/stable content first so the LLM's KV cache can reuse the
65
+ // inherited prefix across all subagent invocations. The parent prompt is
66
+ // placed verbatim (no wrapper tag) so it forms an identical byte prefix
67
+ // with the parent session, maximising KV cache hits. The <active_agent>
68
+ // tag and env block vary per call and are placed after the cached prefix.
69
+ return identity + "\n\n" + bridge + "\n\n" + activeAgentTag + envBlock + customSection;
70
+ }
71
+
72
+ // "replace" mode — parent/genericBase prefix first for KV cache reuse, then
73
+ // the active_agent tag, env block, and the config's full system prompt.
74
+ // Unlike append mode, no <sub_agent_context> bridge or <agent_instructions>
75
+ // wrapper is injected — the custom prompt retains full control.
76
+ return identity + "\n\n" + activeAgentTag + envBlock + "\n\n" + config.systemPrompt;
77
+ }
78
+
79
+ /** Fallback base prompt when parent system prompt is unavailable (both modes). */
80
+ const genericBase = `# Role
81
+ You are a general-purpose coding agent for complex, multi-step tasks.
82
+ You have full access to read, write, edit files, and execute commands.
83
+ Do what has been asked; nothing more, nothing less.`;
@@ -0,0 +1,172 @@
1
+ /**
2
+ * session-config.ts — Pure configuration assembler for agent sessions.
3
+ *
4
+ * `assembleSessionConfig()` is the pure assembly core called by
5
+ * `createSubagentSession()`. It accepts resolved inputs (agent type, narrow
6
+ * context, run options, env info) and returns everything the factory needs to
7
+ * create the SDK session — without importing or constructing any Pi SDK types.
8
+ *
9
+ * The only async IO in the assembly phase (`detectEnv`) is handled by the caller
10
+ * before invoking this function, keeping the assembler synchronous.
11
+ */
12
+
13
+ import type { AgentConfigLookup } from "../config/agent-types";
14
+ import type { EnvInfo } from "../session/env";
15
+ import type { AgentPromptConfig, SubagentType, ThinkingLevel } from "../types";
16
+
17
+ // ── Public interfaces ────────────────────────────────────────────────────────
18
+
19
+ /**
20
+ * IO collaborators injected into `assembleSessionConfig`.
21
+ *
22
+ * Bundling the IO-touching (or promptly testable) function into a single
23
+ * interface keeps the assembler free of direct module imports and makes it
24
+ * trivially testable without `vi.mock()` — callers inject real implementations
25
+ * at the edge (`create-subagent-session.ts`) or stubs in tests.
26
+ */
27
+ export interface AssemblerIO {
28
+ buildAgentPrompt: (config: AgentPromptConfig, cwd: string, env: EnvInfo, parentPrompt?: string) => string;
29
+ }
30
+
31
+ /**
32
+ * Narrow context the assembler reads from the parent session.
33
+ * Tests construct plain objects satisfying this interface — no SDK mocking needed.
34
+ *
35
+ * Models are treated as opaque handles: the assembler never inspects their
36
+ * internals, only passes them through. `getAvailable` returns just enough
37
+ * structural information ({ provider, id }) for the availability check in
38
+ * `resolveDefaultModel`.
39
+ */
40
+ export interface AssemblerContext {
41
+ /** Parent working directory (overridable via options.cwd). */
42
+ cwd: string;
43
+ /** Parent's effective system prompt (for append-mode agents). */
44
+ parentSystemPrompt: string;
45
+ /** Parent's current model instance (fallback when agent config has no model). */
46
+ parentModel?: unknown;
47
+ /** Model registry for resolving config.model strings. */
48
+ modelRegistry: {
49
+ find(provider: string, modelId: string): unknown;
50
+ getAvailable?(): Array<{ provider: string; id: string }>;
51
+ };
52
+ }
53
+
54
+ /**
55
+ * Narrow slice of per-spawn execution fields consumed by the assembler.
56
+ * All fields are optional — callers pass only what they have.
57
+ */
58
+ export interface AssemblerOptions {
59
+ /** Override working directory (e.g. for worktree isolation). */
60
+ cwd?: string;
61
+ /** Explicit model override — wins over agentConfig.model and parent model. */
62
+ model?: unknown;
63
+ /** Explicit thinking level — wins over agentConfig.thinking. */
64
+ thinkingLevel?: ThinkingLevel;
65
+ }
66
+
67
+ /**
68
+ * Assembled configuration returned to `createSubagentSession()`.
69
+ * Contains everything needed to create the SDK session and filter tools —
70
+ * with no SDK object references.
71
+ */
72
+ export interface SessionConfig {
73
+ /** Resolved working directory (`options.cwd ?? ctx.cwd`). */
74
+ effectiveCwd: string;
75
+ /** Fully-assembled system prompt string (ready for `systemPromptOverride`). */
76
+ systemPrompt: string;
77
+ /** Built-in tool name allowlist for this agent type. */
78
+ toolNames: string[];
79
+ /**
80
+ * Resolved model instance (undefined → use parent model as passed to SDK).
81
+ * Opaque handle — the assembler passes it through without inspection.
82
+ * Caller casts to the SDK’s Model<any> at the session-creation boundary.
83
+ */
84
+ model: unknown;
85
+ /** Resolved thinking level (undefined → inherit from session). */
86
+ thinkingLevel: ThinkingLevel | undefined;
87
+ /** Per-agent configured max turns (from agentConfig.maxTurns). */
88
+ agentMaxTurns: number | undefined;
89
+ }
90
+
91
+ // ── Internal helpers ─────────────────────────────────────────────────────────
92
+
93
+ /**
94
+ * Resolve the default model from the agent config's model string.
95
+ *
96
+ * Priority: parentModel is the fallback; if `configModel` is a "provider/modelId"
97
+ * string that resolves against the registry AND is in the available set, return
98
+ * that model instead.
99
+ */
100
+ function resolveDefaultModel(
101
+ parentModel: unknown,
102
+ registry: AssemblerContext["modelRegistry"],
103
+ configModel?: string,
104
+ ): unknown {
105
+ if (configModel) {
106
+ const slashIdx = configModel.indexOf("/");
107
+ if (slashIdx !== -1) {
108
+ const provider = configModel.slice(0, slashIdx);
109
+ const modelId = configModel.slice(slashIdx + 1);
110
+
111
+ const available = registry.getAvailable?.();
112
+ const availableKeys = available ? new Set(available.map((m) => `${m.provider}/${m.id}`)) : undefined;
113
+ const isAvailable = (p: string, id: string) => !availableKeys || availableKeys.has(`${p}/${id}`);
114
+
115
+ const found = registry.find(provider, modelId);
116
+ if (found && isAvailable(provider, modelId)) return found;
117
+ }
118
+ }
119
+ return parentModel;
120
+ }
121
+
122
+ // ── Public function ──────────────────────────────────────────────────────────
123
+
124
+ /**
125
+ * Assemble all configuration needed to create an agent session.
126
+ *
127
+ * Synchronous and side-effect-free — all IO is delegated through the `io`
128
+ * parameter. The caller is responsible for resolving `EnvInfo` beforehand
129
+ * via `detectEnv()`.
130
+ *
131
+ * @param type The subagent type name (case-insensitive registry lookup).
132
+ * @param ctx Narrow context from the parent session.
133
+ * @param options Per-call overrides (cwd, model, thinkingLevel).
134
+ * @param env Pre-resolved environment info from `detectEnv()`.
135
+ * @param registry Agent config lookup — provides resolveAgentConfig and getToolNamesForType.
136
+ * @param io IO collaborators (skill loader, memory builder, prompt builder).
137
+ */
138
+ export function assembleSessionConfig(
139
+ type: SubagentType,
140
+ ctx: AssemblerContext,
141
+ options: AssemblerOptions,
142
+ env: EnvInfo,
143
+ registry: AgentConfigLookup,
144
+ io: AssemblerIO,
145
+ ): SessionConfig {
146
+ const agentConfig = registry.resolveAgentConfig(type);
147
+
148
+ const effectiveCwd = options.cwd ?? ctx.cwd;
149
+
150
+ const toolNames = registry.getToolNamesForType(type);
151
+
152
+ // Build system prompt from the resolved agent config
153
+ const systemPrompt = io.buildAgentPrompt(agentConfig, effectiveCwd, env, ctx.parentSystemPrompt);
154
+
155
+ // Model resolution: explicit option > config model string > parent model
156
+ const model = options.model ?? resolveDefaultModel(ctx.parentModel, ctx.modelRegistry, agentConfig.model);
157
+
158
+ // Thinking level: explicit option > agent config > undefined (inherit)
159
+ const thinkingLevel = options.thinkingLevel ?? agentConfig.thinking;
160
+
161
+ // Per-agent max turns (combined with per-call maxTurns and defaultMaxTurns by SubagentSession.runTurnLoop)
162
+ const agentMaxTurns = agentConfig.maxTurns;
163
+
164
+ return {
165
+ effectiveCwd,
166
+ systemPrompt,
167
+ toolNames,
168
+ model,
169
+ thinkingLevel,
170
+ agentMaxTurns,
171
+ };
172
+ }
@@ -0,0 +1,38 @@
1
+ /**
2
+ * session-dir.ts — Pure function for deriving subagent session directories.
3
+ *
4
+ * Subagent sessions are nested under the parent session's basename so they are
5
+ * discoverable via the parent session path without cluttering the main session list.
6
+ */
7
+
8
+ import { tmpdir } from "node:os";
9
+ import { basename, dirname, join } from "node:path";
10
+
11
+ /**
12
+ * Derive the session directory for a subagent from the parent session file.
13
+ *
14
+ * Layout: `<parent-dir>/<parent-basename>/tasks/`
15
+ *
16
+ * Example:
17
+ * parent: `~/.pi/agent/sessions/--project--/2026-05-20T12-00-00Z_.jsonl`
18
+ * result: `~/.pi/agent/sessions/--project--/2026-05-20T12-00-00Z_/tasks`
19
+ *
20
+ * Falls back to a temp directory when the parent session is not persisted
21
+ * (e.g. API/headless mode where the parent uses `SessionManager.inMemory()`).
22
+ */
23
+ export function deriveSubagentSessionDir(parentSessionFile: string | undefined, cwd: string): string {
24
+ if (parentSessionFile) {
25
+ const dir = dirname(parentSessionFile);
26
+ const base = basename(parentSessionFile, ".jsonl");
27
+ return join(dir, base, "tasks");
28
+ }
29
+
30
+ // Fallback: use a temp directory keyed by uid and cwd so different
31
+ // projects don't collide when the parent session is not persisted.
32
+ const encoded = cwd
33
+ .replace(/[/\\]/g, "-")
34
+ .replace(/^[A-Za-z]:-/, "")
35
+ .replace(/^-+/, "");
36
+ const root = join(tmpdir(), `pi-subagents-${process.getuid?.() ?? 0}`);
37
+ return join(root, encoded, "tasks");
38
+ }
@@ -0,0 +1,227 @@
1
+ // Persistence for pi-subagents operational settings.
2
+ // - Global: ~/.pi/agent/subagents.json (agentDir injected at construction) — manual defaults, never written here
3
+ // - Project: <cwd>/.pi/subagents.json — written by /agents → Settings; overrides global on load
4
+
5
+ import { mkdirSync, writeFileSync } from "node:fs";
6
+ import { dirname, join } from "node:path";
7
+ import { type LayeredSettingsSource, loadLayeredSettings } from "./layered-settings";
8
+ export interface SubagentsSettings {
9
+ maxConcurrent?: number;
10
+ /**
11
+ * 0 = unlimited — the extension's single source of truth for that convention:
12
+ * `normalizeMaxTurns()` in turn-limits.ts treats 0 → `undefined`, and the
13
+ * `/agents` → Settings input prompt explicitly says "0 = unlimited".
14
+ */
15
+ defaultMaxTurns?: number;
16
+ graceTurns?: number;
17
+ }
18
+
19
+ /** Emit callback — a subset of `pi.events.emit` to keep helpers testable. */
20
+ export type SettingsEmit = (event: string, payload: unknown) => void;
21
+
22
+ const DEFAULT_MAX_CONCURRENT = 4;
23
+ const DEFAULT_GRACE_TURNS = 5;
24
+
25
+ /**
26
+ * Owns all three in-memory settings values and their load/save/persist cycle.
27
+ * Replaces the scattered free-function + SettingsAppliers callback pattern.
28
+ */
29
+ export class SettingsManager {
30
+ private _defaultMaxTurns: number | undefined = undefined;
31
+ private _graceTurns: number = DEFAULT_GRACE_TURNS;
32
+ private _maxConcurrent: number = DEFAULT_MAX_CONCURRENT;
33
+
34
+ private readonly emit: SettingsEmit;
35
+ private readonly cwd: string;
36
+ private readonly agentDir: string;
37
+ private readonly onMaxConcurrentChanged: (() => void) | undefined;
38
+
39
+ constructor(deps: { emit: SettingsEmit; cwd: string; agentDir: string; onMaxConcurrentChanged?: () => void }) {
40
+ this.emit = deps.emit;
41
+ this.cwd = deps.cwd;
42
+ this.agentDir = deps.agentDir;
43
+ this.onMaxConcurrentChanged = deps.onMaxConcurrentChanged;
44
+ }
45
+
46
+ // ── defaultMaxTurns: 0 or undefined → unlimited (undefined); else max(1, n) ──
47
+
48
+ get defaultMaxTurns(): number | undefined {
49
+ return this._defaultMaxTurns;
50
+ }
51
+
52
+ set defaultMaxTurns(n: number | undefined) {
53
+ if (n == null || n === 0) {
54
+ this._defaultMaxTurns = undefined;
55
+ } else {
56
+ this._defaultMaxTurns = Math.max(1, n);
57
+ }
58
+ }
59
+
60
+ // ── graceTurns: minimum 1 ──
61
+
62
+ get graceTurns(): number {
63
+ return this._graceTurns;
64
+ }
65
+
66
+ set graceTurns(n: number) {
67
+ this._graceTurns = Math.max(1, n);
68
+ }
69
+
70
+ // ── maxConcurrent: minimum 1 ──
71
+
72
+ get maxConcurrent(): number {
73
+ return this._maxConcurrent;
74
+ }
75
+
76
+ set maxConcurrent(n: number) {
77
+ this._maxConcurrent = Math.max(1, n);
78
+ }
79
+
80
+ // ── Lifecycle methods ──
81
+
82
+ /**
83
+ * Load merged settings (global + project), apply to in-memory values,
84
+ * and emit the `subagents:settings_loaded` lifecycle event.
85
+ * Returns the raw loaded settings object.
86
+ */
87
+ load(): SubagentsSettings {
88
+ const settings = loadSettings(this.agentDir, this.cwd);
89
+ if (typeof settings.maxConcurrent === "number") this.maxConcurrent = settings.maxConcurrent;
90
+ if (typeof settings.defaultMaxTurns === "number") this.defaultMaxTurns = settings.defaultMaxTurns;
91
+ if (typeof settings.graceTurns === "number") this.graceTurns = settings.graceTurns;
92
+ this.emit("subagents:settings_loaded", { settings });
93
+ return settings;
94
+ }
95
+
96
+ /**
97
+ * Snapshot current in-memory values for persistence.
98
+ * `defaultMaxTurns` uses 0 as the on-disk marker for unlimited (undefined).
99
+ */
100
+ snapshot(): { maxConcurrent: number; defaultMaxTurns: number; graceTurns: number } {
101
+ return {
102
+ maxConcurrent: this._maxConcurrent,
103
+ defaultMaxTurns: this._defaultMaxTurns ?? 0,
104
+ graceTurns: this._graceTurns,
105
+ };
106
+ }
107
+
108
+ /**
109
+ * Set maxConcurrent, notify interested parties, persist, and return the toast.
110
+ * Owns the full consequence chain so callers just say what they want.
111
+ */
112
+ applyMaxConcurrent(n: number): { message: string; level: "info" | "warning" } {
113
+ this.maxConcurrent = n; // setter normalizes: max(1, n)
114
+ this.onMaxConcurrentChanged?.();
115
+ return this.saveAndNotify(`Max concurrency set to ${this.maxConcurrent}`);
116
+ }
117
+
118
+ /**
119
+ * Set defaultMaxTurns, persist, and return the toast.
120
+ * Pass 0 for unlimited (maps to undefined internally).
121
+ */
122
+ applyDefaultMaxTurns(n: number): { message: string; level: "info" | "warning" } {
123
+ this.defaultMaxTurns = n === 0 ? undefined : n; // setter normalizes further
124
+ const label = this.defaultMaxTurns == null ? "unlimited" : String(this.defaultMaxTurns);
125
+ return this.saveAndNotify(`Default max turns set to ${label}`);
126
+ }
127
+
128
+ /**
129
+ * Set graceTurns, persist, and return the toast.
130
+ */
131
+ applyGraceTurns(n: number): { message: string; level: "info" | "warning" } {
132
+ this.graceTurns = n; // setter normalizes: max(1, n)
133
+ return this.saveAndNotify(`Grace turns set to ${this.graceTurns}`);
134
+ }
135
+
136
+ /**
137
+ * Persist the current snapshot, emit `subagents:settings_changed`,
138
+ * and return the toast the UI should display.
139
+ */
140
+ saveAndNotify(successMsg: string): { message: string; level: "info" | "warning" } {
141
+ const snap = this.snapshot();
142
+ const persisted = saveSettings(snap, this.cwd);
143
+ this.emit("subagents:settings_changed", { settings: snap, persisted });
144
+ return persistToastFor(successMsg, persisted);
145
+ }
146
+ }
147
+
148
+ // Sanity ceilings — prevent hand-edited configs from asking for values that
149
+ // make no operational sense (e.g. 1e6 concurrent subagents). Permissive enough
150
+ // that any realistic power-user setting passes through.
151
+ const MAX_CONCURRENT_CEILING = 1024;
152
+ const MAX_TURNS_CEILING = 10_000;
153
+ const GRACE_TURNS_CEILING = 1_000;
154
+
155
+ /** Drop fields that don't match the expected shape. Silent — garbage becomes absent. */
156
+ function sanitize(raw: unknown): SubagentsSettings {
157
+ if (!raw || typeof raw !== "object") return {};
158
+ const r = raw as Record<string, unknown>;
159
+ const out: SubagentsSettings = {};
160
+ if (
161
+ Number.isInteger(r.maxConcurrent) &&
162
+ (r.maxConcurrent as number) >= 1 &&
163
+ (r.maxConcurrent as number) <= MAX_CONCURRENT_CEILING
164
+ ) {
165
+ out.maxConcurrent = r.maxConcurrent as number;
166
+ }
167
+ if (
168
+ Number.isInteger(r.defaultMaxTurns) &&
169
+ (r.defaultMaxTurns as number) >= 0 &&
170
+ (r.defaultMaxTurns as number) <= MAX_TURNS_CEILING
171
+ ) {
172
+ out.defaultMaxTurns = r.defaultMaxTurns as number;
173
+ }
174
+ if (
175
+ Number.isInteger(r.graceTurns) &&
176
+ (r.graceTurns as number) >= 1 &&
177
+ (r.graceTurns as number) <= GRACE_TURNS_CEILING
178
+ ) {
179
+ out.graceTurns = r.graceTurns as number;
180
+ }
181
+ return out;
182
+ }
183
+
184
+ function projectPath(cwd: string): string {
185
+ return join(cwd, ".pi", "subagents.json");
186
+ }
187
+
188
+ /** Load merged settings: global provides defaults, project overrides. */
189
+ export function loadSettings(agentDir: string, cwd: string): SubagentsSettings {
190
+ return loadLayeredSettings({
191
+ agentDir,
192
+ cwd,
193
+ filename: "subagents.json",
194
+ sanitize,
195
+ warnLabel: "pi-subagents",
196
+ } satisfies LayeredSettingsSource<SubagentsSettings>);
197
+ }
198
+
199
+ /**
200
+ * Write project-local settings. Global is never touched from code.
201
+ * Returns `true` on success, `false` if the write (or mkdir) failed so the
202
+ * caller can surface a warning — persistence isn't fatal but isn't silent.
203
+ */
204
+ export function saveSettings(s: SubagentsSettings, cwd: string = process.cwd()): boolean {
205
+ const path = projectPath(cwd);
206
+ try {
207
+ mkdirSync(dirname(path), { recursive: true });
208
+ writeFileSync(path, JSON.stringify(s, null, 2), "utf-8");
209
+ return true;
210
+ } catch {
211
+ return false;
212
+ }
213
+ }
214
+
215
+ /**
216
+ * Format the user-facing toast for a settings mutation. Pure function —
217
+ * routes the success/failure of `saveSettings` into the right message + level
218
+ * so the UI layer (index.ts) stays a thin wire between input and notification.
219
+ */
220
+ export function persistToastFor(
221
+ successMsg: string,
222
+ persisted: boolean,
223
+ ): { message: string; level: "info" | "warning" } {
224
+ return persisted
225
+ ? { message: successMsg, level: "info" }
226
+ : { message: `${successMsg} (session only; failed to persist)`, level: "warning" };
227
+ }