@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
@@ -1,8 +1,7 @@
1
1
  import { homedir } from "node:os";
2
2
  import { resolve } from "node:path";
3
3
  import { Type } from "typebox";
4
- import { configuredAgentModel } from "./model-resolver.js";
5
- import type { AgentConfig, IsolationMode, JoinMode, ThinkingLevel } from "./types.js";
4
+ import type { AgentConfig, IsolationMode, ThinkingLevel } from "./types.js";
6
5
 
7
6
  /**
8
7
  * The model-facing `isolation` parameter, shared by the `Agent` tool and the
@@ -79,13 +78,11 @@ export const cwdParam = {
79
78
  * Blank means unset: models that fill every optional parameter send `""`.
80
79
  * Existence is checked later by the manager, which re-checks at start time.
81
80
  */
82
- export function resolveCwdParam(cwd: string | undefined, sessionCwd: string): string | undefined {
81
+ export function resolveCwdParam(cwd: string | undefined, sessionCwd: string, homeDir = homedir()): string | undefined {
83
82
  const trimmed = cwd?.trim();
84
83
  if (!trimmed) return undefined;
85
- // Bun 1.3 ignores HOME in os.homedir(); honor the same explicit override as Node.
86
- const home = process.env[process.platform === "win32" ? "USERPROFILE" : "HOME"] || homedir();
87
- if (trimmed === "~") return home;
88
- if (trimmed.startsWith("~/")) return resolve(home, trimmed.slice(2));
84
+ if (trimmed === "~") return homeDir;
85
+ if (trimmed.startsWith("~/")) return resolve(homeDir, trimmed.slice(2));
89
86
  return resolve(sessionCwd, trimmed);
90
87
  }
91
88
 
@@ -107,8 +104,6 @@ interface AgentInvocationParams {
107
104
  }
108
105
 
109
106
  interface ResolveOptions {
110
- /** The resolved agent type, for the `agentModels` lookup. Omitted → only the `"*"` entry applies. */
111
- agentType?: string;
112
107
  /**
113
108
  * Whether worktree isolation is permitted at all. False when the project set
114
109
  * `worktreeIsolation: false`, which drops a requested worktree rather than
@@ -135,8 +130,6 @@ export function resolveAgentInvocationConfig(
135
130
  params: AgentInvocationParams,
136
131
  opts?: ResolveOptions,
137
132
  ): {
138
- modelInput?: string;
139
- modelFromParams: boolean;
140
133
  thinking?: ThinkingLevel;
141
134
  maxTurns?: number;
142
135
  inheritContext: boolean;
@@ -151,12 +144,6 @@ export function resolveAgentInvocationConfig(
151
144
  const isolation = requested === "worktree" && opts?.worktreeAllowed !== false ? "worktree" : undefined;
152
145
 
153
146
  return {
154
- // A frontmatter model is a portable default, not a policy boundary. An
155
- // explicit model on the Agent call must win so an orchestrator can select a
156
- // specific model (e.g. a `router/*` virtual model) for this dispatch. Below
157
- // it, the global `agentModels` setting outranks the definition's own default.
158
- modelInput: params.model ?? configuredAgentModel(opts?.agentType ?? agentConfig?.name ?? "", agentConfig?.model),
159
- modelFromParams: params.model != null,
160
147
  // A specialist's frontmatter is a reusable default, not a policy boundary:
161
148
  // an explicit dispatch chooses the effort for this run. Pi still clamps the
162
149
  // selected level to the resolved model, and agent-manager records that
@@ -169,7 +156,3 @@ export function resolveAgentInvocationConfig(
169
156
  isolation,
170
157
  };
171
158
  }
172
-
173
- export function resolveJoinMode(defaultJoinMode: JoinMode, runInBackground: boolean): JoinMode | undefined {
174
- return runInBackground ? defaultJoinMode : undefined;
175
- }
package/src/memory.ts CHANGED
@@ -13,8 +13,8 @@
13
13
 
14
14
  import { existsSync, lstatSync, mkdirSync, readFileSync } from "node:fs";
15
15
  import { homedir } from "node:os";
16
- import { join, } from "node:path";
17
- import { getAgentDir } from "@earendil-works/pi-coding-agent";
16
+ import { join } from "node:path";
17
+ import { resolveAgentDir } from "./home-paths.js";
18
18
  import type { MemoryScope } from "./types.js";
19
19
 
20
20
  /** Maximum lines to read from MEMORY.md */
@@ -58,17 +58,17 @@ export function safeReadFile(filePath: string): string | undefined {
58
58
  * Resolve the memory directory path for a given agent + scope + cwd.
59
59
  * Throws if agentName contains path traversal characters.
60
60
  */
61
- export function resolveMemoryDir(agentName: string, scope: MemoryScope, cwd: string): string {
61
+ export function resolveMemoryDir(agentName: string, scope: MemoryScope, cwd: string, homeDir = homedir()): string {
62
62
  if (isUnsafeName(agentName)) {
63
63
  throw new Error(`Unsafe agent name for memory directory: "${agentName}"`);
64
64
  }
65
65
  switch (scope) {
66
66
  case "user": {
67
- const current = join(getAgentDir(), "agent-memory", agentName);
67
+ const current = join(resolveAgentDir(homeDir), "agent-memory", agentName);
68
68
  // Legacy location from when this path was hardcoded. Keep using it if it
69
69
  // already holds this agent's memory and the new location hasn't been
70
70
  // created yet — otherwise existing memories would be silently orphaned.
71
- const legacy = join(homedir(), ".pi", "agent-memory", agentName);
71
+ const legacy = join(homeDir, ".pi", "agent-memory", agentName);
72
72
  if (!existsSync(current) && existsSync(legacy) && !isSymlink(legacy)) {
73
73
  return legacy;
74
74
  }
@@ -120,8 +120,8 @@ export function readMemoryIndex(memoryDir: string): string | undefined {
120
120
  * Build the memory block to inject into the agent's system prompt.
121
121
  * Also ensures the memory directory exists (creates it if needed).
122
122
  */
123
- export function buildMemoryBlock(agentName: string, scope: MemoryScope, cwd: string): string {
124
- const memoryDir = resolveMemoryDir(agentName, scope, cwd);
123
+ export function buildMemoryBlock(agentName: string, scope: MemoryScope, cwd: string, homeDir = homedir()): string {
124
+ const memoryDir = resolveMemoryDir(agentName, scope, cwd, homeDir);
125
125
  // Create the memory directory so the agent can immediately write to it
126
126
  ensureMemoryDir(memoryDir);
127
127
 
@@ -162,8 +162,8 @@ This memory persists across sessions. Use it to build up knowledge over time.`;
162
162
  * Build a read-only memory block for agents that lack write/edit tools.
163
163
  * Does NOT create the memory directory — agents can only consume existing memory.
164
164
  */
165
- export function buildReadOnlyMemoryBlock(agentName: string, scope: MemoryScope, cwd: string): string {
166
- const memoryDir = resolveMemoryDir(agentName, scope, cwd);
165
+ export function buildReadOnlyMemoryBlock(agentName: string, scope: MemoryScope, cwd: string, homeDir = homedir()): string {
166
+ const memoryDir = resolveMemoryDir(agentName, scope, cwd, homeDir);
167
167
  const existingMemory = readMemoryIndex(memoryDir);
168
168
 
169
169
  const header = `# Agent Memory (read-only)
@@ -56,18 +56,21 @@
56
56
  import type { Model } from "@earendil-works/pi-ai";
57
57
  import {
58
58
  createAgentSession,
59
- DefaultResourceLoader,
60
59
  type ExtensionContext,
61
60
  type ExtensionToolContext,
62
- getAgentDir,
63
61
  SessionManager,
64
62
  type ToolDefinition,
65
63
  } from "@earendil-works/pi-coding-agent";
66
- import { runInChildSessionContext } from "./child-context.js";
64
+ import { bindChildResourceLoader, runInChildSessionContext } from "./child-context.js";
65
+ import { errorMessage } from "./error-message.js";
66
+ import { resolveAgentDir } from "./home-paths.js";
67
67
  import { agentMentionReminder } from "./mention.js";
68
+ import { createHomeResourceLoader } from "./resource-loader.js";
68
69
  import type { SubagentType } from "./types.js";
69
70
 
70
71
  export interface MentionCloneOptions {
72
+ /** Home directory for the clone's user resources and settings. */
73
+ homeDir?: string;
71
74
  /** The MAIN session's context — what the spawn is attributed to, and the
72
75
  * source of both the conversation and the live system prompt. */
73
76
  ctx: ExtensionContext;
@@ -140,9 +143,10 @@ export async function runMentionClone(opts: MentionCloneOptions): Promise<Mentio
140
143
  // would add on top (AGENTS.md, skills, APPEND_SYSTEM.md) is already in the
141
144
  // rendered prompt, so it is suppressed, as agent-runner does.
142
145
  const systemPrompt = ctx.getSystemPrompt();
143
- const loader = new DefaultResourceLoader({
146
+ const agentDir = resolveAgentDir(opts.homeDir);
147
+ const loader = createHomeResourceLoader({
144
148
  cwd: ctx.cwd,
145
- agentDir: getAgentDir(),
149
+ agentDir,
146
150
  noExtensions: true,
147
151
  noSkills: true,
148
152
  noPromptTemplates: true,
@@ -150,11 +154,12 @@ export async function runMentionClone(opts: MentionCloneOptions): Promise<Mentio
150
154
  noContextFiles: true,
151
155
  systemPromptOverride: (base: string | undefined) => systemPrompt || base,
152
156
  appendSystemPromptOverride: () => [],
153
- });
154
- await runInChildSessionContext(() => loader.reload());
157
+ }, opts.homeDir);
158
+ await bindChildResourceLoader(loader).reload();
155
159
  const created = await runInChildSessionContext(() =>
156
160
  createAgentSession({
157
161
  cwd: ctx.cwd,
162
+ agentDir,
158
163
  // The parent's root-to-leaf path, which the clone projects itself:
159
164
  // compaction applied, branch summaries substituted. Not `getEntries()`,
160
165
  // whose last entry is not the leaf after `/tree` navigation. In memory,
@@ -184,7 +189,7 @@ export async function runMentionClone(opts: MentionCloneOptions): Promise<Mentio
184
189
  // renderer produces, where the reminder trails the message it is about.
185
190
  await session.prompt(`${message}\n\n${agentMentionReminder(type)}`);
186
191
  } catch (err) {
187
- return { spawned, error: err instanceof Error ? err.message : String(err) };
192
+ return { spawned, error: errorMessage(err) };
188
193
  } finally {
189
194
  session?.dispose?.();
190
195
  }
@@ -1,6 +1,7 @@
1
1
  /**
2
- * Model resolution: exact match ("provider/modelId") with fuzzy fallback.
2
+ * Model resolution: exact match ("provider/modelId") with fuzzy fallback for tool inputs.
3
3
  */
4
+ import { exactTierModel } from "./model-tiers.js";
4
5
 
5
6
  export interface ModelEntry {
6
7
  id: string;
@@ -40,22 +41,21 @@ export function setAgentModels(models: Record<string, string> | undefined): void
40
41
  agentModels = new Map(Object.entries(models ?? {}).map(([type, model]) => [type.toLowerCase(), model]));
41
42
  }
42
43
 
43
- /**
44
- * The model input a spawn of `type` should use when the caller named none:
45
- * `agentModels[type]`, then `agentModels["*"]`, then the agent definition's own
46
- * `model:` (`definitionModel`). The parent model is the floor, applied by the
47
- * caller when this returns `undefined`.
48
- */
49
- export function configuredAgentModel(type: string, definitionModel: string | undefined): string | undefined {
50
- return agentModels.get(type.toLowerCase()) ?? agentModels.get("*") ?? definitionModel;
44
+ /** A snapshot for the shared selection policy; callers cannot mutate configuration. */
45
+ export function getAgentModels(): ReadonlyMap<string, string> {
46
+ return new Map(agentModels);
51
47
  }
52
48
 
53
49
  /**
54
50
  * Narrows fuzzy candidates to the models the user enabled (pi's `enabledModels`),
55
51
  * as lowercase `provider/id` keys. Injected rather than imported so this module
56
- * stays free of settings I/O; `undefined` means no scope is configured.
52
+ * stays free of settings I/O; `undefined` means no scope is configured. An
53
+ * unresolved dispatch passes its config root only for its diagnostic.
57
54
  */
58
- export type ModelScopeProvider = (registry: ModelRegistry) => ReadonlySet<string> | undefined;
55
+ export type ModelScopeProvider = (
56
+ registry: ModelRegistry,
57
+ context?: { cwd: string; homeDir?: string },
58
+ ) => ReadonlySet<string> | undefined;
59
59
 
60
60
  let modelScopeProvider: ModelScopeProvider | undefined;
61
61
 
@@ -87,6 +87,40 @@ export function describeModel(
87
87
  };
88
88
  }
89
89
 
90
+ /**
91
+ * A CONFIG model (`agentModels`, an agent's `model:`, a stored job's model) that
92
+ * did not resolve, so the spawn ran on the parent model instead. Recorded once on
93
+ * the `AgentRecord` by the shared spawn path; every surface reads it from there.
94
+ */
95
+ export interface ModelFallback {
96
+ /** The configured input that failed to resolve. */
97
+ input: string;
98
+ /** Further configured inputs that also failed (e.g. a stored job model AND an `agentModels` override). */
99
+ also?: string[];
100
+ /** `provider/id` of the model actually used; absent when the session default (a parent with no model) ran. */
101
+ used?: string;
102
+ /** Whose model `used` is: the parent's (default) or the agent's own configured model (`agentModels` / definition `model:`), which took over from an unresolved stored model. */
103
+ via?: "parent" | "agent";
104
+ }
105
+
106
+ /** The fallback outcome for `input` when the spawn ran on `parent`. */
107
+ export function modelFallbackTo(input: string, parent: { provider: string; id: string } | undefined): ModelFallback {
108
+ return parent ? { input, used: `${parent.provider}/${parent.id}` } : { input };
109
+ }
110
+
111
+ /**
112
+ * The disclosure line for a fallback: same `Note:` prefix as the unknown-agent-type
113
+ * note. Empty string when there was none, so callers can concatenate it blindly.
114
+ */
115
+ export function modelFallbackNote(fallback: ModelFallback | undefined): string {
116
+ if (!fallback) return "";
117
+ const whose = fallback.via === "agent" ? "the agent's own model" : "parent model";
118
+ const used = fallback.used ? `${whose} ${fallback.used}` : "the session default model";
119
+ const inputs = [fallback.input, ...(fallback.also ?? [])];
120
+ const named = inputs.length === 1 ? `model "${inputs[0]}" did` : `models ${inputs.map(i => `"${i}"`).join(", ")} did`;
121
+ return `Note: configured ${named} not resolve; running on ${used}.`;
122
+ }
123
+
90
124
  /** API id pi gives virtual catalog entries such as `router/default`; they never reach a provider. */
91
125
  const VIRTUAL_MODEL_API = "pi-virtual";
92
126
 
@@ -156,16 +190,17 @@ function compareVersionsDesc(a: ModelVersion | undefined, b: ModelVersion | unde
156
190
  export function resolveModel(
157
191
  input: string,
158
192
  registry: ModelRegistry,
193
+ context?: { cwd: string; homeDir?: string },
159
194
  ): any | string {
160
195
  const aliasCandidates = modelAliases.get(input.trim().toLowerCase());
161
196
  if (aliasCandidates) {
162
197
  for (const candidate of aliasCandidates) {
163
- const resolved = resolveCanonicalModel(candidate, registry);
164
- if (typeof resolved !== "string") return resolved;
198
+ const resolved = exactTierModel(candidate, registry);
199
+ if (resolved) return resolved;
165
200
  }
166
201
  return `Model alias "${input}" has no available candidate.\n\nCandidates:\n${aliasCandidates.map(candidate => ` ${candidate}`).join("\n")}`;
167
202
  }
168
- return resolveCanonicalModel(input, registry);
203
+ return resolveCanonicalModel(input, registry, context);
169
204
  }
170
205
 
171
206
  /**
@@ -174,20 +209,44 @@ export function resolveModel(
174
209
  * match score. A bare family name like "opus" therefore means the newest opus
175
210
  * the user has enabled, not whichever id happens to be shortest.
176
211
  */
177
- function rankFuzzy(matches: { entry: ModelEntry; score: number }[], registry: ModelRegistry): ModelEntry | undefined {
178
- const scope = modelScopeProvider?.(registry);
179
- const scoped = scope ? matches.filter(c => scope.has(`${c.entry.provider}/${c.entry.id}`.toLowerCase())) : [];
180
- const pool = scoped.length > 0 ? scoped : matches;
181
- const ranked = pool
212
+ function scopedCandidates<T extends { entry: ModelEntry }>(
213
+ candidates: T[],
214
+ registry: ModelRegistry,
215
+ context?: { cwd: string; homeDir?: string },
216
+ ): T[] {
217
+ const scope = modelScopeProvider?.(registry, context);
218
+ const scoped = scope ? candidates.filter(candidate => scope.has(`${candidate.entry.provider}/${candidate.entry.id}`.toLowerCase())) : [];
219
+ return scoped.length > 0 ? scoped : candidates;
220
+ }
221
+
222
+ /** Stable tie-breaker independent of registry insertion order and locale. */
223
+ function compareModelIdentity(a: ModelEntry, b: ModelEntry): number {
224
+ const left = `${a.provider}/${a.id}`;
225
+ const right = `${b.provider}/${b.id}`;
226
+ return Number(left > right) - Number(left < right);
227
+ }
228
+
229
+ function rankFuzzy(
230
+ matches: { entry: ModelEntry; score: number }[],
231
+ registry: ModelRegistry,
232
+ context?: { cwd: string; homeDir?: string },
233
+ ): ModelEntry | undefined {
234
+ const ranked = scopedCandidates(matches, registry, context)
182
235
  .map(c => ({ ...c, version: parseModelVersion(c.entry.id) }))
183
- .sort((a, b) => compareVersionsDesc(a.version, b.version) || b.score - a.score);
236
+ .sort((a, b) => compareVersionsDesc(a.version, b.version) || b.score - a.score || compareModelIdentity(a.entry, b.entry));
184
237
  return ranked[0]?.entry;
185
238
  }
186
239
 
240
+ /** Numeric runs stay ordered and contiguous: `5-5` cannot reuse the `5` in `4-5`. */
241
+ function versionRuns(value: string): string[] {
242
+ return [...value.replace(/-\d{8}$/, "").matchAll(/(?:^|[\s-])(\d+(?:[\s-]+\d+)*)(?=$|[\s-])/g)].map(match => match[1]!.replace(/\s+/g, "-"));
243
+ }
244
+
187
245
  /** Resolve an exact or fuzzy provider/model request without consulting aliases. */
188
246
  function resolveCanonicalModel(
189
247
  input: string,
190
248
  registry: ModelRegistry,
249
+ context?: { cwd: string; homeDir?: string },
191
250
  ): any | string {
192
251
  // Available models (those with auth configured)
193
252
  const all = (registry.getAvailable?.() ?? registry.getAll()) as ModelEntry[];
@@ -209,6 +268,7 @@ function resolveCanonicalModel(
209
268
  // "claude-haiku-4-5" (dot vs dash in the version) resolve to the same model.
210
269
  const normalize = (s: string) => s.toLowerCase().replace(/\./g, "-");
211
270
  const query = normalize(input);
271
+ const requestedVersions = versionRuns(query.slice(slashIdx + 1));
212
272
 
213
273
  // Score each model: prefer exact id match > id contains > name contains > provider+id contains
214
274
  const matches: { entry: ModelEntry; score: number }[] = [];
@@ -217,6 +277,12 @@ function resolveCanonicalModel(
217
277
  const id = normalize(m.id);
218
278
  const name = normalize(m.name);
219
279
  const full = normalize(`${m.provider}/${m.id}`);
280
+ const idVersions = versionRuns(id);
281
+ const versions = idVersions.length > 0 ? idVersions : versionRuns(name);
282
+ // Filter before scope/ranking, including substring and name matches. A
283
+ // major-only input can select a newer minor, but never another major or
284
+ // a minor token masquerading as the requested major.
285
+ if (!requestedVersions.every(requested => versions.some(version => version === requested || version.startsWith(`${requested}-`)))) continue;
220
286
 
221
287
  let score = 0;
222
288
  if (id === query || full === query) {
@@ -241,8 +307,9 @@ function resolveCanonicalModel(
241
307
 
242
308
  // An exact id (or provider/id) is the caller naming a model, not describing
243
309
  // one: it wins outright, whatever the scope or any newer sibling.
244
- const exact = matches.find(c => c.score === 100);
245
- const bestMatch = exact?.entry ?? rankFuzzy(matches, registry);
310
+ const exact = matches.filter(c => c.score === 100);
311
+ const exactBest = exact.sort((a, b) => compareModelIdentity(a.entry, b.entry))[0];
312
+ const bestMatch = exactBest ? exactBest.entry : rankFuzzy(matches, registry, context);
246
313
  if (bestMatch) {
247
314
  const found = registry.find(bestMatch.provider, bestMatch.id);
248
315
  if (found) return found;
@@ -253,13 +320,13 @@ function resolveCanonicalModel(
253
320
  // named provider is preferred when present; this only kicks in when it isn't,
254
321
  // so the same model from another provider beats falling back to "inherit".
255
322
  if (slashIdx !== -1) {
256
- const bare = resolveCanonicalModel(input.slice(slashIdx + 1), registry);
323
+ const bare = resolveCanonicalModel(input.slice(slashIdx + 1), registry, context);
257
324
  if (typeof bare !== "string") return bare;
258
325
  }
259
326
 
260
327
  // 4. No match — list available models
261
- const modelList = all
262
- .map(m => ` ${m.provider}/${m.id}`)
328
+ const modelList = scopedCandidates(all.map(entry => ({ entry })), registry, context)
329
+ .map(({ entry }) => ` ${entry.provider}/${entry.id}`)
263
330
  .sort()
264
331
  .join("\n");
265
332
  return `Model not found: "${input}".\n\nAvailable models:\n${modelList}`;
@@ -39,6 +39,7 @@ export type ModelScopeVerdict =
39
39
  * user authored/installed this agent or chose the parent's model; trust it).
40
40
  */
41
41
  export function checkModelScope(args: {
42
+ homeDir?: string;
42
43
  model: { provider: string; id: string } | undefined;
43
44
  cwd: string;
44
45
  modelRegistry: ModelRegistryRef;
@@ -52,7 +53,7 @@ export function checkModelScope(args: {
52
53
  const { model, cwd, modelRegistry, callerSupplied, agentLabel, modelInput } = args;
53
54
  if (!scopeModelsEnabled || !model) return { kind: "ok" };
54
55
 
55
- const allowed = resolveEnabledModels(readEnabledModels(cwd), modelRegistry, cwd);
56
+ const allowed = resolveEnabledModels(readEnabledModels(cwd, args.homeDir), modelRegistry, cwd, args.homeDir);
56
57
  if (!allowed || isModelInScope(model, allowed)) return { kind: "ok" };
57
58
 
58
59
  if (callerSupplied) {
@@ -0,0 +1,170 @@
1
+ /** One selection policy for every dispatch, independent of its transport. */
2
+ import type { Api, Model } from "@earendil-works/pi-ai";
3
+ import { type Clock, realClock } from "./clock.js";
4
+ import { describeModel, describeVirtualModel, getAgentModels, getModelAliases, isVirtualModel, type ModelFallback, type ModelRegistry, modelFallbackNote, modelFallbackTo, resolveModel } from "./model-resolver.js";
5
+ import { checkModelScope } from "./model-scope.js";
6
+ import { exactTierModel, getModelResolution, parseTierBinding, resolveTier, type TierBinding, type TierDisclosure, type TierOutcome, tierBinding, tierNote } from "./model-tiers.js";
7
+ import { type AdmissionEvents, admissionOutcome, type QuotaBlock } from "./quota-admission.js";
8
+
9
+ /** Exact compatibility pins for user-defined legacy Explore definitions. User aliases win. */
10
+ const LEGACY_MODEL_PINS = new Map([
11
+ ["claude-haiku-4-5", "anthropic/claude-haiku-4-5"],
12
+ ]);
13
+
14
+ /** The winning precedence level, before resolution or fallback. */
15
+ export type ModelSource = "param" | "agentModels:type" | "agentModels:*" | "definition" | "parent";
16
+
17
+ /** Keep the original request even when it could not be resolved. */
18
+ export interface ModelSelection {
19
+ requested?: string;
20
+ source: ModelSource;
21
+ model: Model<Api> | undefined;
22
+ outcome: "resolved" | "inherited" | "fallback";
23
+ fallback?: ModelFallback;
24
+ scopeWarning?: string;
25
+ /** Serializable tier provenance, retained when the answering model changes. */
26
+ tier?: TierOutcome;
27
+ /** A prepared binding is resolved with admission before starting the child. */
28
+ tierBinding?: TierBinding;
29
+ tierAdmitted?: boolean;
30
+ /** Startup-only parent floor if credentials/profile disappear between preflight and admission. */
31
+ tierParent?: Model<Api>;
32
+ }
33
+
34
+ /** Choose the input once: explicit parameter > type > wildcard > definition; blank strings are unset. */
35
+ export function modelRequest(type: string, definitionModel?: string, paramModel?: string | Model<Api> | null): {
36
+ requested?: string;
37
+ source: ModelSource;
38
+ } {
39
+ if (paramModel != null && (typeof paramModel !== "string" || paramModel.trim() !== "")) {
40
+ return { requested: typeof paramModel === "string" ? paramModel : `${paramModel.provider}/${paramModel.id}`, source: "param" };
41
+ }
42
+ const overrides = getAgentModels();
43
+ const typed = overrides.get(type.toLowerCase());
44
+ if (typed?.trim()) return { requested: typed, source: "agentModels:type" };
45
+ const wildcard = overrides.get("*");
46
+ if (wildcard?.trim()) return { requested: wildcard, source: "agentModels:*" };
47
+ if (definitionModel?.trim()) return { requested: definitionModel, source: "definition" };
48
+ return { source: "parent" };
49
+ }
50
+
51
+ /**
52
+ * Choose an exact configured input or tier binding before record creation.
53
+ * Only explicit non-tier tool inputs retain fuzzy matching. Tier admission is
54
+ * asynchronous and completes through admitModelSelection before startup; this
55
+ * synchronous preflight preserves spawn's immediate-id and validation contract.
56
+ * An already-open resume keeps the record and does not select again.
57
+ */
58
+ export function selectModel(request: {
59
+ homeDir?: string;
60
+ agentType: string;
61
+ definitionModel?: string;
62
+ paramModel?: string | Model<Api> | null;
63
+ parentModel: Model<Api> | undefined;
64
+ registry: ModelRegistry;
65
+ cwd: string;
66
+ agentLabel?: string;
67
+ }): ModelSelection | string {
68
+ const input = modelRequest(request.agentType, request.definitionModel, request.paramModel);
69
+ let selection: ModelSelection = { ...input, model: request.parentModel, outcome: "inherited" };
70
+ if (input.requested) {
71
+ const requested = input.requested.trim();
72
+ const legacy = getModelAliases().get(requested.toLowerCase());
73
+ const legacyBinding = legacy ? parseTierBinding("legacy", legacy) : undefined;
74
+ // Preserve existing enterprise manifests during the built-in Explore migration.
75
+ // An explicit cheap binding always wins, even when it is unavailable.
76
+ const cheapCompatibility = requested === "@cheap" && request.agentType === "Explore"
77
+ && input.source === "definition"
78
+ ? parseTierBinding("cheap", getModelAliases().get("claude-haiku-4-5")) : undefined;
79
+ const binding = requested.startsWith("@") ? tierBinding(requested.slice(1)) ?? cheapCompatibility
80
+ : legacyBinding ? { ...legacyBinding, tier: requested } : undefined;
81
+ if (requested.startsWith("@") || legacy) {
82
+ const tier = requested.startsWith("@") ? requested.slice(1) : requested;
83
+ const refs = binding?.kind === "routed" ? [binding.profile] : binding?.members ?? [];
84
+ const model = refs.map(ref => exactTierModel(ref, request.registry)).find(Boolean);
85
+ if (!binding || !model) {
86
+ const outcome: TierOutcome = binding ? { kind: "unavailable", tier, tried: refs } : { kind: "unmapped", tier };
87
+ const mode = outcome.kind === "unmapped" ? getModelResolution().onUnmapped : getModelResolution().onUnavailable;
88
+ if (mode === "error") return `Model tier @${tier} is ${outcome.kind}.`;
89
+ selection = { ...selection, tier: outcome, outcome: "fallback", fallback: modelFallbackTo(input.requested, request.parentModel) };
90
+ } else {
91
+ selection = { ...input, model, outcome: "resolved", tierBinding: binding, tierParent: request.parentModel };
92
+ }
93
+ } else {
94
+ const compatibilityPin = input.source === "definition" && request.agentType === "Explore"
95
+ ? LEGACY_MODEL_PINS.get(requested) : undefined;
96
+ const resolved = request.paramModel != null && typeof request.paramModel === "object"
97
+ ? request.paramModel : input.source === "param" ? resolveModel(requested, request.registry, { cwd: request.cwd, homeDir: request.homeDir })
98
+ : exactTierModel(compatibilityPin ?? requested, request.registry) ?? `Model not found: "${requested}".`;
99
+ if (typeof resolved !== "string") selection = { ...input, model: resolved, outcome: "resolved" };
100
+ else if (compatibilityPin) {
101
+ const tier: TierOutcome = { kind: "unavailable", tier: requested, tried: [compatibilityPin] };
102
+ if (getModelResolution().onUnavailable === "error") return `Model tier @${requested} is unavailable.`;
103
+ selection = { ...selection, tier, outcome: "fallback", fallback: modelFallbackTo(input.requested, request.parentModel) };
104
+ } else {
105
+ if (input.source === "param" || getModelResolution().onUnavailable === "error") return resolved;
106
+ selection = { ...selection, outcome: "fallback", fallback: modelFallbackTo(input.requested, request.parentModel) };
107
+ }
108
+ }
109
+ }
110
+ const scope = checkModelScope({
111
+ model: selection.model, cwd: request.cwd, modelRegistry: request.registry,
112
+ callerSupplied: input.source === "param" && !selection.tierBinding && !selection.tier, agentLabel: request.agentLabel ?? request.agentType,
113
+ modelInput: input.requested, homeDir: request.homeDir,
114
+ });
115
+ if (scope.kind === "error") return scope.message;
116
+ if (scope.kind === "warn") selection.scopeWarning = scope.message;
117
+ return selection;
118
+ }
119
+
120
+ /** Label an admitted physical model before any response, without replacing the virtual selection. */
121
+ export function describeSelection(selection: ModelSelection | undefined, registry: ModelRegistry): { modelName?: string; modelId?: string } {
122
+ const admitted = selection?.tier?.kind === "routed" ? selection.tier.admitted : undefined;
123
+ if (admitted) {
124
+ const slash = admitted.indexOf("/");
125
+ const provider = admitted.slice(0, slash);
126
+ const id = admitted.slice(slash + 1);
127
+ return describeModel(registry.find(provider, id) ?? { provider, id });
128
+ }
129
+ const model = selection?.model;
130
+ return model ? isVirtualModel(model) ? describeVirtualModel(model) : describeModel(model) : {};
131
+ }
132
+
133
+ /** Resolved selection identity for model-facing text, not an actionable fallback warning. */
134
+ export function modelIdentityNote(carrier: { modelSelection?: ModelSelection; modelTier?: TierDisclosure } | undefined): string {
135
+ const selected = carrier?.modelSelection?.model;
136
+ // Tier notes already carry admission/answer provenance. Literal virtual
137
+ // selections have no such evidence, so never claim their alias is a leaf.
138
+ return selected && !carrier?.modelTier
139
+ ? `Resolved model: ${selected.provider}/${selected.id}${isVirtualModel(selected) ? " (routing alias; physical response model: unknown)" : ""}.`
140
+ : "";
141
+ }
142
+
143
+ /** One disclosure for records, persisted details, workflows and notices. */
144
+ export function modelSelectionNote(carrier: { modelTier?: TierDisclosure; modelFallback?: ModelFallback; invocation?: { modelId?: string } } | undefined): string {
145
+ const tier = carrier?.modelTier;
146
+ const fallback = carrier?.modelFallback;
147
+ const sameRequest = tier && (fallback?.input === tier.tier || fallback?.input === `@${tier.tier}`);
148
+ return [tierNote(tier, carrier?.invocation?.modelId), sameRequest ? "" : modelFallbackNote(fallback)].filter(Boolean).join("\n");
149
+ }
150
+
151
+ /** Admission for tiers is made once and is shared by every transport. */
152
+ export async function admitModelSelection(selection: ModelSelection, registry: ModelRegistry, events: AdmissionEvents, clock: Clock = realClock, signal?: AbortSignal): Promise<QuotaBlock | undefined> {
153
+ if (!selection.tierBinding || selection.tierAdmitted) return selection.tier?.kind === "exhausted" ? selection.tier.block : undefined;
154
+ selection.tier = await resolveTier(selection.tierBinding.tier, {
155
+ registry, binding: selection.tierBinding, admit: ref => admissionOutcome(events, ref, undefined, clock, signal),
156
+ });
157
+ selection.tierAdmitted = true;
158
+ if (selection.tier.kind === "routed" || selection.tier.kind === "static") selection.model = selection.tier.model;
159
+ if (selection.tier.kind === "exhausted") return selection.tier.block;
160
+ // The binding is handed to `resolveTier`, so it is never `unmapped` here.
161
+ if (selection.tier.kind === "unavailable") {
162
+ if (getModelResolution().onUnavailable === "error") throw new Error(`Model tier @${selection.tier.tier} is unavailable.`);
163
+ selection.model = selection.tierParent;
164
+ selection.outcome = "fallback";
165
+ selection.fallback = modelFallbackTo(selection.requested ?? `@${selection.tier.tier}`, selection.tierParent);
166
+ delete selection.tierBinding;
167
+ }
168
+ delete selection.tierParent;
169
+ return undefined;
170
+ }