@nredd/pi-subagents 0.22.0-nredd.7 → 0.22.0-nredd.9
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/README.md +245 -77
- package/package.json +16 -13
- package/src/abortable.ts +1 -1
- package/src/agent-file-toggle.ts +6 -5
- package/src/agent-manager.ts +489 -129
- package/src/agent-runner.ts +244 -93
- package/src/child-context.ts +19 -1
- package/src/completion-outbox.ts +22 -3
- package/src/cross-extension-rpc.ts +21 -42
- package/src/custom-agents.ts +11 -6
- package/src/default-agents.ts +21 -21
- package/src/detached-results.ts +18 -2
- package/src/enabled-models.ts +8 -7
- package/src/enterprise-local.ts +7 -6
- package/src/error-message.ts +11 -0
- package/src/git-path.ts +66 -0
- package/src/group-join.ts +4 -10
- package/src/home-paths.ts +27 -0
- package/src/index.ts +898 -297
- package/src/invocation-config.ts +4 -21
- package/src/memory.ts +9 -9
- package/src/mention-clone.ts +13 -8
- package/src/model-resolver.ts +92 -25
- package/src/model-scope.ts +2 -1
- package/src/model-selection.ts +170 -0
- package/src/model-tiers.ts +176 -0
- package/src/nested-tools.ts +99 -44
- package/src/notification-result.ts +118 -0
- package/src/output-file.ts +23 -1
- package/src/package-resources.ts +25 -10
- package/src/plan-mode-children.ts +176 -0
- package/src/process-budget.ts +6 -3
- package/src/prompts.ts +7 -2
- package/src/quota-admission.ts +35 -13
- package/src/resource-loader.ts +65 -0
- package/src/schedule.ts +94 -30
- package/src/settings.ts +63 -13
- package/src/skill-loader.ts +244 -93
- package/src/stale-ctx.ts +16 -0
- package/src/status-note.ts +28 -5
- package/src/types.ts +79 -2
- package/src/ui/agent-mention.ts +5 -2
- package/src/ui/agent-row.ts +13 -3
- package/src/ui/agent-widget.ts +55 -25
- package/src/ui/conversation-viewer.ts +10 -5
- package/src/ui/fallback-flag.ts +114 -0
- package/src/ui/fleet-list.ts +431 -86
- package/src/ui/fleet-state.ts +154 -0
- package/src/ui/notification-view.ts +22 -3
- package/src/ui/workflow-card.ts +6 -9
- package/src/ui/workflow-dialog.ts +36 -13
- package/src/workflow/host.ts +47 -50
- package/src/workflow/journal.ts +13 -1
- package/src/workflow/meta.ts +7 -4
- package/src/workflow/progress.ts +8 -3
- package/src/workflow/runtime.ts +48 -15
- package/src/workflow/saved.ts +21 -21
- package/src/workflow/task.ts +22 -7
- package/src/workflow/tool-description.ts +2 -2
- package/src/workflow/worker-source.ts +162 -40
- package/src/worktree.ts +309 -19
package/src/schedule.ts
CHANGED
|
@@ -15,15 +15,14 @@
|
|
|
15
15
|
* `subagent-notification` path. No new delivery code.
|
|
16
16
|
*/
|
|
17
17
|
|
|
18
|
-
import type { Model } from "@earendil-works/pi-ai";
|
|
19
18
|
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
20
19
|
import { Cron } from "croner";
|
|
21
20
|
import { nanoid } from "nanoid";
|
|
22
21
|
import type { AgentManager } from "./agent-manager.js";
|
|
23
22
|
import { normalizeMaxTurns } from "./agent-runner.js";
|
|
24
|
-
import { resolveSpawnType } from "./agent-types.js";
|
|
23
|
+
import { getAgentConfig, resolveSpawnType } from "./agent-types.js";
|
|
25
24
|
import { type Clock, realClock, type TimerHandle } from "./clock.js";
|
|
26
|
-
import {
|
|
25
|
+
import { admitModelSelection, type ModelSelection, selectModel } from "./model-selection.js";
|
|
27
26
|
import { checkAdmission, describeBlock, mayPark, modelRef, PARKED_RESUME_DELAY_MS, QUOTA_RESTART_GRACE_MS } from "./quota-admission.js";
|
|
28
27
|
import type { ScheduleStore } from "./schedule-store.js";
|
|
29
28
|
import type { IsolationMode, ScheduledSubagent, SubagentType, ThinkingLevel } from "./types.js";
|
|
@@ -52,8 +51,17 @@ export interface NewJobInput {
|
|
|
52
51
|
quotaParked?: boolean;
|
|
53
52
|
}
|
|
54
53
|
|
|
54
|
+
/** What `start()` binds, captured by a fire so a later session switch cannot leak into it. */
|
|
55
|
+
interface BoundSession {
|
|
56
|
+
store: ScheduleStore;
|
|
57
|
+
pi: ExtensionAPI;
|
|
58
|
+
ctx: ExtensionContext;
|
|
59
|
+
manager: AgentManager;
|
|
60
|
+
}
|
|
61
|
+
|
|
55
62
|
/** Optional in-process scheduling dependencies; omitted clock uses native time. */
|
|
56
63
|
export interface SubagentSchedulerOptions {
|
|
64
|
+
homeDir?: string;
|
|
57
65
|
clock?: Clock;
|
|
58
66
|
}
|
|
59
67
|
|
|
@@ -61,17 +69,22 @@ export class SubagentScheduler {
|
|
|
61
69
|
private readonly clock: Clock;
|
|
62
70
|
private jobs = new Map<string, Cron>();
|
|
63
71
|
private intervals = new Map<string, TimerHandle>();
|
|
72
|
+
private scheduled = new Map<string, object>();
|
|
73
|
+
private admissions = new Map<string, Set<AbortController>>();
|
|
74
|
+
/** Newest pending admission per job that wrote "running", with the status it replaced. */
|
|
75
|
+
private pendingRuns = new Map<string, { token: AbortController; prior: ScheduledSubagent["lastStatus"] }>();
|
|
64
76
|
private store: ScheduleStore | undefined;
|
|
65
77
|
private pi: ExtensionAPI | undefined;
|
|
66
78
|
private ctx: ExtensionContext | undefined;
|
|
67
79
|
private manager: AgentManager | undefined;
|
|
68
80
|
|
|
69
|
-
constructor(options: SubagentSchedulerOptions = {}) {
|
|
81
|
+
constructor(private readonly options: SubagentSchedulerOptions = {}) {
|
|
70
82
|
this.clock = options.clock ?? realClock;
|
|
71
83
|
}
|
|
72
84
|
|
|
73
85
|
/** Start the scheduler: bind to a session's store and arm enabled jobs. */
|
|
74
86
|
start(pi: ExtensionAPI, ctx: ExtensionContext, manager: AgentManager, store: ScheduleStore): void {
|
|
87
|
+
this.stop();
|
|
75
88
|
this.pi = pi;
|
|
76
89
|
this.ctx = ctx;
|
|
77
90
|
this.manager = manager;
|
|
@@ -88,6 +101,10 @@ export class SubagentScheduler {
|
|
|
88
101
|
this.jobs.clear();
|
|
89
102
|
for (const t of this.intervals.values()) t.cancel();
|
|
90
103
|
this.intervals.clear();
|
|
104
|
+
for (const admissions of this.admissions.values()) for (const admission of admissions) admission.abort();
|
|
105
|
+
this.admissions.clear();
|
|
106
|
+
this.pendingRuns.clear();
|
|
107
|
+
this.scheduled.clear();
|
|
91
108
|
this.store = undefined;
|
|
92
109
|
this.pi = undefined;
|
|
93
110
|
this.ctx = undefined;
|
|
@@ -138,7 +155,7 @@ export class SubagentScheduler {
|
|
|
138
155
|
}
|
|
139
156
|
const job = this.buildJob(input);
|
|
140
157
|
store.add(job);
|
|
141
|
-
|
|
158
|
+
this.scheduleJob(job); // buildJob always creates an enabled job
|
|
142
159
|
this.emit({ type: "added", job });
|
|
143
160
|
return job;
|
|
144
161
|
}
|
|
@@ -183,8 +200,8 @@ export class SubagentScheduler {
|
|
|
183
200
|
// ── Scheduling primitives ────────────────────────────────────────────
|
|
184
201
|
|
|
185
202
|
private scheduleJob(job: ScheduledSubagent): void {
|
|
186
|
-
const store = this.
|
|
187
|
-
|
|
203
|
+
const store = this.requireStore();
|
|
204
|
+
this.scheduled.set(job.id, {});
|
|
188
205
|
try {
|
|
189
206
|
if (job.scheduleType === "interval" && job.intervalMs) {
|
|
190
207
|
const t = this.clock.setInterval(() => this.executeJob(job.id), job.intervalMs);
|
|
@@ -206,6 +223,7 @@ export class SubagentScheduler {
|
|
|
206
223
|
this.intervals.set(job.id, t);
|
|
207
224
|
} else {
|
|
208
225
|
// Past timestamp — disable, mark error, never fire
|
|
226
|
+
this.scheduled.delete(job.id);
|
|
209
227
|
store.update(job.id, { enabled: false, lastStatus: "error" });
|
|
210
228
|
this.emit({ type: "error", jobId: job.id, error: `Scheduled time ${job.schedule} is in the past` });
|
|
211
229
|
}
|
|
@@ -214,11 +232,16 @@ export class SubagentScheduler {
|
|
|
214
232
|
this.jobs.set(job.id, cron);
|
|
215
233
|
}
|
|
216
234
|
} catch (err) {
|
|
235
|
+
// Nothing armed, so no token: a stale entry would admit a fire for a job with no timer.
|
|
236
|
+
this.scheduled.delete(job.id);
|
|
217
237
|
this.emit({ type: "error", jobId: job.id, error: err instanceof Error ? err.message : String(err) });
|
|
218
238
|
}
|
|
219
239
|
}
|
|
220
240
|
|
|
221
241
|
private unscheduleJob(id: string): void {
|
|
242
|
+
this.scheduled.delete(id);
|
|
243
|
+
for (const admission of this.admissions.get(id) ?? []) admission.abort();
|
|
244
|
+
this.admissions.delete(id);
|
|
222
245
|
const cron = this.jobs.get(id);
|
|
223
246
|
if (cron) {
|
|
224
247
|
cron.stop();
|
|
@@ -241,28 +264,60 @@ export class SubagentScheduler {
|
|
|
241
264
|
const pi = this.pi;
|
|
242
265
|
const ctx = this.ctx;
|
|
243
266
|
const manager = this.manager;
|
|
244
|
-
|
|
267
|
+
const scheduled = this.scheduled.get(id);
|
|
268
|
+
if (!store || !pi || !ctx || !manager || !scheduled) return;
|
|
245
269
|
const job = store.get(id);
|
|
246
270
|
if (!job?.enabled) return;
|
|
247
271
|
|
|
272
|
+
// A fire that overlaps a pending admission inherits its prior status.
|
|
273
|
+
const previous = this.pendingRuns.get(id);
|
|
274
|
+
const prior = previous ? previous.prior : job.lastStatus;
|
|
275
|
+
this.pendingRuns.delete(id);
|
|
248
276
|
store.update(id, { lastStatus: "running" });
|
|
249
277
|
|
|
250
|
-
// Resolve
|
|
251
|
-
//
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
278
|
+
// Resolve the original request at fire time, using the same type and model
|
|
279
|
+
// policy as a manual dispatch. Never drop a failed explicit override.
|
|
280
|
+
const dispatch = resolveSpawnType(job.subagent_type);
|
|
281
|
+
const config = dispatch.ok ? getAgentConfig(dispatch.type) : undefined;
|
|
282
|
+
const selection = dispatch.ok ? selectModel({
|
|
283
|
+
homeDir: this.options.homeDir,
|
|
284
|
+
agentType: dispatch.type, definitionModel: config?.model, agentLabel: config?.displayName,
|
|
285
|
+
paramModel: job.model, parentModel: ctx.model, registry: ctx.modelRegistry, cwd: ctx.cwd,
|
|
286
|
+
}) : dispatch.message;
|
|
287
|
+
if (typeof selection === "string") {
|
|
288
|
+
store.update(id, { lastRun: new Date(this.clock.now()).toISOString(), lastStatus: "error" });
|
|
289
|
+
this.emit({ type: "error", jobId: id, error: selection });
|
|
290
|
+
return;
|
|
257
291
|
}
|
|
258
292
|
|
|
293
|
+
if (selection.scopeWarning) ctx.ui.notify(selection.scopeWarning, "warning");
|
|
294
|
+
|
|
259
295
|
// A quota-parked job re-asks the router before starting: the window it
|
|
260
296
|
// waited for may have refilled while another is still exhausted.
|
|
261
|
-
const admissionModel =
|
|
262
|
-
if (job.quotaParked && admissionModel) {
|
|
263
|
-
|
|
297
|
+
const admissionModel = selection.model;
|
|
298
|
+
if ((job.quotaParked || selection.tierBinding) && admissionModel) {
|
|
299
|
+
const controller = new AbortController();
|
|
300
|
+
const admissions = this.admissions.get(id) ?? new Set<AbortController>();
|
|
301
|
+
admissions.add(controller);
|
|
302
|
+
this.admissions.set(id, admissions);
|
|
303
|
+
this.pendingRuns.set(id, { token: controller, prior });
|
|
304
|
+
// Cancelled before spawn: undo this admission's "running", never a newer one's.
|
|
305
|
+
controller.signal.addEventListener("abort", () => {
|
|
306
|
+
if (this.pendingRuns.get(id)?.token !== controller) return;
|
|
307
|
+
this.pendingRuns.delete(id);
|
|
308
|
+
if (store.get(id)?.lastStatus === "running") store.update(id, { lastStatus: prior });
|
|
309
|
+
}, { once: true });
|
|
310
|
+
const admission = selection.tierBinding
|
|
311
|
+
? admitModelSelection(selection, ctx.modelRegistry, pi.events, this.clock, controller.signal)
|
|
312
|
+
: checkAdmission(pi.events, modelRef(admissionModel), undefined, this.clock, controller.signal);
|
|
313
|
+
const settle = (): void => {
|
|
314
|
+
if (this.pendingRuns.get(id)?.token === controller) this.pendingRuns.delete(id);
|
|
315
|
+
};
|
|
316
|
+
void admission.then(block => {
|
|
317
|
+
if (this.store !== store || this.ctx !== ctx || this.scheduled.get(id) !== scheduled) return;
|
|
318
|
+
settle();
|
|
264
319
|
if (!block) {
|
|
265
|
-
this.spawnJob(id,
|
|
320
|
+
this.spawnJob({ store, pi, ctx, manager }, id, selection);
|
|
266
321
|
} else if (mayPark(block) && block.resetAt > this.clock.now()) {
|
|
267
322
|
this.repark(id, block.resetAt);
|
|
268
323
|
} else {
|
|
@@ -272,29 +327,36 @@ export class SubagentScheduler {
|
|
|
272
327
|
this.store?.update(id, { lastRun: new Date(this.clock.now()).toISOString(), lastStatus: "error" });
|
|
273
328
|
this.emit({ type: "error", jobId: id, error: `${describeBlock(block)} The agent was not started.` });
|
|
274
329
|
}
|
|
330
|
+
}).catch(error => {
|
|
331
|
+
if (this.store !== store || this.scheduled.get(id) !== scheduled) return;
|
|
332
|
+
settle();
|
|
333
|
+
store.update(id, { lastStatus: "error" });
|
|
334
|
+
this.emit({ type: "error", jobId: id, error: error instanceof Error ? error.message : String(error) });
|
|
335
|
+
}).finally(() => {
|
|
336
|
+
admissions.delete(controller);
|
|
337
|
+
if (admissions.size === 0 && this.admissions.get(id) === admissions) this.admissions.delete(id);
|
|
275
338
|
});
|
|
276
339
|
return;
|
|
277
340
|
}
|
|
278
|
-
this.spawnJob(id,
|
|
341
|
+
this.spawnJob({ store, pi, ctx, manager }, id, selection);
|
|
279
342
|
}
|
|
280
343
|
|
|
281
|
-
/**
|
|
344
|
+
/**
|
|
345
|
+
* Re-arm a quota-parked one-shot at a later reset instead of starting it.
|
|
346
|
+
* A job removed while admission was pending stays removed: `updateJob` is a
|
|
347
|
+
* no-op for an unknown id.
|
|
348
|
+
*/
|
|
282
349
|
private repark(id: string, resetAt: number): void {
|
|
283
|
-
if (!this.store?.get(id)) return;
|
|
284
350
|
this.updateJob(id, {
|
|
285
351
|
enabled: true,
|
|
286
352
|
schedule: new Date(resetAt + QUOTA_RESTART_GRACE_MS).toISOString(),
|
|
287
353
|
lastStatus: undefined,
|
|
354
|
+
quotaParked: true,
|
|
288
355
|
});
|
|
289
356
|
}
|
|
290
357
|
|
|
291
358
|
/** Spawn a fired job (bypassing the concurrency queue) and persist its outcome. */
|
|
292
|
-
private spawnJob(id: string,
|
|
293
|
-
const store = this.store;
|
|
294
|
-
const pi = this.pi;
|
|
295
|
-
const ctx = this.ctx;
|
|
296
|
-
const manager = this.manager;
|
|
297
|
-
if (!store || !pi || !ctx || !manager) return;
|
|
359
|
+
private spawnJob({ store, pi, ctx, manager }: BoundSession, id: string, selection: ModelSelection): void {
|
|
298
360
|
const job = store.get(id);
|
|
299
361
|
if (!job) return;
|
|
300
362
|
|
|
@@ -312,7 +374,8 @@ export class SubagentScheduler {
|
|
|
312
374
|
description: job.description,
|
|
313
375
|
isBackground: true,
|
|
314
376
|
bypassQueue: true,
|
|
315
|
-
model:
|
|
377
|
+
model: selection.model,
|
|
378
|
+
modelSelection: selection,
|
|
316
379
|
maxTurns: job.max_turns,
|
|
317
380
|
isolated: job.isolated,
|
|
318
381
|
thinkingLevel: job.thinking,
|
|
@@ -429,7 +492,8 @@ export class SubagentScheduler {
|
|
|
429
492
|
new Cron(expr, () => {});
|
|
430
493
|
return { valid: true };
|
|
431
494
|
} catch (e) {
|
|
432
|
-
|
|
495
|
+
// Croner only throws Error subclasses (TypeError, RangeError).
|
|
496
|
+
return { valid: false, error: (e as Error).message };
|
|
433
497
|
}
|
|
434
498
|
}
|
|
435
499
|
|
package/src/settings.ts
CHANGED
|
@@ -4,9 +4,12 @@
|
|
|
4
4
|
|
|
5
5
|
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
6
6
|
import { dirname, join } from "node:path";
|
|
7
|
-
import { getAgentDir } from "@earendil-works/pi-coding-agent";
|
|
8
7
|
import { NO_FALLBACK } from "./agent-types.js";
|
|
9
8
|
import { readEnterpriseLocalManifest } from "./enterprise-local.js";
|
|
9
|
+
import { errorMessage } from "./error-message.js";
|
|
10
|
+
import { resolveAgentDir } from "./home-paths.js";
|
|
11
|
+
import { type ModelResolution, parseTierBinding, setModelTiers } from "./model-tiers.js";
|
|
12
|
+
import { NOTIFICATION_RESULT_CHARS_CEILING } from "./notification-result.js";
|
|
10
13
|
import { type BudgetPolicy, DEFAULT_BUDGET_POLICY } from "./process-budget.js";
|
|
11
14
|
import type { AgentMentionMode, JoinMode, ViewerMarkdownMode, WidgetMode } from "./types.js";
|
|
12
15
|
|
|
@@ -65,6 +68,15 @@ export interface SubagentsSettings {
|
|
|
65
68
|
*/
|
|
66
69
|
defaultMaxTurns?: number;
|
|
67
70
|
graceTurns?: number;
|
|
71
|
+
/**
|
|
72
|
+
* Result characters a background completion notice carries per agent,
|
|
73
|
+
* defaulting to `16000`. Past it the notice keeps the head and tail and
|
|
74
|
+
* names the transcript and `get_subagent_result`; agents that finish
|
|
75
|
+
* together share four times this. A result delivered whole counts as read.
|
|
76
|
+
* `0` restores the 500-character preview (300 per `group` member). See
|
|
77
|
+
* `notification-result.ts`.
|
|
78
|
+
*/
|
|
79
|
+
notificationResultChars?: number;
|
|
68
80
|
defaultJoinMode?: JoinMode;
|
|
69
81
|
/**
|
|
70
82
|
* Whether a top-level `Agent` spawn that doesn't say runs detached.
|
|
@@ -268,6 +280,10 @@ export interface SubagentsSettings {
|
|
|
268
280
|
fallbackSubagent?: string;
|
|
269
281
|
/** Trusted-global logical name -> ordered canonical provider/model candidates. */
|
|
270
282
|
modelAliases?: Record<string, string[]>;
|
|
283
|
+
/** Trusted global/enterprise exact model bindings; never read from a project. */
|
|
284
|
+
modelTiers?: Record<string, string[]>;
|
|
285
|
+
/** Missing/unavailable tier policy. Both default to inherit with disclosure. */
|
|
286
|
+
modelResolution?: ModelResolution;
|
|
271
287
|
/**
|
|
272
288
|
* Trusted-global map of agent `type` (or `"*"` for every type) -> model, in
|
|
273
289
|
* any form `Agent({ model })` accepts. Precedence, highest first: the Agent
|
|
@@ -386,6 +402,7 @@ export interface SettingsAppliers {
|
|
|
386
402
|
setBudgetPolicy: (policy: BudgetPolicy) => void;
|
|
387
403
|
setDefaultMaxTurns: (n: number) => void;
|
|
388
404
|
setGraceTurns: (n: number) => void;
|
|
405
|
+
setNotificationResultChars: (n: number) => void;
|
|
389
406
|
setDefaultJoinMode: (mode: JoinMode) => void;
|
|
390
407
|
setBackgroundByDefault: (b: boolean) => void;
|
|
391
408
|
setSchedulingEnabled: (b: boolean) => void;
|
|
@@ -497,6 +514,13 @@ function sanitize(raw: unknown): SubagentsSettings {
|
|
|
497
514
|
) {
|
|
498
515
|
out.graceTurns = r.graceTurns as number;
|
|
499
516
|
}
|
|
517
|
+
if (
|
|
518
|
+
Number.isInteger(r.notificationResultChars) &&
|
|
519
|
+
(r.notificationResultChars as number) >= 0 &&
|
|
520
|
+
(r.notificationResultChars as number) <= NOTIFICATION_RESULT_CHARS_CEILING
|
|
521
|
+
) {
|
|
522
|
+
out.notificationResultChars = r.notificationResultChars as number;
|
|
523
|
+
}
|
|
500
524
|
if (
|
|
501
525
|
Number.isInteger(r.maxSubagentDepth) &&
|
|
502
526
|
(r.maxSubagentDepth as number) >= 0 &&
|
|
@@ -576,6 +600,24 @@ function sanitize(raw: unknown): SubagentsSettings {
|
|
|
576
600
|
} else if (typeof r.fallbackSubagent === "string" && r.fallbackSubagent.trim()) {
|
|
577
601
|
out.fallbackSubagent = r.fallbackSubagent.trim();
|
|
578
602
|
}
|
|
603
|
+
if (r.modelTiers !== undefined) {
|
|
604
|
+
out.modelTiers = {};
|
|
605
|
+
if (r.modelTiers && typeof r.modelTiers === "object" && !Array.isArray(r.modelTiers)) {
|
|
606
|
+
for (const [tier, members] of Object.entries(r.modelTiers)) {
|
|
607
|
+
const binding = parseTierBinding(tier, members);
|
|
608
|
+
if (binding) out.modelTiers[tier] = binding.kind === "routed" ? [binding.profile] : binding.members;
|
|
609
|
+
else console.warn(`[pi-subagents] Ignoring invalid modelTiers entry "${tier}".`);
|
|
610
|
+
}
|
|
611
|
+
} else console.warn("[pi-subagents] Ignoring modelTiers: expected an object of tier -> model list.");
|
|
612
|
+
}
|
|
613
|
+
if (r.modelResolution && typeof r.modelResolution === "object" && !Array.isArray(r.modelResolution)) {
|
|
614
|
+
out.modelResolution = {};
|
|
615
|
+
for (const key of ["onUnmapped", "onUnavailable"] as const) {
|
|
616
|
+
const mode = (r.modelResolution as Record<string, unknown>)[key];
|
|
617
|
+
if (mode === "error" || mode === "inherit") out.modelResolution[key] = mode;
|
|
618
|
+
else if (mode !== undefined) console.warn(`[pi-subagents] Ignoring invalid modelResolution.${key}.`);
|
|
619
|
+
}
|
|
620
|
+
}
|
|
579
621
|
if (r.modelAliases !== undefined) {
|
|
580
622
|
const aliases = sanitizeModelAliases(r.modelAliases);
|
|
581
623
|
if (aliases) out.modelAliases = aliases;
|
|
@@ -630,8 +672,8 @@ function sanitizeModelAliases(raw: unknown): Record<string, string[]> | undefine
|
|
|
630
672
|
return aliases;
|
|
631
673
|
}
|
|
632
674
|
|
|
633
|
-
function globalPath(): string {
|
|
634
|
-
return join(
|
|
675
|
+
function globalPath(homeDir?: string): string {
|
|
676
|
+
return join(resolveAgentDir(homeDir), "subagents.json");
|
|
635
677
|
}
|
|
636
678
|
|
|
637
679
|
function projectPath(cwd: string): string {
|
|
@@ -648,23 +690,28 @@ function readSettingsFile(path: string): SubagentsSettings {
|
|
|
648
690
|
try {
|
|
649
691
|
return sanitize(JSON.parse(readFileSync(path, "utf-8")));
|
|
650
692
|
} catch (err) {
|
|
651
|
-
const reason =
|
|
693
|
+
const reason = errorMessage(err);
|
|
652
694
|
console.warn(`[pi-subagents] Ignoring malformed settings at ${path}: ${reason}`);
|
|
653
695
|
return {};
|
|
654
696
|
}
|
|
655
697
|
}
|
|
656
698
|
|
|
657
699
|
/** Load merged settings: global provides defaults, project overrides. */
|
|
658
|
-
export function loadSettings(cwd: string = process.cwd()): SubagentsSettings {
|
|
700
|
+
export function loadSettings(cwd: string = process.cwd(), homeDir?: string): SubagentsSettings {
|
|
659
701
|
// Aliases pick the provider a subagent runs on, so they are machine
|
|
660
702
|
// configuration: a cloned repo's project file must not redirect them.
|
|
661
703
|
const {
|
|
662
704
|
modelAliases: projectModelAliases,
|
|
705
|
+
modelTiers: projectModelTiers,
|
|
706
|
+
modelResolution: projectModelResolution,
|
|
663
707
|
agentModels: projectAgentModels,
|
|
664
708
|
maxActiveSubagents: projectMaxActive,
|
|
665
709
|
nestedSubagentReserve: projectReserve,
|
|
666
710
|
...project
|
|
667
711
|
} = readSettingsFile(projectPath(cwd));
|
|
712
|
+
if (projectModelTiers !== undefined || projectModelResolution !== undefined) {
|
|
713
|
+
console.warn("[pi-subagents] Ignoring modelTiers/modelResolution in project .pi/subagents.json: model policy is read from trusted global configuration only.");
|
|
714
|
+
}
|
|
668
715
|
if (projectModelAliases !== undefined) {
|
|
669
716
|
console.warn("[pi-subagents] Ignoring modelAliases in project .pi/subagents.json: aliases are read from trusted global configuration only.");
|
|
670
717
|
}
|
|
@@ -676,14 +723,14 @@ export function loadSettings(cwd: string = process.cwd()): SubagentsSettings {
|
|
|
676
723
|
if (projectMaxActive !== undefined || projectReserve !== undefined) {
|
|
677
724
|
console.warn("[pi-subagents] Ignoring maxActiveSubagents/nestedSubagentReserve in project .pi/subagents.json: the process-wide budget is read from global configuration only.");
|
|
678
725
|
}
|
|
679
|
-
return { ...trustedSettings(), ...project };
|
|
726
|
+
return { ...trustedSettings(homeDir), ...project };
|
|
680
727
|
}
|
|
681
728
|
|
|
682
729
|
/** Global settings with the enterprise manifest over them: the layer a project file cannot touch. */
|
|
683
|
-
function trustedSettings(): SubagentsSettings {
|
|
684
|
-
const enterprise = readEnterpriseLocalManifest()?.subagents;
|
|
730
|
+
function trustedSettings(homeDir?: string): SubagentsSettings {
|
|
731
|
+
const enterprise = readEnterpriseLocalManifest(homeDir)?.subagents;
|
|
685
732
|
const enterpriseSettings = enterprise === undefined ? {} : sanitize(enterprise);
|
|
686
|
-
return { ...readSettingsFile(globalPath()), ...enterpriseSettings };
|
|
733
|
+
return { ...readSettingsFile(globalPath(homeDir)), ...enterpriseSettings };
|
|
687
734
|
}
|
|
688
735
|
|
|
689
736
|
/**
|
|
@@ -692,8 +739,8 @@ function trustedSettings(): SubagentsSettings {
|
|
|
692
739
|
* how deep its own sessions nest, but not what the process-wide reserve
|
|
693
740
|
* covers. A missing key means its default, so removing the cap turns it off.
|
|
694
741
|
*/
|
|
695
|
-
export function loadBudgetPolicy(): BudgetPolicy {
|
|
696
|
-
const trusted = trustedSettings();
|
|
742
|
+
export function loadBudgetPolicy(homeDir?: string): BudgetPolicy {
|
|
743
|
+
const trusted = trustedSettings(homeDir);
|
|
697
744
|
return {
|
|
698
745
|
maxActive: trusted.maxActiveSubagents ?? DEFAULT_BUDGET_POLICY.maxActive,
|
|
699
746
|
nestedReserve: trusted.nestedSubagentReserve ?? DEFAULT_BUDGET_POLICY.nestedReserve,
|
|
@@ -725,9 +772,11 @@ export function applySettings(s: SubagentsSettings, appliers: SettingsAppliers):
|
|
|
725
772
|
}
|
|
726
773
|
if (typeof s.defaultMaxTurns === "number") appliers.setDefaultMaxTurns(s.defaultMaxTurns);
|
|
727
774
|
if (typeof s.graceTurns === "number") appliers.setGraceTurns(s.graceTurns);
|
|
775
|
+
if (typeof s.notificationResultChars === "number") appliers.setNotificationResultChars(s.notificationResultChars);
|
|
728
776
|
if (typeof s.maxSubagentDepth === "number") appliers.setMaxSubagentDepth(s.maxSubagentDepth);
|
|
729
777
|
if (typeof s.fallbackSubagent === "string") appliers.setFallbackSubagent(s.fallbackSubagent);
|
|
730
778
|
// Module state: reset when a session omits it, or the previous activation's aliases leak.
|
|
779
|
+
setModelTiers(s.modelTiers, s.modelResolution);
|
|
731
780
|
appliers.setModelAliases(s.modelAliases);
|
|
732
781
|
appliers.setAgentModels(s.agentModels);
|
|
733
782
|
if (s.defaultJoinMode) appliers.setDefaultJoinMode(s.defaultJoinMode);
|
|
@@ -775,10 +824,11 @@ export function applyAndEmitLoaded(
|
|
|
775
824
|
appliers: SettingsAppliers,
|
|
776
825
|
emit: SettingsEmit,
|
|
777
826
|
cwd: string = process.cwd(),
|
|
827
|
+
homeDir?: string,
|
|
778
828
|
): SubagentsSettings {
|
|
779
|
-
const settings = loadSettings(cwd);
|
|
829
|
+
const settings = loadSettings(cwd, homeDir);
|
|
780
830
|
applySettings(settings, appliers);
|
|
781
|
-
appliers.setBudgetPolicy(loadBudgetPolicy());
|
|
831
|
+
appliers.setBudgetPolicy(loadBudgetPolicy(homeDir));
|
|
782
832
|
emit("subagents:settings_loaded", { settings });
|
|
783
833
|
return settings;
|
|
784
834
|
}
|