@jopqior/pi-subagents 1.0.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 (110) hide show
  1. package/CHANGELOG.md +2705 -0
  2. package/LICENSE +21 -0
  3. package/README.md +503 -0
  4. package/dist/public.d.ts +331 -0
  5. package/dist/settings.d.ts +82 -0
  6. package/docs/architecture/architecture.md +1566 -0
  7. package/docs/architecture/client-server-opportunities.md +127 -0
  8. package/docs/architecture/history/phase-1-api-boundary.md +8 -0
  9. package/docs/architecture/history/phase-10-structural-decomposition.md +141 -0
  10. package/docs/architecture/history/phase-11-closure-to-class.md +100 -0
  11. package/docs/architecture/history/phase-12-complexity-test-fixtures.md +55 -0
  12. package/docs/architecture/history/phase-13-remaining-smells.md +88 -0
  13. package/docs/architecture/history/phase-14-strip-policy.md +49 -0
  14. package/docs/architecture/history/phase-15-domain-model-evolution.md +73 -0
  15. package/docs/architecture/history/phase-16-invert-dependencies.md +144 -0
  16. package/docs/architecture/history/phase-17-core-consolidation.md +214 -0
  17. package/docs/architecture/history/phase-18-reconsider-ui.md +166 -0
  18. package/docs/architecture/history/phase-19-implement-ui-decisions.md +282 -0
  19. package/docs/architecture/history/phase-2-remove-scheduling.md +9 -0
  20. package/docs/architecture/history/phase-20-result-delivery.md +245 -0
  21. package/docs/architecture/history/phase-21-classification-model-boundary.md +107 -0
  22. package/docs/architecture/history/phase-3-remove-rpc-groupjoin.md +11 -0
  23. package/docs/architecture/history/phase-4-implement-service.md +8 -0
  24. package/docs/architecture/history/phase-5-decompose-index.md +42 -0
  25. package/docs/architecture/history/phase-7-encapsulation.md +173 -0
  26. package/docs/architecture/history/phase-8-testability.md +103 -0
  27. package/docs/architecture/history/phase-9-observation-ctx.md +122 -0
  28. package/docs/comparison-with-upstream.md +77 -0
  29. package/docs/configuration.md +364 -0
  30. package/docs/decisions/0001-deferred-patches.md +80 -0
  31. package/docs/decisions/0002-extensions-on-a-minimal-core.md +125 -0
  32. package/docs/decisions/0003-publish-bundled-type-declarations.md +71 -0
  33. package/docs/decisions/0004-reconsider-ui-direction.md +279 -0
  34. package/docs/decisions/0005-subagent-record-admission-policy.md +106 -0
  35. package/docs/decisions/0006-inherited-prompt-is-identity-only.md +104 -0
  36. package/docs/decisions/0007-transcript-viewer-is-not-an-overlay.md +228 -0
  37. package/docs/decisions/0008-inherited-region-is-shared-parts.md +81 -0
  38. package/docs/decisions/0009-portable-inheritance-is-provider-scoped.md +116 -0
  39. package/package.json +91 -0
  40. package/src/config/agent-types.ts +135 -0
  41. package/src/config/custom-agents.ts +151 -0
  42. package/src/config/default-agents.ts +121 -0
  43. package/src/config/invocation-config.ts +167 -0
  44. package/src/config/thinking-level.ts +58 -0
  45. package/src/debug.ts +14 -0
  46. package/src/handlers/index.ts +3 -0
  47. package/src/handlers/interrupt.ts +58 -0
  48. package/src/handlers/lifecycle.ts +71 -0
  49. package/src/handlers/widget-events.ts +49 -0
  50. package/src/index.ts +292 -0
  51. package/src/layered-settings.ts +105 -0
  52. package/src/lifecycle/child-lifecycle.ts +115 -0
  53. package/src/lifecycle/child-shutdown.ts +105 -0
  54. package/src/lifecycle/concurrency-limiter.ts +55 -0
  55. package/src/lifecycle/create-subagent-session.ts +335 -0
  56. package/src/lifecycle/parent-snapshot.ts +119 -0
  57. package/src/lifecycle/run-listeners.ts +37 -0
  58. package/src/lifecycle/selection-scope.ts +116 -0
  59. package/src/lifecycle/spawn-selection.ts +259 -0
  60. package/src/lifecycle/subagent-manager.ts +546 -0
  61. package/src/lifecycle/subagent-session.ts +347 -0
  62. package/src/lifecycle/subagent-state.ts +404 -0
  63. package/src/lifecycle/subagent.ts +885 -0
  64. package/src/lifecycle/turn-limits.ts +13 -0
  65. package/src/lifecycle/usage.ts +60 -0
  66. package/src/lifecycle/workspace-bracket.ts +76 -0
  67. package/src/lifecycle/workspace.ts +46 -0
  68. package/src/observation/composite-subagent-observer.ts +74 -0
  69. package/src/observation/notification.ts +430 -0
  70. package/src/observation/outcome-delivery.ts +239 -0
  71. package/src/observation/record-observer.ts +78 -0
  72. package/src/observation/renderer.ts +161 -0
  73. package/src/observation/subagent-events-observer.ts +148 -0
  74. package/src/runtime.ts +137 -0
  75. package/src/service/service-adapter.ts +201 -0
  76. package/src/service/service.ts +246 -0
  77. package/src/session/ask-parent-tool.ts +69 -0
  78. package/src/session/content-items.ts +53 -0
  79. package/src/session/context.ts +80 -0
  80. package/src/session/conversation.ts +49 -0
  81. package/src/session/env.ts +40 -0
  82. package/src/session/model-resolver.ts +126 -0
  83. package/src/session/notify-parent-tool.ts +83 -0
  84. package/src/session/package-exclusions.ts +75 -0
  85. package/src/session/prompts.ts +231 -0
  86. package/src/session/provider-inheritance.ts +56 -0
  87. package/src/session/selection-catalogue.ts +143 -0
  88. package/src/session/session-config.ts +202 -0
  89. package/src/session/session-dir.ts +38 -0
  90. package/src/settings.ts +447 -0
  91. package/src/tools/agent-tool.ts +305 -0
  92. package/src/tools/background-spawner.ts +83 -0
  93. package/src/tools/foreground-runner.ts +159 -0
  94. package/src/tools/get-result-renderer.ts +119 -0
  95. package/src/tools/get-result-report.ts +84 -0
  96. package/src/tools/get-result-tool.ts +192 -0
  97. package/src/tools/helpers.ts +118 -0
  98. package/src/tools/result-renderer.ts +153 -0
  99. package/src/tools/spawn-config.ts +192 -0
  100. package/src/tools/steer-tool.ts +109 -0
  101. package/src/types.ts +143 -0
  102. package/src/ui/agent-widget.ts +333 -0
  103. package/src/ui/bounded-lines.ts +45 -0
  104. package/src/ui/display.ts +180 -0
  105. package/src/ui/glyphs.ts +62 -0
  106. package/src/ui/session-navigation.ts +150 -0
  107. package/src/ui/session-navigator.ts +255 -0
  108. package/src/ui/subagents-settings.ts +179 -0
  109. package/src/ui/transcript-content.ts +374 -0
  110. package/src/ui/widget-renderer.ts +301 -0
@@ -0,0 +1,447 @@
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 "#src/layered-settings";
8
+ import type { PromptInheritance } from "#src/types";
9
+ export interface SubagentsSettings {
10
+ maxConcurrent?: number;
11
+ /**
12
+ * 0 = unlimited — the extension's single source of truth for that convention:
13
+ * `normalizeMaxTurns()` in turn-limits.ts treats 0 → `undefined`, and the
14
+ * `/agents` → Settings input prompt explicitly says "0 = unlimited".
15
+ */
16
+ defaultMaxTurns?: number;
17
+ graceTurns?: number;
18
+ /** Minutes a consumed agent's session is retained after its last relevance event. */
19
+ consumedSessionRetentionMinutes?: number;
20
+ /** Minutes an unconsumed agent's session is retained (safety cap). */
21
+ unconsumedSessionRetentionMinutes?: number;
22
+ /**
23
+ * When false, a parent interrupt (ESC) leaves background and queued subagents
24
+ * running. Foreground agents hold the parent's run signal directly, so they
25
+ * abort on ESC either way.
26
+ */
27
+ abortAllOnInterrupt?: boolean;
28
+ /**
29
+ * When false, a background child is not given the `notify_parent` tool, so it
30
+ * cannot interrupt the parent with a mid-run finding. Ask-back is unaffected.
31
+ */
32
+ midRunUpdates?: boolean;
33
+ /**
34
+ * Pi package sources whose extensions child sessions must not load, matched
35
+ * against Pi's configured source string exactly (e.g. `npm:@scope/pkg`).
36
+ * The package's skills, prompts, and themes stay available to children.
37
+ */
38
+ excludedExtensionPackages?: string[];
39
+ /**
40
+ * Prompt-inheritance strategy per provider, keyed by the provider id of the
41
+ * child's resolved model. Every provider not listed inherits `"full"`.
42
+ * The key is the provider rather than the agent because re-homing is a
43
+ * property of the transport, and a per-spawn `model` override moves a child
44
+ * between transports (ADR 0009).
45
+ */
46
+ promptInheritance?: Record<string, PromptInheritance>;
47
+ }
48
+
49
+ /**
50
+ * The persisted form of the in-memory settings values.
51
+ * `saveSettings` rewrites the whole project file from this shape, so every key
52
+ * that must survive a `/subagents:settings` edit has to appear here.
53
+ */
54
+ export interface SettingsSnapshot {
55
+ maxConcurrent: number;
56
+ defaultMaxTurns: number;
57
+ graceTurns: number;
58
+ consumedSessionRetentionMinutes: number;
59
+ unconsumedSessionRetentionMinutes: number;
60
+ abortAllOnInterrupt: boolean;
61
+ midRunUpdates: boolean;
62
+ /**
63
+ * Present only when non-empty, so files that never set it gain no noise.
64
+ * It must round-trip: the key has no `/subagents:settings` affordance, so a
65
+ * hand-edited value would otherwise be erased by any unrelated setting change.
66
+ */
67
+ excludedExtensionPackages?: string[];
68
+ /** Present only when non-empty, and round-tripped for the same reason. */
69
+ promptInheritance?: Record<string, PromptInheritance>;
70
+ }
71
+
72
+
73
+ /** Emit callback — a subset of `pi.events.emit` to keep helpers testable. */
74
+ export type SettingsEmit = (event: string, payload: unknown) => void;
75
+
76
+ const DEFAULT_MAX_CONCURRENT = 4;
77
+ const DEFAULT_GRACE_TURNS = 5;
78
+ const DEFAULT_CONSUMED_RETENTION_MINUTES = 10;
79
+ const DEFAULT_UNCONSUMED_RETENTION_MINUTES = 720;
80
+ const DEFAULT_ABORT_ALL_ON_INTERRUPT = true;
81
+ const DEFAULT_MID_RUN_UPDATES = true;
82
+
83
+ /**
84
+ * Owns all three in-memory settings values and their load/save/persist cycle.
85
+ * Replaces the scattered free-function + SettingsAppliers callback pattern.
86
+ */
87
+ export class SettingsManager {
88
+ private _defaultMaxTurns: number | undefined = undefined;
89
+ private _graceTurns: number = DEFAULT_GRACE_TURNS;
90
+ private _maxConcurrent: number = DEFAULT_MAX_CONCURRENT;
91
+ private _consumedSessionRetentionMinutes: number = DEFAULT_CONSUMED_RETENTION_MINUTES;
92
+ private _unconsumedSessionRetentionMinutes: number = DEFAULT_UNCONSUMED_RETENTION_MINUTES;
93
+ private _abortAllOnInterrupt: boolean = DEFAULT_ABORT_ALL_ON_INTERRUPT;
94
+ private _midRunUpdates: boolean = DEFAULT_MID_RUN_UPDATES;
95
+ private _excludedExtensionPackages: string[] = [];
96
+ private _promptInheritance: Record<string, PromptInheritance> = {};
97
+
98
+ private readonly emit: SettingsEmit;
99
+ private readonly cwd: string;
100
+ private readonly agentDir: string;
101
+ private readonly onMaxConcurrentChanged: (() => void) | undefined;
102
+
103
+ constructor(deps: { emit: SettingsEmit; cwd: string; agentDir: string; onMaxConcurrentChanged?: () => void }) {
104
+ this.emit = deps.emit;
105
+ this.cwd = deps.cwd;
106
+ this.agentDir = deps.agentDir;
107
+ this.onMaxConcurrentChanged = deps.onMaxConcurrentChanged;
108
+ }
109
+
110
+ // ── defaultMaxTurns: 0 or undefined → unlimited (undefined); else max(1, n) ──
111
+
112
+ get defaultMaxTurns(): number | undefined {
113
+ return this._defaultMaxTurns;
114
+ }
115
+
116
+ set defaultMaxTurns(n: number | undefined) {
117
+ if (n == null || n === 0) {
118
+ this._defaultMaxTurns = undefined;
119
+ } else {
120
+ this._defaultMaxTurns = Math.max(1, n);
121
+ }
122
+ }
123
+
124
+ // ── graceTurns: minimum 1 ──
125
+
126
+ get graceTurns(): number {
127
+ return this._graceTurns;
128
+ }
129
+
130
+ set graceTurns(n: number) {
131
+ this._graceTurns = Math.max(1, n);
132
+ }
133
+
134
+ // ── maxConcurrent: minimum 1 ──
135
+
136
+ get maxConcurrent(): number {
137
+ return this._maxConcurrent;
138
+ }
139
+
140
+ set maxConcurrent(n: number) {
141
+ this._maxConcurrent = Math.max(1, n);
142
+ }
143
+
144
+ // ── retention windows: clamped to [1, RETENTION_MINUTES_CEILING] minutes ──
145
+
146
+ get consumedSessionRetentionMinutes(): number {
147
+ return this._consumedSessionRetentionMinutes;
148
+ }
149
+
150
+ set consumedSessionRetentionMinutes(n: number) {
151
+ this._consumedSessionRetentionMinutes = clampRetentionMinutes(n);
152
+ }
153
+
154
+ get unconsumedSessionRetentionMinutes(): number {
155
+ return this._unconsumedSessionRetentionMinutes;
156
+ }
157
+
158
+ set unconsumedSessionRetentionMinutes(n: number) {
159
+ this._unconsumedSessionRetentionMinutes = clampRetentionMinutes(n);
160
+ }
161
+
162
+ // ── abortAllOnInterrupt: flipped via toggleAbortAllOnInterrupt(); no normalization ──
163
+
164
+ get abortAllOnInterrupt(): boolean {
165
+ return this._abortAllOnInterrupt;
166
+ }
167
+
168
+ // ── excludedExtensionPackages: hand-edited only; no /subagents:settings affordance ──
169
+
170
+ get excludedExtensionPackages(): readonly string[] {
171
+ return this._excludedExtensionPackages;
172
+ }
173
+
174
+ // ── promptInheritance: hand-edited only; no /subagents:settings affordance ──
175
+
176
+ /**
177
+ * The prompt-inheritance strategy a child on `provider` adopts.
178
+ *
179
+ * Unlisted providers, and a child that resolved no model at all, inherit
180
+ * `"full"` — the default, which changes no existing child's prompt.
181
+ */
182
+ promptInheritanceFor(provider: string | undefined): PromptInheritance {
183
+ if (provider === undefined) return "full";
184
+ return this._promptInheritance[provider] ?? "full";
185
+ }
186
+
187
+ // ── Lifecycle methods ──
188
+
189
+ /**
190
+ * Load merged settings (global + project), apply to in-memory values,
191
+ * and emit the `subagents:settings_loaded` lifecycle event.
192
+ * Returns the raw loaded settings object.
193
+ */
194
+ load(): SubagentsSettings {
195
+ const settings = loadSettings(this.agentDir, this.cwd);
196
+ if (typeof settings.maxConcurrent === "number") this.maxConcurrent = settings.maxConcurrent;
197
+ if (typeof settings.defaultMaxTurns === "number") this.defaultMaxTurns = settings.defaultMaxTurns;
198
+ if (typeof settings.graceTurns === "number") this.graceTurns = settings.graceTurns;
199
+ if (typeof settings.consumedSessionRetentionMinutes === "number")
200
+ this.consumedSessionRetentionMinutes = settings.consumedSessionRetentionMinutes;
201
+ if (typeof settings.unconsumedSessionRetentionMinutes === "number")
202
+ this.unconsumedSessionRetentionMinutes = settings.unconsumedSessionRetentionMinutes;
203
+ if (typeof settings.abortAllOnInterrupt === "boolean")
204
+ this._abortAllOnInterrupt = settings.abortAllOnInterrupt;
205
+ if (typeof settings.midRunUpdates === "boolean") this._midRunUpdates = settings.midRunUpdates;
206
+ // Assigned unconditionally: removing the key from disk must clear the value.
207
+ this._excludedExtensionPackages = [...(settings.excludedExtensionPackages ?? [])];
208
+ this._promptInheritance = { ...settings.promptInheritance };
209
+ this.emit("subagents:settings_loaded", { settings });
210
+ return settings;
211
+ }
212
+
213
+ /**
214
+ * Snapshot current in-memory values for persistence.
215
+ * `defaultMaxTurns` uses 0 as the on-disk marker for unlimited (undefined).
216
+ */
217
+ snapshot(): SettingsSnapshot {
218
+ const snapshot: SettingsSnapshot = {
219
+ maxConcurrent: this._maxConcurrent,
220
+ defaultMaxTurns: this._defaultMaxTurns ?? 0,
221
+ graceTurns: this._graceTurns,
222
+ consumedSessionRetentionMinutes: this._consumedSessionRetentionMinutes,
223
+ unconsumedSessionRetentionMinutes: this._unconsumedSessionRetentionMinutes,
224
+ abortAllOnInterrupt: this._abortAllOnInterrupt,
225
+ midRunUpdates: this._midRunUpdates,
226
+ };
227
+ if (this._excludedExtensionPackages.length > 0) {
228
+ snapshot.excludedExtensionPackages = [...this._excludedExtensionPackages];
229
+ }
230
+ if (Object.keys(this._promptInheritance).length > 0) {
231
+ snapshot.promptInheritance = { ...this._promptInheritance };
232
+ }
233
+ return snapshot;
234
+ }
235
+
236
+ /**
237
+ * Set maxConcurrent, notify interested parties, persist, and return the toast.
238
+ * Owns the full consequence chain so callers just say what they want.
239
+ */
240
+ applyMaxConcurrent(n: number): { message: string; level: "info" | "warning" } {
241
+ this.maxConcurrent = n; // setter normalizes: max(1, n)
242
+ this.onMaxConcurrentChanged?.();
243
+ return this.saveAndNotify(`Max concurrency set to ${this.maxConcurrent}`);
244
+ }
245
+
246
+ /**
247
+ * Set defaultMaxTurns, persist, and return the toast.
248
+ * Pass 0 for unlimited (maps to undefined internally).
249
+ */
250
+ applyDefaultMaxTurns(n: number): { message: string; level: "info" | "warning" } {
251
+ this.defaultMaxTurns = n === 0 ? undefined : n; // setter normalizes further
252
+ const label = this.defaultMaxTurns == null ? "unlimited" : String(this.defaultMaxTurns);
253
+ return this.saveAndNotify(`Default max turns set to ${label}`);
254
+ }
255
+
256
+ /**
257
+ * Set graceTurns, persist, and return the toast.
258
+ */
259
+ applyGraceTurns(n: number): { message: string; level: "info" | "warning" } {
260
+ this.graceTurns = n; // setter normalizes: max(1, n)
261
+ return this.saveAndNotify(`Grace turns set to ${this.graceTurns}`);
262
+ }
263
+
264
+ /** Set the consumed-session retention window (minutes), persist, and return the toast. */
265
+ applyConsumedSessionRetentionMinutes(n: number): { message: string; level: "info" | "warning" } {
266
+ this.consumedSessionRetentionMinutes = n; // setter normalizes: clamp [1, ceiling]
267
+ return this.saveAndNotify(`Consumed-session retention set to ${this.consumedSessionRetentionMinutes} min`);
268
+ }
269
+
270
+ /** Set the unconsumed-session retention window (minutes), persist, and return the toast. */
271
+ applyUnconsumedSessionRetentionMinutes(n: number): { message: string; level: "info" | "warning" } {
272
+ this.unconsumedSessionRetentionMinutes = n; // setter normalizes: clamp [1, ceiling]
273
+ return this.saveAndNotify(`Unconsumed-session retention set to ${this.unconsumedSessionRetentionMinutes} min`);
274
+ }
275
+
276
+ /**
277
+ * Flip whether a parent interrupt (ESC) aborts every subagent, persist, and
278
+ * return the toast. The manager owns the negation so callers just say "flip it".
279
+ */
280
+ toggleAbortAllOnInterrupt(): { message: string; level: "info" | "warning" } {
281
+ this._abortAllOnInterrupt = !this._abortAllOnInterrupt;
282
+ return this.saveAndNotify(
283
+ `Abort all subagents on ESC: ${this._abortAllOnInterrupt ? "on" : "off"}`,
284
+ );
285
+ }
286
+
287
+ get midRunUpdates(): boolean {
288
+ return this._midRunUpdates;
289
+ }
290
+
291
+ /**
292
+ * Flip whether a background child may interrupt the parent with a mid-run
293
+ * update, persist, and return the toast.
294
+ */
295
+ toggleMidRunUpdates(): { message: string; level: "info" | "warning" } {
296
+ this._midRunUpdates = !this._midRunUpdates;
297
+ return this.saveAndNotify(
298
+ `Mid-run updates from background subagents: ${this._midRunUpdates ? "on" : "off"}`,
299
+ );
300
+ }
301
+
302
+ /**
303
+ * Persist the current snapshot, emit `subagents:settings_changed`,
304
+ * and return the toast the UI should display.
305
+ */
306
+ saveAndNotify(successMsg: string): { message: string; level: "info" | "warning" } {
307
+ const snap = this.snapshot();
308
+ const persisted = saveSettings(snap, this.cwd);
309
+ this.emit("subagents:settings_changed", { settings: snap, persisted });
310
+ return persistToastFor(successMsg, persisted);
311
+ }
312
+ }
313
+
314
+ // Sanity ceilings — prevent hand-edited configs from asking for values that
315
+ // make no operational sense (e.g. 1e6 concurrent subagents). Permissive enough
316
+ // that any realistic power-user setting passes through.
317
+ const MAX_CONCURRENT_CEILING = 1024;
318
+ const MAX_TURNS_CEILING = 10_000;
319
+ const GRACE_TURNS_CEILING = 1_000;
320
+ // Retention windows: 1 minute floor, two-week ceiling (60 * 24 * 14).
321
+ const RETENTION_MINUTES_CEILING = 20_160;
322
+
323
+ /** Clamp a retention window to [1, RETENTION_MINUTES_CEILING] minutes. */
324
+ function clampRetentionMinutes(n: number): number {
325
+ return Math.min(RETENTION_MINUTES_CEILING, Math.max(1, n));
326
+ }
327
+
328
+ /** True when a value is an integer minute count within the accepted retention range. */
329
+ function isRetentionMinutes(n: unknown): n is number {
330
+ return Number.isInteger(n) && (n as number) >= 1 && (n as number) <= RETENTION_MINUTES_CEILING;
331
+ }
332
+
333
+ /** Drop fields that don't match the expected shape. Silent — garbage becomes absent. */
334
+ function sanitize(raw: unknown): SubagentsSettings {
335
+ if (!raw || typeof raw !== "object") return {};
336
+ const r = raw as Record<string, unknown>;
337
+ const out: SubagentsSettings = {};
338
+ if (
339
+ Number.isInteger(r.maxConcurrent) &&
340
+ (r.maxConcurrent as number) >= 1 &&
341
+ (r.maxConcurrent as number) <= MAX_CONCURRENT_CEILING
342
+ ) {
343
+ out.maxConcurrent = r.maxConcurrent as number;
344
+ }
345
+ if (
346
+ Number.isInteger(r.defaultMaxTurns) &&
347
+ (r.defaultMaxTurns as number) >= 0 &&
348
+ (r.defaultMaxTurns as number) <= MAX_TURNS_CEILING
349
+ ) {
350
+ out.defaultMaxTurns = r.defaultMaxTurns as number;
351
+ }
352
+ if (
353
+ Number.isInteger(r.graceTurns) &&
354
+ (r.graceTurns as number) >= 1 &&
355
+ (r.graceTurns as number) <= GRACE_TURNS_CEILING
356
+ ) {
357
+ out.graceTurns = r.graceTurns as number;
358
+ }
359
+ if (isRetentionMinutes(r.consumedSessionRetentionMinutes)) {
360
+ out.consumedSessionRetentionMinutes = r.consumedSessionRetentionMinutes;
361
+ }
362
+ if (isRetentionMinutes(r.unconsumedSessionRetentionMinutes)) {
363
+ out.unconsumedSessionRetentionMinutes = r.unconsumedSessionRetentionMinutes;
364
+ }
365
+ if (typeof r.abortAllOnInterrupt === "boolean") {
366
+ out.abortAllOnInterrupt = r.abortAllOnInterrupt;
367
+ }
368
+ if (typeof r.midRunUpdates === "boolean") {
369
+ out.midRunUpdates = r.midRunUpdates;
370
+ }
371
+ if (Array.isArray(r.excludedExtensionPackages)) {
372
+ const sources = r.excludedExtensionPackages
373
+ .filter((value): value is string => typeof value === "string")
374
+ .map((value) => value.trim())
375
+ .filter(Boolean);
376
+ out.excludedExtensionPackages = [...new Set(sources)];
377
+ }
378
+ const promptInheritance = sanitizePromptInheritance(r.promptInheritance);
379
+ if (promptInheritance) {
380
+ out.promptInheritance = promptInheritance;
381
+ }
382
+ return out;
383
+ }
384
+
385
+ /**
386
+ * Keep only provider entries naming a known strategy, absent when none survive.
387
+ *
388
+ * Settings arrive from JSON, where the declared types are aspirations, so the
389
+ * strategy is checked at run time rather than trusted.
390
+ */
391
+ function sanitizePromptInheritance(
392
+ raw: unknown,
393
+ ): Record<string, PromptInheritance> | undefined {
394
+ if (!raw || typeof raw !== "object") return undefined;
395
+ const rules: Record<string, PromptInheritance> = {};
396
+ for (const [provider, strategy] of Object.entries(raw as Record<string, unknown>)) {
397
+ if (strategy === "full" || strategy === "portable") {
398
+ rules[provider] = strategy;
399
+ }
400
+ }
401
+ return Object.keys(rules).length > 0 ? rules : undefined;
402
+ }
403
+
404
+ function projectPath(cwd: string): string {
405
+ return join(cwd, ".pi", "subagents.json");
406
+ }
407
+
408
+ /** Load merged settings: global provides defaults, project overrides. */
409
+ export function loadSettings(agentDir: string, cwd: string): SubagentsSettings {
410
+ return loadLayeredSettings({
411
+ agentDir,
412
+ cwd,
413
+ filename: "subagents.json",
414
+ sanitize,
415
+ warnLabel: "pi-subagents",
416
+ } satisfies LayeredSettingsSource<SubagentsSettings>);
417
+ }
418
+
419
+ /**
420
+ * Write project-local settings. Global is never touched from code.
421
+ * Returns `true` on success, `false` if the write (or mkdir) failed so the
422
+ * caller can surface a warning — persistence isn't fatal but isn't silent.
423
+ */
424
+ export function saveSettings(s: SubagentsSettings, cwd: string = process.cwd()): boolean {
425
+ const path = projectPath(cwd);
426
+ try {
427
+ mkdirSync(dirname(path), { recursive: true });
428
+ writeFileSync(path, JSON.stringify(s, null, 2), "utf-8");
429
+ return true;
430
+ } catch {
431
+ return false;
432
+ }
433
+ }
434
+
435
+ /**
436
+ * Format the user-facing toast for a settings mutation. Pure function —
437
+ * routes the success/failure of `saveSettings` into the right message + level
438
+ * so the UI layer (index.ts) stays a thin wire between input and notification.
439
+ */
440
+ export function persistToastFor(
441
+ successMsg: string,
442
+ persisted: boolean,
443
+ ): { message: string; level: "info" | "warning" } {
444
+ return persisted
445
+ ? { message: successMsg, level: "info" }
446
+ : { message: `${successMsg} (session only; failed to persist)`, level: "warning" };
447
+ }