@yefengr/pi-subagents 0.0.0-stage → 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.
- package/CHANGELOG.md +2411 -0
- package/LICENSE +21 -0
- package/README.md +374 -2
- package/UPSTREAM.md +48 -0
- package/dist/public.d.ts +201 -0
- package/dist/settings.d.ts +82 -0
- package/docs/architecture/architecture.md +833 -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 +79 -0
- package/docs/configuration.md +215 -0
- package/docs/decisions/0001-deferred-patches.md +80 -0
- package/docs/decisions/0002-extensions-on-a-minimal-core.md +114 -0
- package/docs/decisions/0003-publish-bundled-type-declarations.md +71 -0
- package/docs/decisions/0004-reconsider-ui-direction.md +279 -0
- package/index.ts +1 -0
- package/package.json +91 -4
- package/src/config/agent-enablement.ts +132 -0
- package/src/config/agent-types.ts +144 -0
- package/src/config/custom-agents.ts +114 -0
- package/src/config/default-agents.ts +120 -0
- package/src/config/invocation-config.ts +39 -0
- package/src/config/resource-limits.ts +26 -0
- package/src/config/settings-file.ts +174 -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 +65 -0
- package/src/handlers/tool-start.ts +32 -0
- package/src/index.ts +257 -0
- package/src/layered-settings.ts +105 -0
- package/src/lifecycle/child-lifecycle.ts +89 -0
- package/src/lifecycle/child-shutdown.ts +105 -0
- package/src/lifecycle/concurrency-limiter.ts +123 -0
- package/src/lifecycle/create-subagent-session.ts +265 -0
- package/src/lifecycle/invocation-control.ts +109 -0
- package/src/lifecycle/lifecycle-record.ts +159 -0
- package/src/lifecycle/parent-snapshot.ts +46 -0
- package/src/lifecycle/run-listeners.ts +41 -0
- package/src/lifecycle/subagent-manager.ts +392 -0
- package/src/lifecycle/subagent-session.ts +287 -0
- package/src/lifecycle/subagent-state.ts +307 -0
- package/src/lifecycle/subagent.ts +854 -0
- package/src/lifecycle/usage.ts +60 -0
- package/src/lifecycle/workspace-bracket.ts +59 -0
- package/src/lifecycle/workspace.ts +47 -0
- package/src/observation/composite-subagent-observer.ts +53 -0
- package/src/observation/notification.ts +233 -0
- package/src/observation/record-observer.ts +78 -0
- package/src/observation/renderer.ts +106 -0
- package/src/observation/subagent-events-observer.ts +75 -0
- package/src/runtime.ts +78 -0
- package/src/service/service-adapter.ts +159 -0
- package/src/service/service.ts +147 -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 +135 -0
- package/src/session/package-exclusions.ts +75 -0
- package/src/session/prompts.ts +145 -0
- package/src/session/provider-inheritance.ts +38 -0
- package/src/session/session-config.ts +177 -0
- package/src/session/session-dir.ts +38 -0
- package/src/settings.ts +436 -0
- package/src/tools/agent-tool.ts +289 -0
- package/src/tools/background-spawner.ts +73 -0
- package/src/tools/foreground-runner.ts +135 -0
- package/src/tools/get-result-report.ts +73 -0
- package/src/tools/get-result-tool.ts +109 -0
- package/src/tools/helpers.ts +134 -0
- package/src/tools/result-renderer.ts +121 -0
- package/src/tools/spawn-config.ts +182 -0
- package/src/tools/steer-tool.ts +120 -0
- package/src/types.ts +145 -0
- package/src/ui/agent-widget.ts +313 -0
- package/src/ui/display.ts +214 -0
- package/src/ui/glyphs.ts +62 -0
- package/src/ui/session-navigation.ts +152 -0
- package/src/ui/session-navigator.ts +257 -0
- package/src/ui/subagents-agents.ts +116 -0
- package/src/ui/subagents-settings.ts +207 -0
- package/src/ui/transcript-content.ts +374 -0
- package/src/ui/widget-renderer.ts +289 -0
package/src/settings.ts
ADDED
|
@@ -0,0 +1,436 @@
|
|
|
1
|
+
// Persistence for pi-subagents operational settings.
|
|
2
|
+
// - Shared global: ~/.pi/agent/subagents.json (agentDir injected at construction) — managed by agent-tools
|
|
3
|
+
// - Personal global agent overrides: ~/.pi/agent/subagents.local.json — managed by /subagents:agents
|
|
4
|
+
// - Project: <cwd>/.pi/subagents.json — written by /agents → Settings; overrides global on load
|
|
5
|
+
|
|
6
|
+
import { join } from "node:path";
|
|
7
|
+
import { clampWrapUpTurns } from "#src/config/resource-limits";
|
|
8
|
+
import { updateSettingsFile } from "#src/config/settings-file";
|
|
9
|
+
import { type LayeredSettingsSource, loadLayeredSettings } from "#src/layered-settings";
|
|
10
|
+
|
|
11
|
+
export interface SubagentsSettings {
|
|
12
|
+
maxConcurrent?: number;
|
|
13
|
+
/** Global hard turn budget: every spawn is capped by this value. */
|
|
14
|
+
maxTurns?: number;
|
|
15
|
+
/** Global wrap-up window in turns, reserved before the hard stop; kept below maxTurns. */
|
|
16
|
+
wrapUpTurns?: number;
|
|
17
|
+
/** Global wall-clock budget for one subagent run, in seconds. */
|
|
18
|
+
maxDurationSeconds?: number;
|
|
19
|
+
/** Global budget for time an agent may wait in the background queue, in seconds. */
|
|
20
|
+
maxQueueWaitSeconds?: number;
|
|
21
|
+
/** Grace period after an abort request before waiters and the concurrency slot are released. */
|
|
22
|
+
abortGraceSeconds?: number;
|
|
23
|
+
/** Minutes a consumed agent's session is retained after its last relevance event. */
|
|
24
|
+
consumedSessionRetentionMinutes?: number;
|
|
25
|
+
/** Minutes an unconsumed agent's session is retained (safety cap). */
|
|
26
|
+
unconsumedSessionRetentionMinutes?: number;
|
|
27
|
+
/**
|
|
28
|
+
* When false, a parent interrupt (ESC) leaves background and queued subagents
|
|
29
|
+
* running. Foreground agents hold the parent's run signal directly, so they
|
|
30
|
+
* abort on ESC either way.
|
|
31
|
+
*/
|
|
32
|
+
abortAllOnInterrupt?: 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
|
+
|
|
41
|
+
/**
|
|
42
|
+
* The persisted form of the in-memory settings values.
|
|
43
|
+
* `saveSettings` updates these values in the project file while preserving
|
|
44
|
+
* keys owned by other configuration consumers.
|
|
45
|
+
*/
|
|
46
|
+
export interface SettingsSnapshot {
|
|
47
|
+
maxConcurrent: number;
|
|
48
|
+
maxTurns: number;
|
|
49
|
+
wrapUpTurns: number;
|
|
50
|
+
maxDurationSeconds: number;
|
|
51
|
+
maxQueueWaitSeconds: number;
|
|
52
|
+
abortGraceSeconds: number;
|
|
53
|
+
consumedSessionRetentionMinutes: number;
|
|
54
|
+
unconsumedSessionRetentionMinutes: number;
|
|
55
|
+
abortAllOnInterrupt: boolean;
|
|
56
|
+
/**
|
|
57
|
+
* Present only when non-empty, so files that never set it gain no noise.
|
|
58
|
+
* It must round-trip: the key has no `/subagents:settings` affordance, so a
|
|
59
|
+
* hand-edited value would otherwise be erased by any unrelated setting change.
|
|
60
|
+
*/
|
|
61
|
+
excludedExtensionPackages?: string[];
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** Emit callback — a subset of `pi.events.emit` to keep helpers testable. */
|
|
65
|
+
export type SettingsEmit = (event: string, payload: unknown) => void;
|
|
66
|
+
|
|
67
|
+
const DEFAULT_MAX_CONCURRENT = 4;
|
|
68
|
+
const DEFAULT_MAX_TURNS = 30;
|
|
69
|
+
const DEFAULT_WRAP_UP_TURNS = 2;
|
|
70
|
+
const DEFAULT_MAX_DURATION_SECONDS = 1800;
|
|
71
|
+
const DEFAULT_MAX_QUEUE_WAIT_SECONDS = 900;
|
|
72
|
+
const DEFAULT_ABORT_GRACE_SECONDS = 5;
|
|
73
|
+
const DEFAULT_CONSUMED_RETENTION_MINUTES = 10;
|
|
74
|
+
const DEFAULT_UNCONSUMED_RETENTION_MINUTES = 720;
|
|
75
|
+
const DEFAULT_ABORT_ALL_ON_INTERRUPT = true;
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Owns all in-memory settings values and their load/save/persist cycle.
|
|
79
|
+
* Replaces the scattered free-function + SettingsAppliers callback pattern.
|
|
80
|
+
*/
|
|
81
|
+
export class SettingsManager {
|
|
82
|
+
private _maxConcurrent: number = DEFAULT_MAX_CONCURRENT;
|
|
83
|
+
private _maxTurns: number = DEFAULT_MAX_TURNS;
|
|
84
|
+
private _wrapUpTurns: number = DEFAULT_WRAP_UP_TURNS;
|
|
85
|
+
private _maxDurationSeconds: number = DEFAULT_MAX_DURATION_SECONDS;
|
|
86
|
+
private _maxQueueWaitSeconds: number = DEFAULT_MAX_QUEUE_WAIT_SECONDS;
|
|
87
|
+
private _abortGraceSeconds: number = DEFAULT_ABORT_GRACE_SECONDS;
|
|
88
|
+
private _consumedSessionRetentionMinutes: number = DEFAULT_CONSUMED_RETENTION_MINUTES;
|
|
89
|
+
private _unconsumedSessionRetentionMinutes: number = DEFAULT_UNCONSUMED_RETENTION_MINUTES;
|
|
90
|
+
private _abortAllOnInterrupt: boolean = DEFAULT_ABORT_ALL_ON_INTERRUPT;
|
|
91
|
+
private _excludedExtensionPackages: string[] = [];
|
|
92
|
+
|
|
93
|
+
private readonly emit: SettingsEmit;
|
|
94
|
+
private readonly cwd: string;
|
|
95
|
+
private readonly agentDir: string;
|
|
96
|
+
private readonly onMaxConcurrentChanged: (() => void) | undefined;
|
|
97
|
+
|
|
98
|
+
constructor(deps: { emit: SettingsEmit; cwd: string; agentDir: string; onMaxConcurrentChanged?: () => void }) {
|
|
99
|
+
this.emit = deps.emit;
|
|
100
|
+
this.cwd = deps.cwd;
|
|
101
|
+
this.agentDir = deps.agentDir;
|
|
102
|
+
this.onMaxConcurrentChanged = deps.onMaxConcurrentChanged;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// ── maxConcurrent: minimum 1 ──
|
|
106
|
+
|
|
107
|
+
get maxConcurrent(): number {
|
|
108
|
+
return this._maxConcurrent;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
set maxConcurrent(n: number) {
|
|
112
|
+
this._maxConcurrent = Math.max(1, n);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// ── maxTurns: positive integer ≥ 1; lowering it re-clamps wrapUpTurns ──
|
|
116
|
+
|
|
117
|
+
get maxTurns(): number {
|
|
118
|
+
return this._maxTurns;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
set maxTurns(n: number) {
|
|
122
|
+
this._maxTurns = clampMin(n, MAX_TURNS_CEILING);
|
|
123
|
+
this._wrapUpTurns = clampWrapUpTurns(this._wrapUpTurns, this._maxTurns);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// ── wrapUpTurns: 0 allowed; always kept below maxTurns ──
|
|
127
|
+
|
|
128
|
+
get wrapUpTurns(): number {
|
|
129
|
+
return this._wrapUpTurns;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
set wrapUpTurns(n: number) {
|
|
133
|
+
this._wrapUpTurns = clampWrapUpTurns(n, this._maxTurns);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// ── duration budgets: seconds, minimum 1 ──
|
|
137
|
+
|
|
138
|
+
get maxDurationSeconds(): number {
|
|
139
|
+
return this._maxDurationSeconds;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
set maxDurationSeconds(n: number) {
|
|
143
|
+
this._maxDurationSeconds = clampMin(n, MAX_DURATION_SECONDS_CEILING);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
get maxQueueWaitSeconds(): number {
|
|
147
|
+
return this._maxQueueWaitSeconds;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
set maxQueueWaitSeconds(n: number) {
|
|
151
|
+
this._maxQueueWaitSeconds = clampMin(n, MAX_QUEUE_WAIT_SECONDS_CEILING);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
get abortGraceSeconds(): number {
|
|
155
|
+
return this._abortGraceSeconds;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
set abortGraceSeconds(n: number) {
|
|
159
|
+
this._abortGraceSeconds = clampMin(n, ABORT_GRACE_SECONDS_CEILING);
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
// ── retention windows: clamped to [1, RETENTION_MINUTES_CEILING] minutes ──
|
|
163
|
+
|
|
164
|
+
get consumedSessionRetentionMinutes(): number {
|
|
165
|
+
return this._consumedSessionRetentionMinutes;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
set consumedSessionRetentionMinutes(n: number) {
|
|
169
|
+
this._consumedSessionRetentionMinutes = clampRetentionMinutes(n);
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
get unconsumedSessionRetentionMinutes(): number {
|
|
173
|
+
return this._unconsumedSessionRetentionMinutes;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
set unconsumedSessionRetentionMinutes(n: number) {
|
|
177
|
+
this._unconsumedSessionRetentionMinutes = clampRetentionMinutes(n);
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
// ── abortAllOnInterrupt: flipped via toggleAbortAllOnInterrupt(); no normalization ──
|
|
181
|
+
|
|
182
|
+
get abortAllOnInterrupt(): boolean {
|
|
183
|
+
return this._abortAllOnInterrupt;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
// ── excludedExtensionPackages: hand-edited only; no /subagents:settings affordance ──
|
|
187
|
+
|
|
188
|
+
get excludedExtensionPackages(): readonly string[] {
|
|
189
|
+
return this._excludedExtensionPackages;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
// ── Lifecycle methods ──
|
|
193
|
+
|
|
194
|
+
/**
|
|
195
|
+
* Load merged settings (global + project), apply to in-memory values,
|
|
196
|
+
* and emit the `subagents:settings_loaded` lifecycle event.
|
|
197
|
+
* Returns the raw loaded settings object.
|
|
198
|
+
*/
|
|
199
|
+
load(): SubagentsSettings {
|
|
200
|
+
const settings = loadSettings(this.agentDir, this.cwd);
|
|
201
|
+
if (typeof settings.maxConcurrent === "number") this.maxConcurrent = settings.maxConcurrent;
|
|
202
|
+
// maxTurns before wrapUpTurns: the turn budget clamps the wrap-up window.
|
|
203
|
+
if (typeof settings.maxTurns === "number") this.maxTurns = settings.maxTurns;
|
|
204
|
+
if (typeof settings.wrapUpTurns === "number") this.wrapUpTurns = settings.wrapUpTurns;
|
|
205
|
+
if (typeof settings.maxDurationSeconds === "number") this.maxDurationSeconds = settings.maxDurationSeconds;
|
|
206
|
+
if (typeof settings.maxQueueWaitSeconds === "number") this.maxQueueWaitSeconds = settings.maxQueueWaitSeconds;
|
|
207
|
+
if (typeof settings.abortGraceSeconds === "number") this.abortGraceSeconds = settings.abortGraceSeconds;
|
|
208
|
+
if (typeof settings.consumedSessionRetentionMinutes === "number")
|
|
209
|
+
this.consumedSessionRetentionMinutes = settings.consumedSessionRetentionMinutes;
|
|
210
|
+
if (typeof settings.unconsumedSessionRetentionMinutes === "number")
|
|
211
|
+
this.unconsumedSessionRetentionMinutes = settings.unconsumedSessionRetentionMinutes;
|
|
212
|
+
if (typeof settings.abortAllOnInterrupt === "boolean")
|
|
213
|
+
this._abortAllOnInterrupt = settings.abortAllOnInterrupt;
|
|
214
|
+
// Assigned unconditionally: removing the key from disk must clear the value.
|
|
215
|
+
this._excludedExtensionPackages = [...(settings.excludedExtensionPackages ?? [])];
|
|
216
|
+
this.emit("subagents:settings_loaded", { settings });
|
|
217
|
+
return settings;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/** Snapshot current in-memory values for persistence. */
|
|
221
|
+
snapshot(): SettingsSnapshot {
|
|
222
|
+
const snapshot: SettingsSnapshot = {
|
|
223
|
+
maxConcurrent: this._maxConcurrent,
|
|
224
|
+
maxTurns: this._maxTurns,
|
|
225
|
+
wrapUpTurns: this._wrapUpTurns,
|
|
226
|
+
maxDurationSeconds: this._maxDurationSeconds,
|
|
227
|
+
maxQueueWaitSeconds: this._maxQueueWaitSeconds,
|
|
228
|
+
abortGraceSeconds: this._abortGraceSeconds,
|
|
229
|
+
consumedSessionRetentionMinutes: this._consumedSessionRetentionMinutes,
|
|
230
|
+
unconsumedSessionRetentionMinutes: this._unconsumedSessionRetentionMinutes,
|
|
231
|
+
abortAllOnInterrupt: this._abortAllOnInterrupt,
|
|
232
|
+
};
|
|
233
|
+
if (this._excludedExtensionPackages.length > 0) {
|
|
234
|
+
snapshot.excludedExtensionPackages = [...this._excludedExtensionPackages];
|
|
235
|
+
}
|
|
236
|
+
return snapshot;
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
/**
|
|
240
|
+
* Set maxConcurrent, notify interested parties, persist, and return the toast.
|
|
241
|
+
* Owns the full consequence chain so callers just say what they want.
|
|
242
|
+
*/
|
|
243
|
+
applyMaxConcurrent(n: number): { message: string; level: "info" | "warning" } {
|
|
244
|
+
this.maxConcurrent = n; // setter normalizes: max(1, n)
|
|
245
|
+
this.onMaxConcurrentChanged?.();
|
|
246
|
+
return this.saveAndNotify(`Max concurrency set to ${this.maxConcurrent}`);
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
/** Set the global turn budget, persist, and return the toast. */
|
|
250
|
+
applyMaxTurns(n: number): { message: string; level: "info" | "warning" } {
|
|
251
|
+
this.maxTurns = n; // setter normalizes: clamp [1, ceiling]
|
|
252
|
+
return this.saveAndNotify(`Max turns set to ${this.maxTurns}`);
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
/** Set the global wrap-up window (turns), persist, and return the toast. */
|
|
256
|
+
applyWrapUpTurns(n: number): { message: string; level: "info" | "warning" } {
|
|
257
|
+
this.wrapUpTurns = n; // setter normalizes: clamp [0, maxTurns - 1]
|
|
258
|
+
return this.saveAndNotify(`Wrap-up turns set to ${this.wrapUpTurns}`);
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
/** Set the global run-duration budget (seconds), persist, and return the toast. */
|
|
262
|
+
applyMaxDurationSeconds(n: number): { message: string; level: "info" | "warning" } {
|
|
263
|
+
this.maxDurationSeconds = n; // setter normalizes: clamp [1, ceiling]
|
|
264
|
+
return this.saveAndNotify(`Max duration set to ${this.maxDurationSeconds} s`);
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
/** Set the global queue-wait budget (seconds), persist, and return the toast. */
|
|
268
|
+
applyMaxQueueWaitSeconds(n: number): { message: string; level: "info" | "warning" } {
|
|
269
|
+
this.maxQueueWaitSeconds = n; // setter normalizes: clamp [1, ceiling]
|
|
270
|
+
return this.saveAndNotify(`Max queue wait set to ${this.maxQueueWaitSeconds} s`);
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
/** Set the abort grace period (seconds), persist, and return the toast. */
|
|
274
|
+
applyAbortGraceSeconds(n: number): { message: string; level: "info" | "warning" } {
|
|
275
|
+
this.abortGraceSeconds = n; // setter normalizes: clamp [1, ceiling]
|
|
276
|
+
return this.saveAndNotify(`Abort grace set to ${this.abortGraceSeconds} s`);
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
/** Set the consumed-session retention window (minutes), persist, and return the toast. */
|
|
280
|
+
applyConsumedSessionRetentionMinutes(n: number): { message: string; level: "info" | "warning" } {
|
|
281
|
+
this.consumedSessionRetentionMinutes = n; // setter normalizes: clamp [1, ceiling]
|
|
282
|
+
return this.saveAndNotify(`Consumed-session retention set to ${this.consumedSessionRetentionMinutes} min`);
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
/** Set the unconsumed-session retention window (minutes), persist, and return the toast. */
|
|
286
|
+
applyUnconsumedSessionRetentionMinutes(n: number): { message: string; level: "info" | "warning" } {
|
|
287
|
+
this.unconsumedSessionRetentionMinutes = n; // setter normalizes: clamp [1, ceiling]
|
|
288
|
+
return this.saveAndNotify(`Unconsumed-session retention set to ${this.unconsumedSessionRetentionMinutes} min`);
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
/**
|
|
292
|
+
* Flip whether a parent interrupt (ESC) aborts every subagent, persist, and
|
|
293
|
+
* return the toast. The manager owns the negation so callers just say "flip it".
|
|
294
|
+
*/
|
|
295
|
+
toggleAbortAllOnInterrupt(): { message: string; level: "info" | "warning" } {
|
|
296
|
+
this._abortAllOnInterrupt = !this._abortAllOnInterrupt;
|
|
297
|
+
return this.saveAndNotify(
|
|
298
|
+
`Abort all subagents on ESC: ${this._abortAllOnInterrupt ? "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
|
+
// wrapUpTurns must stay below maxTurns, so its ceiling is one turn lower.
|
|
320
|
+
const WRAP_UP_TURNS_CEILING = MAX_TURNS_CEILING - 1;
|
|
321
|
+
const ONE_DAY_SECONDS = 60 * 60 * 24;
|
|
322
|
+
const MAX_DURATION_SECONDS_CEILING = ONE_DAY_SECONDS;
|
|
323
|
+
const MAX_QUEUE_WAIT_SECONDS_CEILING = ONE_DAY_SECONDS;
|
|
324
|
+
const ABORT_GRACE_SECONDS_CEILING = 60 * 60;
|
|
325
|
+
// Retention windows: 1 minute floor, two-week ceiling (60 * 24 * 14).
|
|
326
|
+
const RETENTION_MINUTES_CEILING = 20_160;
|
|
327
|
+
|
|
328
|
+
/** Clamp a budget to [1, ceiling]. */
|
|
329
|
+
function clampMin(n: number, ceiling: number): number {
|
|
330
|
+
return Math.min(ceiling, Math.max(1, n));
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
/** Clamp a retention window to [1, RETENTION_MINUTES_CEILING] minutes. */
|
|
334
|
+
function clampRetentionMinutes(n: number): number {
|
|
335
|
+
return Math.min(RETENTION_MINUTES_CEILING, Math.max(1, n));
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
/** True when a value is an integer minute count within the accepted retention range. */
|
|
339
|
+
function isRetentionMinutes(n: unknown): n is number {
|
|
340
|
+
return Number.isInteger(n) && (n as number) >= 1 && (n as number) <= RETENTION_MINUTES_CEILING;
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
/** True when a value is an integer within [min, max]. */
|
|
344
|
+
function isIntInRange(value: unknown, min: number, max: number): value is number {
|
|
345
|
+
return Number.isInteger(value) && (value as number) >= min && (value as number) <= max;
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
/** Drop fields that don't match the expected shape. Silent — garbage becomes absent. */
|
|
349
|
+
function sanitize(raw: unknown): SubagentsSettings {
|
|
350
|
+
if (!raw || typeof raw !== "object") return {};
|
|
351
|
+
const r = raw as Record<string, unknown>;
|
|
352
|
+
const out: SubagentsSettings = {};
|
|
353
|
+
if (isIntInRange(r.maxConcurrent, 1, MAX_CONCURRENT_CEILING)) {
|
|
354
|
+
out.maxConcurrent = r.maxConcurrent;
|
|
355
|
+
}
|
|
356
|
+
if (isIntInRange(r.maxTurns, 1, MAX_TURNS_CEILING)) {
|
|
357
|
+
out.maxTurns = r.maxTurns;
|
|
358
|
+
}
|
|
359
|
+
if (isIntInRange(r.wrapUpTurns, 0, WRAP_UP_TURNS_CEILING)) {
|
|
360
|
+
out.wrapUpTurns = r.wrapUpTurns;
|
|
361
|
+
}
|
|
362
|
+
if (isIntInRange(r.maxDurationSeconds, 1, MAX_DURATION_SECONDS_CEILING)) {
|
|
363
|
+
out.maxDurationSeconds = r.maxDurationSeconds;
|
|
364
|
+
}
|
|
365
|
+
if (isIntInRange(r.maxQueueWaitSeconds, 1, MAX_QUEUE_WAIT_SECONDS_CEILING)) {
|
|
366
|
+
out.maxQueueWaitSeconds = r.maxQueueWaitSeconds;
|
|
367
|
+
}
|
|
368
|
+
if (isIntInRange(r.abortGraceSeconds, 1, ABORT_GRACE_SECONDS_CEILING)) {
|
|
369
|
+
out.abortGraceSeconds = r.abortGraceSeconds;
|
|
370
|
+
}
|
|
371
|
+
if (isRetentionMinutes(r.consumedSessionRetentionMinutes)) {
|
|
372
|
+
out.consumedSessionRetentionMinutes = r.consumedSessionRetentionMinutes;
|
|
373
|
+
}
|
|
374
|
+
if (isRetentionMinutes(r.unconsumedSessionRetentionMinutes)) {
|
|
375
|
+
out.unconsumedSessionRetentionMinutes = r.unconsumedSessionRetentionMinutes;
|
|
376
|
+
}
|
|
377
|
+
if (typeof r.abortAllOnInterrupt === "boolean") {
|
|
378
|
+
out.abortAllOnInterrupt = r.abortAllOnInterrupt;
|
|
379
|
+
}
|
|
380
|
+
if (Array.isArray(r.excludedExtensionPackages)) {
|
|
381
|
+
const sources = r.excludedExtensionPackages
|
|
382
|
+
.filter((value): value is string => typeof value === "string")
|
|
383
|
+
.map((value) => value.trim())
|
|
384
|
+
.filter(Boolean);
|
|
385
|
+
out.excludedExtensionPackages = [...new Set(sources)];
|
|
386
|
+
}
|
|
387
|
+
return out;
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
function projectPath(cwd: string): string {
|
|
391
|
+
return join(cwd, ".pi", "subagents.json");
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
/** Load merged settings: global provides defaults, project overrides. */
|
|
395
|
+
export function loadSettings(agentDir: string, cwd: string): SubagentsSettings {
|
|
396
|
+
return loadLayeredSettings({
|
|
397
|
+
agentDir,
|
|
398
|
+
cwd,
|
|
399
|
+
filename: "subagents.json",
|
|
400
|
+
sanitize,
|
|
401
|
+
warnLabel: "pi-subagents",
|
|
402
|
+
} satisfies LayeredSettingsSource<SubagentsSettings>);
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
/**
|
|
406
|
+
* Update project-local settings. Global is never touched from code.
|
|
407
|
+
* Returns `true` on success, `false` if the locked atomic update fails so the
|
|
408
|
+
* caller can surface a warning — persistence isn't fatal but isn't silent.
|
|
409
|
+
*/
|
|
410
|
+
export function saveSettings(s: SubagentsSettings, cwd: string = process.cwd()): boolean {
|
|
411
|
+
const path = projectPath(cwd);
|
|
412
|
+
try {
|
|
413
|
+
updateSettingsFile(path, (current) => ({ ...current, ...withoutUndefined(s) }));
|
|
414
|
+
return true;
|
|
415
|
+
} catch {
|
|
416
|
+
return false;
|
|
417
|
+
}
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
function withoutUndefined(settings: SubagentsSettings): Record<string, unknown> {
|
|
421
|
+
return Object.fromEntries(Object.entries(settings).filter(([, value]) => value !== undefined));
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
/**
|
|
425
|
+
* Format the user-facing toast for a settings mutation. Pure function —
|
|
426
|
+
* routes the success/failure of `saveSettings` into the right message + level
|
|
427
|
+
* so the UI layer (index.ts) stays a thin wire between input and notification.
|
|
428
|
+
*/
|
|
429
|
+
export function persistToastFor(
|
|
430
|
+
successMsg: string,
|
|
431
|
+
persisted: boolean,
|
|
432
|
+
): { message: string; level: "info" | "warning" } {
|
|
433
|
+
return persisted
|
|
434
|
+
? { message: successMsg, level: "info" }
|
|
435
|
+
: { message: `${successMsg} (session only; failed to persist)`, level: "warning" };
|
|
436
|
+
}
|