@nredd/pi-subagents 0.22.0-nredd.6 → 0.22.0-nredd.8

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 (46) hide show
  1. package/README.md +160 -43
  2. package/package.json +8 -8
  3. package/src/agent-file-toggle.ts +6 -4
  4. package/src/agent-manager.ts +418 -118
  5. package/src/agent-runner.ts +155 -62
  6. package/src/cross-extension-rpc.ts +11 -36
  7. package/src/custom-agents.ts +9 -5
  8. package/src/default-agents.ts +2 -4
  9. package/src/enabled-models.ts +8 -7
  10. package/src/enterprise-local.ts +5 -5
  11. package/src/home-paths.ts +27 -0
  12. package/src/index.ts +231 -107
  13. package/src/invocation-config.ts +8 -35
  14. package/src/memory.ts +9 -9
  15. package/src/mention-clone.ts +9 -5
  16. package/src/model-resolver.ts +41 -11
  17. package/src/model-scope.ts +2 -1
  18. package/src/model-selection.ts +159 -0
  19. package/src/model-tiers.ts +171 -0
  20. package/src/nested-tools.ts +31 -26
  21. package/src/output-file.ts +23 -1
  22. package/src/package-resources.ts +25 -10
  23. package/src/process-budget.ts +346 -0
  24. package/src/quota-admission.ts +21 -9
  25. package/src/resource-loader.ts +51 -0
  26. package/src/schedule.ts +41 -19
  27. package/src/settings.ts +111 -8
  28. package/src/skill-loader.ts +8 -7
  29. package/src/stale-ctx.ts +16 -0
  30. package/src/types.ts +33 -0
  31. package/src/ui/agent-row.ts +13 -3
  32. package/src/ui/agent-widget.ts +15 -4
  33. package/src/ui/conversation-viewer.ts +6 -3
  34. package/src/ui/fallback-flag.ts +90 -0
  35. package/src/ui/fleet-list.ts +363 -53
  36. package/src/ui/fleet-state.ts +154 -0
  37. package/src/ui/notification-view.ts +18 -2
  38. package/src/ui/workflow-card.ts +5 -1
  39. package/src/ui/workflow-dialog.ts +10 -0
  40. package/src/workflow/host.ts +43 -45
  41. package/src/workflow/journal.ts +13 -1
  42. package/src/workflow/progress.ts +4 -0
  43. package/src/workflow/runtime.ts +25 -8
  44. package/src/workflow/saved.ts +19 -17
  45. package/src/workflow/task.ts +10 -0
  46. package/src/workflow/worker-source.ts +156 -38
@@ -0,0 +1,51 @@
1
+ /** Normalize explicit settings paths before Pi's real package manager resolves them. */
2
+ import { DefaultResourceLoader, SettingsManager } from "@earendil-works/pi-coding-agent";
3
+ import { normalizeHomePath } from "./home-paths.js";
4
+
5
+ type ResourceLoaderOptions = ConstructorParameters<typeof DefaultResourceLoader>[0];
6
+ type ResourceSettings = ReturnType<SettingsManager["getGlobalSettings"]>;
7
+
8
+ const RESOURCE_FIELDS = ["extensions", "skills", "prompts", "themes"] as const;
9
+
10
+ /** Keep filter modifiers and non-home paths intact; only replace leading home references. */
11
+ function expandSetting(entry: string, homeDir: string, modifiers = true): string {
12
+ const prefix = modifiers && /^[!+-]/.test(entry) ? entry[0] : "";
13
+ const path = entry.slice(prefix.length);
14
+ const trimmed = path.trim();
15
+ if (trimmed !== "~" && !trimmed.startsWith("~/") && !(process.platform === "win32" && trimmed.startsWith("~\\"))) return entry;
16
+ return prefix + normalizeHomePath(trimmed, homeDir);
17
+ }
18
+
19
+ /** Return copies so loading resources never changes persisted settings or their source objects. */
20
+ export function normalizeResourceSettings(settings: ResourceSettings, homeDir: string): ResourceSettings {
21
+ const normalized = { ...settings };
22
+ for (const field of RESOURCE_FIELDS) {
23
+ if (settings[field]) normalized[field] = settings[field].map(entry => expandSetting(entry, homeDir));
24
+ }
25
+ if (settings.packages) {
26
+ normalized.packages = settings.packages.map(pkg => {
27
+ if (typeof pkg === "string") return expandSetting(pkg, homeDir, false);
28
+ const copy = { ...pkg, source: expandSetting(pkg.source, homeDir, false) };
29
+ for (const field of RESOURCE_FIELDS) {
30
+ if (pkg[field]) copy[field] = pkg[field].map(entry => expandSetting(entry, homeDir));
31
+ }
32
+ return copy;
33
+ });
34
+ }
35
+ return normalized;
36
+ }
37
+
38
+ /** Use the real loader/manager while preventing ambient-home expansion of explicit settings. */
39
+ export function createHomeResourceLoader(options: ResourceLoaderOptions, homeDir?: string): DefaultResourceLoader {
40
+ if (homeDir === undefined) return new DefaultResourceLoader(options);
41
+ const settingsManager = options.settingsManager ?? SettingsManager.create(options.cwd, options.agentDir);
42
+ const globalSettings = settingsManager.getGlobalSettings.bind(settingsManager);
43
+ const projectSettings = settingsManager.getProjectSettings.bind(settingsManager);
44
+ // Wrap the public readers, not a cached snapshot: reload and project-trust
45
+ // decisions continue to be owned by Pi, and every read receives fresh copies.
46
+ Object.assign(settingsManager, {
47
+ getGlobalSettings: () => normalizeResourceSettings(globalSettings(), homeDir),
48
+ getProjectSettings: () => normalizeResourceSettings(projectSettings(), homeDir),
49
+ });
50
+ return new DefaultResourceLoader({ ...options, settingsManager });
51
+ }
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";
@@ -54,6 +53,7 @@ export interface NewJobInput {
54
53
 
55
54
  /** Optional in-process scheduling dependencies; omitted clock uses native time. */
56
55
  export interface SubagentSchedulerOptions {
56
+ homeDir?: string;
57
57
  clock?: Clock;
58
58
  }
59
59
 
@@ -66,7 +66,7 @@ export class SubagentScheduler {
66
66
  private ctx: ExtensionContext | undefined;
67
67
  private manager: AgentManager | undefined;
68
68
 
69
- constructor(options: SubagentSchedulerOptions = {}) {
69
+ constructor(private readonly options: SubagentSchedulerOptions = {}) {
70
70
  this.clock = options.clock ?? realClock;
71
71
  }
72
72
 
@@ -247,22 +247,34 @@ export class SubagentScheduler {
247
247
 
248
248
  store.update(id, { lastStatus: "running" });
249
249
 
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;
250
+ // Resolve the original request at fire time, using the same type and model
251
+ // policy as a manual dispatch. Never drop a failed explicit override.
252
+ const dispatch = resolveSpawnType(job.subagent_type);
253
+ const config = dispatch.ok ? getAgentConfig(dispatch.type) : undefined;
254
+ const selection = dispatch.ok ? selectModel({
255
+ homeDir: this.options.homeDir,
256
+ agentType: dispatch.type, definitionModel: config?.model, agentLabel: config?.displayName,
257
+ paramModel: job.model, parentModel: ctx.model, registry: ctx.modelRegistry, cwd: ctx.cwd,
258
+ }) : dispatch.message;
259
+ if (typeof selection === "string") {
260
+ store.update(id, { lastRun: new Date(this.clock.now()).toISOString(), lastStatus: "error" });
261
+ this.emit({ type: "error", jobId: id, error: selection });
262
+ return;
257
263
  }
258
264
 
265
+ if (selection.scopeWarning) ctx.ui.notify(selection.scopeWarning, "warning");
266
+
259
267
  // A quota-parked job re-asks the router before starting: the window it
260
268
  // 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 => {
269
+ const admissionModel = selection.model;
270
+ if ((job.quotaParked || selection.tierBinding) && admissionModel) {
271
+ const admission = selection.tierBinding
272
+ ? admitModelSelection(selection, ctx.modelRegistry, pi.events)
273
+ : checkAdmission(pi.events, modelRef(admissionModel));
274
+ void admission.then(block => {
275
+ if (this.store !== store || this.ctx !== ctx) return;
264
276
  if (!block) {
265
- this.spawnJob(id, resolvedModel);
277
+ this.spawnJob(id, selection);
266
278
  } else if (mayPark(block) && block.resetAt > this.clock.now()) {
267
279
  this.repark(id, block.resetAt);
268
280
  } else {
@@ -272,10 +284,14 @@ export class SubagentScheduler {
272
284
  this.store?.update(id, { lastRun: new Date(this.clock.now()).toISOString(), lastStatus: "error" });
273
285
  this.emit({ type: "error", jobId: id, error: `${describeBlock(block)} The agent was not started.` });
274
286
  }
287
+ }).catch(error => {
288
+ if (this.store !== store) return;
289
+ store.update(id, { lastStatus: "error" });
290
+ this.emit({ type: "error", jobId: id, error: error instanceof Error ? error.message : String(error) });
275
291
  });
276
292
  return;
277
293
  }
278
- this.spawnJob(id, resolvedModel);
294
+ this.spawnJob(id, selection);
279
295
  }
280
296
 
281
297
  /** Re-arm a quota-parked one-shot at a later reset instead of starting it. */
@@ -285,11 +301,12 @@ export class SubagentScheduler {
285
301
  enabled: true,
286
302
  schedule: new Date(resetAt + QUOTA_RESTART_GRACE_MS).toISOString(),
287
303
  lastStatus: undefined,
304
+ quotaParked: true,
288
305
  });
289
306
  }
290
307
 
291
308
  /** Spawn a fired job (bypassing the concurrency queue) and persist its outcome. */
292
- private spawnJob(id: string, resolvedModel: Model<any> | undefined): void {
309
+ private spawnJob(id: string, selection: ModelSelection): void {
293
310
  const store = this.store;
294
311
  const pi = this.pi;
295
312
  const ctx = this.ctx;
@@ -312,7 +329,8 @@ export class SubagentScheduler {
312
329
  description: job.description,
313
330
  isBackground: true,
314
331
  bypassQueue: true,
315
- model: resolvedModel,
332
+ model: selection.model,
333
+ modelSelection: selection,
316
334
  maxTurns: job.max_turns,
317
335
  isolated: job.isolated,
318
336
  thinkingLevel: job.thinking,
@@ -357,7 +375,11 @@ export class SubagentScheduler {
357
375
  // Terminal states: completed/steered = success; error/aborted/stopped = error.
358
376
  // awaitStartup first: with isolation: "worktree" the run promise only exists
359
377
  // once the repo copy is made, and a failed copy rejects here.
360
- manager.awaitStartup(agentId)
378
+ // The start gate comes first: `bypassQueue` skips the pool but not the
379
+ // process budget (`maxActiveSubagents`), so a fired job can sit "queued"
380
+ // with no startup and no promise yet, and would otherwise finalize at once.
381
+ Promise.resolve(manager.getRecord(agentId)?.startGate)
382
+ .then(() => manager.awaitStartup(agentId))
361
383
  .then(() => manager.getRecord(agentId)?.promise)
362
384
  .then(() => {
363
385
  const r = manager.getRecord(agentId);
package/src/settings.ts CHANGED
@@ -4,9 +4,11 @@
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 { resolveAgentDir } from "./home-paths.js";
10
+ import { type ModelResolution, parseTierBinding, setModelTiers } from "./model-tiers.js";
11
+ import { type BudgetPolicy, DEFAULT_BUDGET_POLICY } from "./process-budget.js";
10
12
  import type { AgentMentionMode, JoinMode, ViewerMarkdownMode, WidgetMode } from "./types.js";
11
13
 
12
14
  export interface SubagentsSettings {
@@ -33,6 +35,30 @@ export interface SubagentsSettings {
33
35
  * several blocking resumes in one message can still exceed the limit.
34
36
  */
35
37
  maxConcurrentForeground?: number;
38
+ /**
39
+ * Process-wide ceiling on running subagents, across every manager in this pi
40
+ * process: top-level, nested, workflow, scheduled, RPC and mention spawns,
41
+ * and resumes, in the root session and in sessions a `/new`, `/resume`,
42
+ * `/fork` or `/reload` left running. `0` = off, the default. The process,
43
+ * not the machine: two pi processes each get their own. See
44
+ * `process-budget.ts`.
45
+ *
46
+ * Global configuration only, like `modelAliases`: a project file's value is
47
+ * ignored with a warning. Shared by every activation in the process, so the
48
+ * last one to apply its settings wins. A change that tightens it waits until
49
+ * no subagent in the process holds a slot (`setBudgetPolicy`).
50
+ */
51
+ maxActiveSubagents?: number;
52
+ /**
53
+ * Slots of `maxActiveSubagents` held back per nesting level below a run, so
54
+ * parents that keep their slot while they wait can never starve their own
55
+ * children. Defaults to `2`; with the global `maxSubagentDepth: 2` that
56
+ * leaves `maxActiveSubagents - 2` for top-level runs. With nesting on it
57
+ * must be at least `1`, and `maxActiveSubagents` at least
58
+ * `nestedSubagentReserve * (maxSubagentDepth - 1) + 1`; a budget that
59
+ * breaks either is ignored with a warning, since it could deadlock.
60
+ */
61
+ nestedSubagentReserve?: number;
36
62
  /**
37
63
  * 0 = unlimited — the extension's single source of truth for that convention:
38
64
  * `normalizeMaxTurns()` in agent-runner.ts treats 0 → `undefined`, and the
@@ -243,6 +269,10 @@ export interface SubagentsSettings {
243
269
  fallbackSubagent?: string;
244
270
  /** Trusted-global logical name -> ordered canonical provider/model candidates. */
245
271
  modelAliases?: Record<string, string[]>;
272
+ /** Trusted global/enterprise exact model bindings; never read from a project. */
273
+ modelTiers?: Record<string, string[]>;
274
+ /** Missing/unavailable tier policy. Both default to inherit with disclosure. */
275
+ modelResolution?: ModelResolution;
246
276
  /**
247
277
  * Trusted-global map of agent `type` (or `"*"` for every type) -> model, in
248
278
  * any form `Agent({ model })` accepts. Precedence, highest first: the Agent
@@ -357,6 +387,8 @@ export type ToolDescriptionMode = "full" | "compact" | "custom";
357
387
  export interface SettingsAppliers {
358
388
  setMaxConcurrent: (n: number) => void;
359
389
  setMaxConcurrentForeground: (n: number) => void;
390
+ /** The process budget, from `loadBudgetPolicy`: global configuration only. */
391
+ setBudgetPolicy: (policy: BudgetPolicy) => void;
360
392
  setDefaultMaxTurns: (n: number) => void;
361
393
  setGraceTurns: (n: number) => void;
362
394
  setDefaultJoinMode: (mode: JoinMode) => void;
@@ -442,6 +474,20 @@ function sanitize(raw: unknown): SubagentsSettings {
442
474
  ) {
443
475
  out.maxConcurrentForeground = r.maxConcurrentForeground as number;
444
476
  }
477
+ if (
478
+ Number.isInteger(r.maxActiveSubagents) &&
479
+ (r.maxActiveSubagents as number) >= 0 &&
480
+ (r.maxActiveSubagents as number) <= MAX_CONCURRENT_CEILING
481
+ ) {
482
+ out.maxActiveSubagents = r.maxActiveSubagents as number;
483
+ }
484
+ if (
485
+ Number.isInteger(r.nestedSubagentReserve) &&
486
+ (r.nestedSubagentReserve as number) >= 0 &&
487
+ (r.nestedSubagentReserve as number) <= MAX_CONCURRENT_CEILING
488
+ ) {
489
+ out.nestedSubagentReserve = r.nestedSubagentReserve as number;
490
+ }
445
491
  if (
446
492
  Number.isInteger(r.defaultMaxTurns) &&
447
493
  (r.defaultMaxTurns as number) >= 0 &&
@@ -535,6 +581,24 @@ function sanitize(raw: unknown): SubagentsSettings {
535
581
  } else if (typeof r.fallbackSubagent === "string" && r.fallbackSubagent.trim()) {
536
582
  out.fallbackSubagent = r.fallbackSubagent.trim();
537
583
  }
584
+ if (r.modelTiers !== undefined) {
585
+ out.modelTiers = {};
586
+ if (r.modelTiers && typeof r.modelTiers === "object" && !Array.isArray(r.modelTiers)) {
587
+ for (const [tier, members] of Object.entries(r.modelTiers)) {
588
+ const binding = parseTierBinding(tier, members);
589
+ if (binding) out.modelTiers[tier] = binding.kind === "routed" ? [binding.profile] : binding.members;
590
+ else console.warn(`[pi-subagents] Ignoring invalid modelTiers entry "${tier}".`);
591
+ }
592
+ } else console.warn("[pi-subagents] Ignoring modelTiers: expected an object of tier -> model list.");
593
+ }
594
+ if (r.modelResolution && typeof r.modelResolution === "object" && !Array.isArray(r.modelResolution)) {
595
+ out.modelResolution = {};
596
+ for (const key of ["onUnmapped", "onUnavailable"] as const) {
597
+ const mode = (r.modelResolution as Record<string, unknown>)[key];
598
+ if (mode === "error" || mode === "inherit") out.modelResolution[key] = mode;
599
+ else if (mode !== undefined) console.warn(`[pi-subagents] Ignoring invalid modelResolution.${key}.`);
600
+ }
601
+ }
538
602
  if (r.modelAliases !== undefined) {
539
603
  const aliases = sanitizeModelAliases(r.modelAliases);
540
604
  if (aliases) out.modelAliases = aliases;
@@ -589,8 +653,8 @@ function sanitizeModelAliases(raw: unknown): Record<string, string[]> | undefine
589
653
  return aliases;
590
654
  }
591
655
 
592
- function globalPath(): string {
593
- return join(getAgentDir(), "subagents.json");
656
+ function globalPath(homeDir?: string): string {
657
+ return join(resolveAgentDir(homeDir), "subagents.json");
594
658
  }
595
659
 
596
660
  function projectPath(cwd: string): string {
@@ -614,19 +678,55 @@ function readSettingsFile(path: string): SubagentsSettings {
614
678
  }
615
679
 
616
680
  /** Load merged settings: global provides defaults, project overrides. */
617
- export function loadSettings(cwd: string = process.cwd()): SubagentsSettings {
681
+ export function loadSettings(cwd: string = process.cwd(), homeDir?: string): SubagentsSettings {
618
682
  // Aliases pick the provider a subagent runs on, so they are machine
619
683
  // configuration: a cloned repo's project file must not redirect them.
620
- const { modelAliases: projectModelAliases, agentModels: projectAgentModels, ...project } = readSettingsFile(projectPath(cwd));
684
+ const {
685
+ modelAliases: projectModelAliases,
686
+ modelTiers: projectModelTiers,
687
+ modelResolution: projectModelResolution,
688
+ agentModels: projectAgentModels,
689
+ maxActiveSubagents: projectMaxActive,
690
+ nestedSubagentReserve: projectReserve,
691
+ ...project
692
+ } = readSettingsFile(projectPath(cwd));
693
+ if (projectModelTiers !== undefined || projectModelResolution !== undefined) {
694
+ console.warn("[pi-subagents] Ignoring modelTiers/modelResolution in project .pi/subagents.json: model policy is read from trusted global configuration only.");
695
+ }
621
696
  if (projectModelAliases !== undefined) {
622
697
  console.warn("[pi-subagents] Ignoring modelAliases in project .pi/subagents.json: aliases are read from trusted global configuration only.");
623
698
  }
624
699
  if (projectAgentModels !== undefined) {
625
700
  console.warn("[pi-subagents] Ignoring agentModels in project .pi/subagents.json: per-agent models are read from trusted global configuration only.");
626
701
  }
627
- const enterprise = readEnterpriseLocalManifest()?.subagents;
702
+ // The process budget spans every session in the process, so no one
703
+ // project's file may set it, least of all a cloned one lifting the cap.
704
+ if (projectMaxActive !== undefined || projectReserve !== undefined) {
705
+ console.warn("[pi-subagents] Ignoring maxActiveSubagents/nestedSubagentReserve in project .pi/subagents.json: the process-wide budget is read from global configuration only.");
706
+ }
707
+ return { ...trustedSettings(homeDir), ...project };
708
+ }
709
+
710
+ /** Global settings with the enterprise manifest over them: the layer a project file cannot touch. */
711
+ function trustedSettings(homeDir?: string): SubagentsSettings {
712
+ const enterprise = readEnterpriseLocalManifest(homeDir)?.subagents;
628
713
  const enterpriseSettings = enterprise === undefined ? {} : sanitize(enterprise);
629
- return { ...readSettingsFile(globalPath()), ...enterpriseSettings, ...project };
714
+ return { ...readSettingsFile(globalPath(homeDir)), ...enterpriseSettings };
715
+ }
716
+
717
+ /**
718
+ * The process budget's policy, from trusted configuration only -- including
719
+ * its depth, the GLOBAL `maxSubagentDepth`: a project file may still change
720
+ * how deep its own sessions nest, but not what the process-wide reserve
721
+ * covers. A missing key means its default, so removing the cap turns it off.
722
+ */
723
+ export function loadBudgetPolicy(homeDir?: string): BudgetPolicy {
724
+ const trusted = trustedSettings(homeDir);
725
+ return {
726
+ maxActive: trusted.maxActiveSubagents ?? DEFAULT_BUDGET_POLICY.maxActive,
727
+ nestedReserve: trusted.nestedSubagentReserve ?? DEFAULT_BUDGET_POLICY.nestedReserve,
728
+ maxDepth: trusted.maxSubagentDepth ?? DEFAULT_BUDGET_POLICY.maxDepth,
729
+ };
630
730
  }
631
731
 
632
732
  /**
@@ -656,6 +756,7 @@ export function applySettings(s: SubagentsSettings, appliers: SettingsAppliers):
656
756
  if (typeof s.maxSubagentDepth === "number") appliers.setMaxSubagentDepth(s.maxSubagentDepth);
657
757
  if (typeof s.fallbackSubagent === "string") appliers.setFallbackSubagent(s.fallbackSubagent);
658
758
  // Module state: reset when a session omits it, or the previous activation's aliases leak.
759
+ setModelTiers(s.modelTiers, s.modelResolution);
659
760
  appliers.setModelAliases(s.modelAliases);
660
761
  appliers.setAgentModels(s.agentModels);
661
762
  if (s.defaultJoinMode) appliers.setDefaultJoinMode(s.defaultJoinMode);
@@ -703,9 +804,11 @@ export function applyAndEmitLoaded(
703
804
  appliers: SettingsAppliers,
704
805
  emit: SettingsEmit,
705
806
  cwd: string = process.cwd(),
807
+ homeDir?: string,
706
808
  ): SubagentsSettings {
707
- const settings = loadSettings(cwd);
809
+ const settings = loadSettings(cwd, homeDir);
708
810
  applySettings(settings, appliers);
811
+ appliers.setBudgetPolicy(loadBudgetPolicy(homeDir));
709
812
  emit("subagents:settings_loaded", { settings });
710
813
  return settings;
711
814
  }
@@ -25,7 +25,7 @@ import type { Dirent } from "node:fs";
25
25
  import { existsSync, readdirSync, readFileSync, realpathSync, statSync } from "node:fs";
26
26
  import { homedir } from "node:os";
27
27
  import { dirname, join } from "node:path";
28
- import { getAgentDir } from "@earendil-works/pi-coding-agent";
28
+ import { resolveAgentDir } from "./home-paths.js";
29
29
  import { isSymlink, isUnsafeName, safeReadFile } from "./memory.js";
30
30
 
31
31
  export interface PreloadedSkill {
@@ -33,21 +33,22 @@ export interface PreloadedSkill {
33
33
  content: string;
34
34
  }
35
35
 
36
- export function preloadSkills(skillNames: string[], cwd: string): PreloadedSkill[] {
37
- return skillNames.map((name) => ({ name, content: loadSkillContent(name, cwd) }));
36
+ export function preloadSkills(skillNames: string[], cwd: string, homeDir = homedir()): PreloadedSkill[] {
37
+ return skillNames.map((name) => ({ name, content: loadSkillContent(name, cwd, homeDir) }));
38
38
  }
39
39
 
40
- function loadSkillContent(name: string, cwd: string): string {
40
+ function loadSkillContent(name: string, cwd: string, homeDir: string): string {
41
41
  if (isUnsafeName(name)) {
42
42
  return `(Skill "${name}" skipped: name contains path traversal characters)`;
43
43
  }
44
44
  // `trusted`: user-owned root, symlinks followed. Project roots stay strict.
45
+ const agentDir = resolveAgentDir(homeDir);
45
46
  const roots = [
46
47
  { path: join(cwd, ".pi", "skills"), trusted: false }, // project — Pi standard
47
48
  { path: join(cwd, ".agents", "skills"), trusted: false }, // project — Agent Skills spec
48
- { path: join(getAgentDir(), "skills"), trusted: true }, // user — Pi standard
49
- { path: join(homedir(), ".agents", "skills"), trusted: true }, // user — Agent Skills spec
50
- { path: join(homedir(), ".pi", "skills"), trusted: true }, // legacy global, pre-Pi
49
+ { path: join(agentDir, "skills"), trusted: true }, // user — Pi standard
50
+ { path: join(homeDir, ".agents", "skills"), trusted: true }, // user — Agent Skills spec
51
+ { path: join(homeDir, ".pi", "skills"), trusted: true }, // legacy global, pre-Pi
51
52
  ];
52
53
  for (const root of roots) {
53
54
  const content = findInRoot(root.path, name, root.trusted);
@@ -0,0 +1,16 @@
1
+ /**
2
+ * stale-ctx.ts -- recognising pi's "extension ctx is stale" failure.
3
+ *
4
+ * pi invalidates an extension runner on reload and on session replacement
5
+ * (/new, /fork, /resume). Anything that still reaches the old runner or its
6
+ * `pi` throws this message (#145).
7
+ */
8
+
9
+ /** Matched on the stable prefix of core's message. */
10
+ export const STALE_CTX_RE = /extension ctx is stale after session replacement or reload/i;
11
+
12
+ /** Whether `error` (an `Error` or a message string) is the stale-ctx failure. */
13
+ export function isStaleCtxError(error: unknown): boolean {
14
+ const message = error instanceof Error ? error.message : typeof error === "string" ? error : "";
15
+ return STALE_CTX_RE.test(message);
16
+ }
package/src/types.ts CHANGED
@@ -4,6 +4,10 @@
4
4
 
5
5
  import type { ThinkingLevel } from "@earendil-works/pi-ai";
6
6
  import type { AgentSession } from "@earendil-works/pi-coding-agent";
7
+ import type { ModelFallback } from "./model-resolver.js";
8
+ import type { ModelSelection } from "./model-selection.js";
9
+ import type { TierDisclosure } from "./model-tiers.js";
10
+ import type { BudgetLease } from "./process-budget.js";
7
11
  import type { LifetimeUsage } from "./usage.js";
8
12
 
9
13
  export type { ThinkingLevel };
@@ -146,6 +150,9 @@ export interface AgentTombstone {
146
150
  description: string;
147
151
  /** Always set — a record with no session file is never tombstoned. */
148
152
  sessionFile: string;
153
+ /** The evicted run's tier and fallback, handed back when `@handle` reopens it. */
154
+ modelTier?: TierDisclosure;
155
+ modelFallback?: ModelFallback;
149
156
  completedAt: number;
150
157
  }
151
158
 
@@ -204,6 +211,11 @@ export interface AgentRecord {
204
211
  * thing that moves it, together with the counters.
205
212
  */
206
213
  chargedPool?: Pool;
214
+ /**
215
+ * This run's process-wide budget lease (`process-budget.ts`), held from
216
+ * admission until the run settles. Spawns and resumes alike; cleared on release.
217
+ */
218
+ budgetLease?: BudgetLease;
207
219
  /**
208
220
  * Removes the listener that stops this run when its caller's signal aborts.
209
221
  * At most one is armed at a time — queueing arms one, starting replaces it.
@@ -264,6 +276,24 @@ export interface AgentRecord {
264
276
  isBackground?: boolean;
265
277
  /** Resolved spawn params, captured for UI display. Fixed at spawn time. */
266
278
  invocation?: AgentInvocation;
279
+ /**
280
+ * Set when a configured model did not resolve and the agent runs on the parent
281
+ * model. The single source for every disclosure surface; a resume reuses it
282
+ * as is, never recomputes it from the resuming call's config.
283
+ */
284
+ modelFallback?: ModelFallback;
285
+ /**
286
+ * This record's selection, retained across live resumes and physical relabelling.
287
+ * An evicted-session reopen selects anew; current fallback disclosure always
288
+ * comes from that selection.
289
+ */
290
+ modelSelection?: ModelSelection;
291
+ /** Actual run's tier provenance, preserved through eviction and replay. */
292
+ modelTier?: TierDisclosure;
293
+ /** Prior evicted run's outcome, never the continuation's selection or disclosure. */
294
+ previousModelTier?: TierDisclosure;
295
+ /** Prior evicted run's fallback, retained separately from the continuation's current disclosure. */
296
+ previousModelFallback?: ModelFallback;
267
297
  /** Nesting depth: top-level subagent = 1. */
268
298
  depth?: number;
269
299
  /**
@@ -344,6 +374,9 @@ export interface NotificationDetails {
344
374
  durationMs: number;
345
375
  outputFile?: string;
346
376
  error?: string;
377
+ /** The `Note:` line when the agent ran on the parent model because its configured one did not resolve. */
378
+ modelNote?: string;
379
+ modelTier?: TierDisclosure;
347
380
  resultPreview: string;
348
381
  /** Additional agents in a group notification. */
349
382
  others?: NotificationDetails[];
@@ -14,6 +14,7 @@ import { pathToFileURL } from "node:url";
14
14
  import { type Component, hyperlink, truncateToWidth, wrapTextWithAnsi } from "@earendil-works/pi-tui";
15
15
  import { type Clock, realClock, type TimerHandle } from "../clock.js";
16
16
  import { formatClock } from "../model-call.js";
17
+ import { modelSelectionNote } from "../model-selection.js";
17
18
  import type { AgentRecord } from "../types.js";
18
19
  import { getLifetimeCost, getLifetimeTotal } from "../usage.js";
19
20
  import {
@@ -26,6 +27,7 @@ import {
26
27
  SPINNER,
27
28
  type Theme,
28
29
  } from "./agent-widget.js";
30
+ import { fallbackFlag, withLeadingFlag } from "./fallback-flag.js";
29
31
 
30
32
  /** Lines of the result shown when expanded; the rest is one `get_subagent_result` away. */
31
33
  const EXPANDED_RESULT_LINES = 50;
@@ -89,6 +91,7 @@ export function agentRowSummary(
89
91
  live: AgentRowLive,
90
92
  theme: Theme,
91
93
  now: number = Date.now(),
94
+ width?: number,
92
95
  ): string {
93
96
  const record = details.agentId ? live.getRecord(details.agentId) : undefined;
94
97
  const activity = details.agentId ? live.getActivity(details.agentId) : undefined;
@@ -106,7 +109,10 @@ export function agentRowSummary(
106
109
  if (tokenText) parts.push(tokenText);
107
110
  if (cost) parts.push(cost);
108
111
  if (!finished && details.backgroundHint && elapsed >= 2000) parts.push("(ctrl+b to run in background)");
109
- return `${icon} ${parts.map(p => fgPreservingNestedStyles(theme, "dim", p)).join(` ${theme.fg("dim", "·")} `)}`;
112
+ // Leading, ahead of the status text, which may carry a 300-char error.
113
+ const flag = fallbackFlag(theme, record?.modelFallback || record?.modelTier ? record : details);
114
+ const body = parts.map(p => fgPreservingNestedStyles(theme, "dim", p)).join(` ${theme.fg("dim", "·")} `);
115
+ return width === undefined ? `${icon} ${flag ? `${flag} ` : ""}${body}` : withLeadingFlag(`${icon} `, flag, body, width);
110
116
  }
111
117
 
112
118
  /**
@@ -116,7 +122,9 @@ export function agentRowSummary(
116
122
  */
117
123
  function tagsLine(details: AgentDetails, theme: Theme): string | undefined {
118
124
  const parts = [...(details.modelName ? [details.modelName] : []), ...(details.tags ?? [])];
119
- return parts.length > 0 ? theme.fg("dim", `↳ ${parts.join(" · ")}`) : undefined;
125
+ const flag = fallbackFlag(theme, details);
126
+ if (parts.length === 0 && !flag) return undefined;
127
+ return `${theme.fg("dim", "↳")} ${flag ? `${flag} ` : ""}${theme.fg("dim", parts.join(" · "))}`;
120
128
  }
121
129
 
122
130
  /** The transcript as an OSC 8 hyperlink, so a click opens it. */
@@ -189,12 +197,14 @@ export class AgentResultView implements Component {
189
197
  ) {}
190
198
 
191
199
  render(width: number): string[] {
192
- const lines = [truncateToWidth(agentRowSummary(this.details, this.live, this.theme, this.clock.now()), width)];
200
+ const lines = [agentRowSummary(this.details, this.live, this.theme, this.clock.now(), width)];
193
201
  if (!this.expanded) return lines;
194
202
 
195
203
  const record = this.details.agentId ? this.live.getRecord(this.details.agentId) : undefined;
196
204
  const tags = tagsLine(this.details, this.theme);
197
205
  if (tags) lines.push(truncateToWidth(tags, width));
206
+ const note = modelSelectionNote(record) || this.details.modelNote;
207
+ if (note) lines.push(...wrapTextWithAnsi(this.theme.fg("warning", note), width));
198
208
  const transcript = record?.outputFile ?? this.details.outputFile;
199
209
  if (transcript) lines.push(truncateToWidth(transcriptLink(transcript, this.theme), width));
200
210
 
@@ -11,8 +11,11 @@ import { type AgentManager, isTopLevelAgent } from "../agent-manager.js";
11
11
  import { getConfig } from "../agent-types.js";
12
12
  import { type Clock, realClock, type TimerHandle } from "../clock.js";
13
13
  import { describeModelCall, type ModelCallState } from "../model-call.js";
14
+ import type { ModelFallback } from "../model-resolver.js";
15
+ import type { TierDisclosure } from "../model-tiers.js";
14
16
  import type { AgentInvocation, SubagentType, WidgetMode } from "../types.js";
15
17
  import { getLifetimeCost, getLifetimeTotal, getSessionContextPercent, type LifetimeUsage, type SessionLike } from "../usage.js";
18
+ import { fallbackFlag } from "./fallback-flag.js";
16
19
 
17
20
  // ---- Constants ----
18
21
 
@@ -83,6 +86,11 @@ export interface AgentDetails {
83
86
  backgroundHint?: boolean;
84
87
  /** Short label for the model the run used, e.g. "haiku 4.5". */
85
88
  modelName?: string;
89
+ /** Set when a configured model did not resolve and the run fell back to the parent model. */
90
+ modelNote?: string;
91
+ /** The structured form of {@link modelNote}, for the one-line label. */
92
+ modelFallback?: ModelFallback;
93
+ modelTier?: TierDisclosure;
86
94
  /** Notable config tags (e.g. ["thinking: high", "isolated"]). */
87
95
  tags?: string[];
88
96
  /** Current turn count. */
@@ -389,7 +397,7 @@ export class AgentWidget {
389
397
  }
390
398
 
391
399
  /** Render a finished agent line. */
392
- private renderFinishedLine(a: { id: string; type: SubagentType; status: string; description: string; toolUses: number; startedAt: number; completedAt?: number; error?: string; lifetimeUsage?: LifetimeUsage }, theme: Theme): string {
400
+ private renderFinishedLine(a: { id: string; type: SubagentType; status: string; description: string; toolUses: number; startedAt: number; completedAt?: number; error?: string; lifetimeUsage?: LifetimeUsage; modelFallback?: ModelFallback; modelTier?: TierDisclosure }, theme: Theme): string {
393
401
  const modeLabel = getPromptModeLabel(a.type);
394
402
  const duration = formatMs((a.completedAt ?? this.clock.now()) - a.startedAt);
395
403
 
@@ -418,6 +426,7 @@ export class AgentWidget {
418
426
  const activity = this.agentActivity.get(a.id);
419
427
  if (activity) parts.push(formatTurns(activity.turnCount, activity.maxTurns));
420
428
  if (a.toolUses > 0) parts.push(`${a.toolUses} tool use${a.toolUses === 1 ? "" : "s"}`);
429
+ // Not gated by `showModel`: a substituted model is never a cosmetic detail.
421
430
  // From the record, not the activity tracker: that entry is deleted the
422
431
  // moment an agent finishes, and "what did it cost" is a question asked
423
432
  // about finished agents.
@@ -426,7 +435,8 @@ export class AgentWidget {
426
435
  parts.push(duration);
427
436
 
428
437
  const modeTag = modeLabel ? ` ${theme.fg("dim", `(${modeLabel})`)}` : "";
429
- return `${icon} ${renderAgentName(a.type, theme, { fallbackColor: "dim" })}${modeTag} ${theme.fg("dim", a.description)} ${theme.fg("dim", "·")} ${theme.fg("dim", parts.join(" · "))}${statusText}`;
438
+ const flag = fallbackFlag(theme, a) ? `${fallbackFlag(theme, a)} ` : "";
439
+ return `${icon} ${flag}${renderAgentName(a.type, theme, { fallbackColor: "dim" })}${modeTag} ${theme.fg("dim", a.description)} ${theme.fg("dim", "·")} ${theme.fg("dim", parts.join(" · "))}${statusText}`;
430
440
  }
431
441
 
432
442
  /**
@@ -466,6 +476,7 @@ export class AgentWidget {
466
476
  for (const a of running) {
467
477
  const modeLabel = getPromptModeLabel(a.type);
468
478
  const modeTag = modeLabel ? ` ${theme.fg("dim", `(${modeLabel})`)}` : "";
479
+ const flag = fallbackFlag(theme, a) ? `${fallbackFlag(theme, a)} ` : "";
469
480
  const elapsed = formatMs(this.clock.now() - a.startedAt);
470
481
 
471
482
  const bg = this.agentActivity.get(a.id);
@@ -489,7 +500,7 @@ export class AgentWidget {
489
500
  const thinkingTag = tags.find(tag => tag.startsWith("thinking: "));
490
501
  if (thinkingTag) parts.push(thinkingTag);
491
502
  }
492
- if (bg) parts.push(formatTurns(bg.turnCount, bg.maxTurns));
503
+ if (bg) parts.push(formatTurns(bg.turnCount, bg.maxTurns));
493
504
  if (toolUses > 0) parts.push(`${toolUses} tool use${toolUses === 1 ? "" : "s"}`);
494
505
  if (tokenText) parts.push(tokenText);
495
506
  if (costText) parts.push(costText);
@@ -499,7 +510,7 @@ export class AgentWidget {
499
510
  const activity = bg ? describeActivity(bg.activeTools, bg.responseText, bg.modelCall, this.clock.now()) : "thinking…";
500
511
 
501
512
  runningLines.push([
502
- truncate(theme.fg("dim", "├─") + ` ${theme.fg("accent", frame)} ${renderAgentName(a.type, theme, { bold: true })}${modeTag} ${theme.fg("muted", a.description)} ${theme.fg("dim", "·")} ${fgPreservingNestedStyles(theme, "dim", statsText)}`),
513
+ truncate(theme.fg("dim", "├─") + ` ${theme.fg("accent", frame)} ${flag}${renderAgentName(a.type, theme, { bold: true })}${modeTag} ${theme.fg("muted", a.description)} ${theme.fg("dim", "·")} ${fgPreservingNestedStyles(theme, "dim", statsText)}`),
503
514
  truncate(theme.fg("dim", "│ ") + theme.fg("dim", ` ⎿ ${activity}`)),
504
515
  ]);
505
516
  }