@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.
- package/CHANGELOG.md +2705 -0
- package/LICENSE +21 -0
- package/README.md +503 -0
- package/dist/public.d.ts +331 -0
- package/dist/settings.d.ts +82 -0
- package/docs/architecture/architecture.md +1566 -0
- package/docs/architecture/client-server-opportunities.md +127 -0
- package/docs/architecture/history/phase-1-api-boundary.md +8 -0
- package/docs/architecture/history/phase-10-structural-decomposition.md +141 -0
- package/docs/architecture/history/phase-11-closure-to-class.md +100 -0
- package/docs/architecture/history/phase-12-complexity-test-fixtures.md +55 -0
- package/docs/architecture/history/phase-13-remaining-smells.md +88 -0
- package/docs/architecture/history/phase-14-strip-policy.md +49 -0
- package/docs/architecture/history/phase-15-domain-model-evolution.md +73 -0
- package/docs/architecture/history/phase-16-invert-dependencies.md +144 -0
- package/docs/architecture/history/phase-17-core-consolidation.md +214 -0
- package/docs/architecture/history/phase-18-reconsider-ui.md +166 -0
- package/docs/architecture/history/phase-19-implement-ui-decisions.md +282 -0
- package/docs/architecture/history/phase-2-remove-scheduling.md +9 -0
- package/docs/architecture/history/phase-20-result-delivery.md +245 -0
- package/docs/architecture/history/phase-21-classification-model-boundary.md +107 -0
- package/docs/architecture/history/phase-3-remove-rpc-groupjoin.md +11 -0
- package/docs/architecture/history/phase-4-implement-service.md +8 -0
- package/docs/architecture/history/phase-5-decompose-index.md +42 -0
- package/docs/architecture/history/phase-7-encapsulation.md +173 -0
- package/docs/architecture/history/phase-8-testability.md +103 -0
- package/docs/architecture/history/phase-9-observation-ctx.md +122 -0
- package/docs/comparison-with-upstream.md +77 -0
- package/docs/configuration.md +364 -0
- package/docs/decisions/0001-deferred-patches.md +80 -0
- package/docs/decisions/0002-extensions-on-a-minimal-core.md +125 -0
- package/docs/decisions/0003-publish-bundled-type-declarations.md +71 -0
- package/docs/decisions/0004-reconsider-ui-direction.md +279 -0
- package/docs/decisions/0005-subagent-record-admission-policy.md +106 -0
- package/docs/decisions/0006-inherited-prompt-is-identity-only.md +104 -0
- package/docs/decisions/0007-transcript-viewer-is-not-an-overlay.md +228 -0
- package/docs/decisions/0008-inherited-region-is-shared-parts.md +81 -0
- package/docs/decisions/0009-portable-inheritance-is-provider-scoped.md +116 -0
- package/package.json +91 -0
- package/src/config/agent-types.ts +135 -0
- package/src/config/custom-agents.ts +151 -0
- package/src/config/default-agents.ts +121 -0
- package/src/config/invocation-config.ts +167 -0
- package/src/config/thinking-level.ts +58 -0
- package/src/debug.ts +14 -0
- package/src/handlers/index.ts +3 -0
- package/src/handlers/interrupt.ts +58 -0
- package/src/handlers/lifecycle.ts +71 -0
- package/src/handlers/widget-events.ts +49 -0
- package/src/index.ts +292 -0
- package/src/layered-settings.ts +105 -0
- package/src/lifecycle/child-lifecycle.ts +115 -0
- package/src/lifecycle/child-shutdown.ts +105 -0
- package/src/lifecycle/concurrency-limiter.ts +55 -0
- package/src/lifecycle/create-subagent-session.ts +335 -0
- package/src/lifecycle/parent-snapshot.ts +119 -0
- package/src/lifecycle/run-listeners.ts +37 -0
- package/src/lifecycle/selection-scope.ts +116 -0
- package/src/lifecycle/spawn-selection.ts +259 -0
- package/src/lifecycle/subagent-manager.ts +546 -0
- package/src/lifecycle/subagent-session.ts +347 -0
- package/src/lifecycle/subagent-state.ts +404 -0
- package/src/lifecycle/subagent.ts +885 -0
- package/src/lifecycle/turn-limits.ts +13 -0
- package/src/lifecycle/usage.ts +60 -0
- package/src/lifecycle/workspace-bracket.ts +76 -0
- package/src/lifecycle/workspace.ts +46 -0
- package/src/observation/composite-subagent-observer.ts +74 -0
- package/src/observation/notification.ts +430 -0
- package/src/observation/outcome-delivery.ts +239 -0
- package/src/observation/record-observer.ts +78 -0
- package/src/observation/renderer.ts +161 -0
- package/src/observation/subagent-events-observer.ts +148 -0
- package/src/runtime.ts +137 -0
- package/src/service/service-adapter.ts +201 -0
- package/src/service/service.ts +246 -0
- package/src/session/ask-parent-tool.ts +69 -0
- package/src/session/content-items.ts +53 -0
- package/src/session/context.ts +80 -0
- package/src/session/conversation.ts +49 -0
- package/src/session/env.ts +40 -0
- package/src/session/model-resolver.ts +126 -0
- package/src/session/notify-parent-tool.ts +83 -0
- package/src/session/package-exclusions.ts +75 -0
- package/src/session/prompts.ts +231 -0
- package/src/session/provider-inheritance.ts +56 -0
- package/src/session/selection-catalogue.ts +143 -0
- package/src/session/session-config.ts +202 -0
- package/src/session/session-dir.ts +38 -0
- package/src/settings.ts +447 -0
- package/src/tools/agent-tool.ts +305 -0
- package/src/tools/background-spawner.ts +83 -0
- package/src/tools/foreground-runner.ts +159 -0
- package/src/tools/get-result-renderer.ts +119 -0
- package/src/tools/get-result-report.ts +84 -0
- package/src/tools/get-result-tool.ts +192 -0
- package/src/tools/helpers.ts +118 -0
- package/src/tools/result-renderer.ts +153 -0
- package/src/tools/spawn-config.ts +192 -0
- package/src/tools/steer-tool.ts +109 -0
- package/src/types.ts +143 -0
- package/src/ui/agent-widget.ts +333 -0
- package/src/ui/bounded-lines.ts +45 -0
- package/src/ui/display.ts +180 -0
- package/src/ui/glyphs.ts +62 -0
- package/src/ui/session-navigation.ts +150 -0
- package/src/ui/session-navigator.ts +255 -0
- package/src/ui/subagents-settings.ts +179 -0
- package/src/ui/transcript-content.ts +374 -0
- package/src/ui/widget-renderer.ts +301 -0
package/src/settings.ts
ADDED
|
@@ -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
|
+
}
|