@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.
Files changed (61) hide show
  1. package/README.md +245 -77
  2. package/package.json +16 -13
  3. package/src/abortable.ts +1 -1
  4. package/src/agent-file-toggle.ts +6 -5
  5. package/src/agent-manager.ts +489 -129
  6. package/src/agent-runner.ts +244 -93
  7. package/src/child-context.ts +19 -1
  8. package/src/completion-outbox.ts +22 -3
  9. package/src/cross-extension-rpc.ts +21 -42
  10. package/src/custom-agents.ts +11 -6
  11. package/src/default-agents.ts +21 -21
  12. package/src/detached-results.ts +18 -2
  13. package/src/enabled-models.ts +8 -7
  14. package/src/enterprise-local.ts +7 -6
  15. package/src/error-message.ts +11 -0
  16. package/src/git-path.ts +66 -0
  17. package/src/group-join.ts +4 -10
  18. package/src/home-paths.ts +27 -0
  19. package/src/index.ts +898 -297
  20. package/src/invocation-config.ts +4 -21
  21. package/src/memory.ts +9 -9
  22. package/src/mention-clone.ts +13 -8
  23. package/src/model-resolver.ts +92 -25
  24. package/src/model-scope.ts +2 -1
  25. package/src/model-selection.ts +170 -0
  26. package/src/model-tiers.ts +176 -0
  27. package/src/nested-tools.ts +99 -44
  28. package/src/notification-result.ts +118 -0
  29. package/src/output-file.ts +23 -1
  30. package/src/package-resources.ts +25 -10
  31. package/src/plan-mode-children.ts +176 -0
  32. package/src/process-budget.ts +6 -3
  33. package/src/prompts.ts +7 -2
  34. package/src/quota-admission.ts +35 -13
  35. package/src/resource-loader.ts +65 -0
  36. package/src/schedule.ts +94 -30
  37. package/src/settings.ts +63 -13
  38. package/src/skill-loader.ts +244 -93
  39. package/src/stale-ctx.ts +16 -0
  40. package/src/status-note.ts +28 -5
  41. package/src/types.ts +79 -2
  42. package/src/ui/agent-mention.ts +5 -2
  43. package/src/ui/agent-row.ts +13 -3
  44. package/src/ui/agent-widget.ts +55 -25
  45. package/src/ui/conversation-viewer.ts +10 -5
  46. package/src/ui/fallback-flag.ts +114 -0
  47. package/src/ui/fleet-list.ts +431 -86
  48. package/src/ui/fleet-state.ts +154 -0
  49. package/src/ui/notification-view.ts +22 -3
  50. package/src/ui/workflow-card.ts +6 -9
  51. package/src/ui/workflow-dialog.ts +36 -13
  52. package/src/workflow/host.ts +47 -50
  53. package/src/workflow/journal.ts +13 -1
  54. package/src/workflow/meta.ts +7 -4
  55. package/src/workflow/progress.ts +8 -3
  56. package/src/workflow/runtime.ts +48 -15
  57. package/src/workflow/saved.ts +21 -21
  58. package/src/workflow/task.ts +22 -7
  59. package/src/workflow/tool-description.ts +2 -2
  60. package/src/workflow/worker-source.ts +162 -40
  61. 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 { resolveModel } from "./model-resolver.js";
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
- if (job.enabled) this.scheduleJob(job);
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.store;
187
- if (!store) return;
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
- if (!store || !pi || !ctx || !manager) return;
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 model at fire time — registry contents may have changed since the
251
- // job was created (auth added/removed). Fall back silently to spawn-default
252
- // if resolution fails; the spawn path handles undefined model gracefully.
253
- let resolvedModel: Model<any> | undefined;
254
- if (job.model) {
255
- const r = resolveModel(job.model, ctx.modelRegistry);
256
- if (typeof r !== "string") resolvedModel = r;
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 = resolvedModel ?? ctx.model;
262
- if (job.quotaParked && admissionModel) {
263
- void checkAdmission(pi.events, modelRef(admissionModel)).then(block => {
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, resolvedModel);
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, resolvedModel);
341
+ this.spawnJob({ store, pi, ctx, manager }, id, selection);
279
342
  }
280
343
 
281
- /** Re-arm a quota-parked one-shot at a later reset instead of starting it. */
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, resolvedModel: Model<any> | undefined): void {
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: resolvedModel,
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
- return { valid: false, error: e instanceof Error ? e.message : "Invalid cron expression" };
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(getAgentDir(), "subagents.json");
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 = err instanceof Error ? err.message : String(err);
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
  }