pi-famulus 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (53) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +36 -0
  3. package/bin/pi-famulus.js +29 -0
  4. package/package.json +49 -0
  5. package/src/agents/builtins.ts +41 -0
  6. package/src/agents/definition.ts +221 -0
  7. package/src/agents/loader.ts +119 -0
  8. package/src/bash-override.ts +372 -0
  9. package/src/behavior-guidelines.ts +42 -0
  10. package/src/clock.ts +169 -0
  11. package/src/comms/comms.ts +199 -0
  12. package/src/comms/mailbox.ts +156 -0
  13. package/src/comms/registry-host.ts +73 -0
  14. package/src/comms/reply-command.ts +52 -0
  15. package/src/comms/routing.ts +23 -0
  16. package/src/comms/tools.ts +339 -0
  17. package/src/comms/types.ts +68 -0
  18. package/src/config.ts +266 -0
  19. package/src/events.ts +61 -0
  20. package/src/exit-notify-gate.ts +69 -0
  21. package/src/exit-watchdog.ts +60 -0
  22. package/src/file-tail.ts +48 -0
  23. package/src/format.ts +276 -0
  24. package/src/index.ts +700 -0
  25. package/src/manager-client.ts +839 -0
  26. package/src/monitor-batching.ts +191 -0
  27. package/src/monitor.ts +517 -0
  28. package/src/native-manager.d.ts +9 -0
  29. package/src/native-manager.js +39 -0
  30. package/src/notify.ts +231 -0
  31. package/src/shell-exec.ts +145 -0
  32. package/src/subagent/agent-records.ts +168 -0
  33. package/src/subagent/child-bash.ts +183 -0
  34. package/src/subagent/conversation.ts +76 -0
  35. package/src/subagent/fleet-widget.ts +176 -0
  36. package/src/subagent/model-spec.ts +108 -0
  37. package/src/subagent/pi-runtime.ts +295 -0
  38. package/src/subagent/pool.ts +190 -0
  39. package/src/subagent/registry.ts +575 -0
  40. package/src/subagent/runner.ts +709 -0
  41. package/src/subagent/tool.ts +741 -0
  42. package/src/subagent/transcript.ts +71 -0
  43. package/src/subagent/types.ts +125 -0
  44. package/src/task-exit-notify.ts +21 -0
  45. package/src/task-tools.ts +330 -0
  46. package/src/tui/message-renderers.ts +183 -0
  47. package/src/tui/pi-tui-load.ts +186 -0
  48. package/src/tui/scroll-detail-view.ts +190 -0
  49. package/src/tui/task-output-paths.ts +46 -0
  50. package/src/tui/tasks-command.ts +515 -0
  51. package/src/tui/tool-component.ts +29 -0
  52. package/src/wake.ts +219 -0
  53. package/src/work-index.ts +182 -0
@@ -0,0 +1,183 @@
1
+ /**
2
+ * Child-session bash variant (design doc §4.2 tail / §4.6).
3
+ *
4
+ * Registered as a custom tool named "bash" inside subagent sessions (it
5
+ * overrides the built-in there). Differences from the main bash override:
6
+ * - no run_in_background: subagents must not background work — the schema
7
+ * omits the flag entirely (lain waitUntilExit lesson);
8
+ * - execute waits for the whole timeout via manager start + wait; on expiry
9
+ * the process is killed (SIGTERM->SIGKILL by the manager) and a timeout
10
+ * error is returned instead of backgrounding;
11
+ * - bare sleep / idle-loop commands are rejected (same patterns as the main
12
+ * bash override; the detection logic is copied, not imported, per the M3
13
+ * file-ownership rules);
14
+ * - no local fallback: when the manager is unavailable the tool fails with a
15
+ * clear error (subagent sessions are spawned by the extension where the
16
+ * manager is expected to be up).
17
+ */
18
+ import { Type } from "typebox";
19
+ import type {
20
+ AgentToolResult,
21
+ BashToolDetails,
22
+ ExtensionContext,
23
+ ToolDefinition,
24
+ } from "@earendil-works/pi-coding-agent";
25
+ import { taskOutputPath } from "../config";
26
+ import { realClock, type Clock } from "../clock";
27
+ import type { ManagerClient } from "../manager-client";
28
+ import {
29
+ appendStatus,
30
+ bareSleepError,
31
+ collectOutput,
32
+ formatFinishedOutput,
33
+ killedStatus,
34
+ timedOutStatus,
35
+ SHELL_MAX_BYTES,
36
+ SHELL_MAX_LINES,
37
+ withAbort,
38
+ } from "../shell-exec";
39
+
40
+ const MAX_LINES = SHELL_MAX_LINES;
41
+ const MAX_BYTES = SHELL_MAX_BYTES;
42
+ /** Wait granularity when no explicit timeout is given (abort responsiveness). */
43
+ const WAIT_SLICE_MS = 60_000;
44
+
45
+ const childBashParameters = Type.Object({
46
+ command: Type.String({ description: "The bash command to execute" }),
47
+ timeout: Type.Optional(
48
+ Type.Number({ description: "Hard kill timeout in seconds (optional, no default timeout)" }),
49
+ ),
50
+ });
51
+
52
+ type ChildBashParams = { command: string; timeout?: number };
53
+
54
+ export interface FamulusChildBashDetails extends BashToolDetails {
55
+ task_id?: string;
56
+ }
57
+
58
+ export interface ChildBashDeps {
59
+ getClient: () => ManagerClient | null;
60
+ home: string;
61
+ /** Parent session id — the manager namespace this connection owns. */
62
+ sessionId: () => string;
63
+ /** Precomputed PI_* env injection (from the parent session). */
64
+ sessionEnv: () => Record<string, string>;
65
+ trackTask: (taskId: string, meta: { kind: string; command: string; cwd?: string }) => void;
66
+ childId?: string;
67
+ runId?: string;
68
+ clock?: Clock;
69
+ }
70
+
71
+ const CHILD_SLEEP_GUIDANCE =
72
+ "Sleeping to wait for work is never useful: run the actual command directly, or report back to the supervisor if you are blocked.";
73
+
74
+ function rejectBareSleep(command: string): string | null {
75
+ return bareSleepError(command, CHILD_SLEEP_GUIDANCE);
76
+ }
77
+
78
+ function resolveTimeoutMs(timeoutSeconds: number | undefined): number | null {
79
+ if (timeoutSeconds === undefined) return null;
80
+ if (!Number.isFinite(timeoutSeconds) || timeoutSeconds <= 0) {
81
+ throw new Error("Invalid timeout: must be a finite number of seconds");
82
+ }
83
+ return Math.round(timeoutSeconds * 1000);
84
+ }
85
+
86
+ function fullEnv(deps: ChildBashDeps): Record<string, string> {
87
+ const env: Record<string, string> = {};
88
+ for (const [key, value] of Object.entries(process.env)) {
89
+ if (value !== undefined) env[key] = value;
90
+ }
91
+ Object.assign(env, deps.sessionEnv());
92
+ return env;
93
+ }
94
+
95
+ export function createChildBashTool(
96
+ deps: ChildBashDeps,
97
+ ): ToolDefinition<typeof childBashParameters, FamulusChildBashDetails | undefined> {
98
+ return {
99
+ name: "bash",
100
+ label: "Bash",
101
+ description:
102
+ "Execute a bash command in the current working directory. Returns stdout and stderr. " +
103
+ `Output is truncated to last ${MAX_LINES} lines or ${MAX_BYTES / 1024}KB (whichever is hit first). ` +
104
+ "The command runs to completion (or the optional timeout in seconds, after which it is killed). " +
105
+ "There is no background execution inside subagents.",
106
+ promptSnippet: "Execute bash commands (ls, grep, find, etc.)",
107
+ parameters: childBashParameters,
108
+ async execute(_toolCallId, params, signal, _onUpdate, ctx: ExtensionContext) {
109
+ const input = params as ChildBashParams;
110
+
111
+ const sleepError = rejectBareSleep(input.command);
112
+ if (sleepError) throw new Error(sleepError);
113
+
114
+ const client = deps.getClient();
115
+ if (!client || !(await client.ensureAvailable())) {
116
+ throw new Error("pi-famulus is not available; bash is disabled inside this subagent");
117
+ }
118
+
119
+ const timeoutMs = resolveTimeoutMs(input.timeout);
120
+ const start = await client.start({
121
+ kind: "shell",
122
+ command: input.command,
123
+ cwd: ctx.cwd,
124
+ env: fullEnv(deps),
125
+ run_in_background: false,
126
+ timeout_ms: timeoutMs,
127
+ ...(deps.childId && deps.runId
128
+ ? { origin: { via: "child-bash" as const, child_id: deps.childId, run_id: deps.runId } }
129
+ : {}),
130
+ });
131
+ deps.trackTask(start.task_id, { kind: "shell", command: input.command, cwd: ctx.cwd });
132
+ const outputPath = taskOutputPath(deps.home, deps.sessionId(), start.task_id);
133
+
134
+ const clock = deps.clock ?? realClock;
135
+ const deadline = timeoutMs !== null ? clock.now() + timeoutMs : null;
136
+ let waitResult: { done: boolean; exit_code?: number | null } | null = null;
137
+ for (;;) {
138
+ const budget =
139
+ deadline === null
140
+ ? WAIT_SLICE_MS
141
+ : Math.min(WAIT_SLICE_MS, Math.max(1, deadline - clock.now()));
142
+ try {
143
+ waitResult = await withAbort(client.wait(start.task_id, budget), signal, () => {
144
+ client.stop(start.task_id, "tool").catch(() => {});
145
+ });
146
+ } catch (err) {
147
+ if ((err as Error).message === "aborted") {
148
+ throw new Error("Command aborted (task stopped)");
149
+ }
150
+ throw new Error(
151
+ `Lost contact with pi-famulus while waiting for task ${start.task_id}: ` +
152
+ `${(err as Error).message}. Output so far: ${outputPath}.`,
153
+ );
154
+ }
155
+ if (waitResult.done) break;
156
+ if (deadline !== null && clock.now() >= deadline) {
157
+ await client.stop(start.task_id, "timeout").catch(() => {});
158
+ const collected = await collectOutput(client, start.task_id).catch(() => null);
159
+ const text = collected ? formatFinishedOutput(collected, outputPath).text : "";
160
+ throw new Error(
161
+ appendStatus(text, timedOutStatus(input.timeout!)),
162
+ );
163
+ }
164
+ }
165
+
166
+ const collected = await collectOutput(client, start.task_id);
167
+ const { text, details } = formatFinishedOutput(collected, outputPath);
168
+ const exitCode = waitResult.exit_code ?? null;
169
+ if (exitCode !== 0 && exitCode !== null) {
170
+ throw new Error(appendStatus(text, `Command exited with code ${exitCode}`));
171
+ }
172
+ // No exit code: killed (timeout, stop, crash), unless the manager
173
+ // finished it as completed with its runner status unobservable.
174
+ if (exitCode === null && collected.status !== "completed") {
175
+ throw new Error(appendStatus(text, await killedStatus(client, start.task_id, input.timeout)));
176
+ }
177
+ const finalDetails = details
178
+ ? { ...details, task_id: start.task_id }
179
+ : ({ task_id: start.task_id } as FamulusChildBashDetails);
180
+ return { content: [{ type: "text", text }], details: finalDetails };
181
+ },
182
+ };
183
+ }
@@ -0,0 +1,76 @@
1
+ /**
2
+ * Flatten a child AgentSession message list into plain turns.
3
+ * Structural on purpose: this file does not import pi.
4
+ */
5
+ import type { ConversationTurn } from "./types";
6
+
7
+ interface LooseBlock {
8
+ type?: string;
9
+ text?: string;
10
+ name?: string;
11
+ arguments?: unknown;
12
+ }
13
+
14
+ interface LooseMessage {
15
+ role?: string;
16
+ content?: unknown;
17
+ toolName?: string;
18
+ isError?: boolean;
19
+ stopReason?: string;
20
+ errorMessage?: string;
21
+ }
22
+
23
+ function blockText(block: LooseBlock): string {
24
+ if (block.type === "text" || block.type === "thinking") return block.text ?? "";
25
+ if (block.type === "toolCall") {
26
+ const args = block.arguments === undefined ? "" : ` ${JSON.stringify(block.arguments)}`;
27
+ return `tool ${block.name ?? "?"}${args}`;
28
+ }
29
+ return "";
30
+ }
31
+
32
+ function visibleUserText(text: string): string {
33
+ let visible = text.replace(/^You are running as model [^\n]+\.\n\n/, "");
34
+ const separator = visible.indexOf("\n\n---\n\n");
35
+ if (separator >= 0) visible = visible.slice(separator + "\n\n---\n\n".length);
36
+ return visible.trim();
37
+ }
38
+
39
+ function contentText(content: unknown): string {
40
+ if (typeof content === "string") return content;
41
+ if (!Array.isArray(content)) return "";
42
+ return content
43
+ .map((block) => (block && typeof block === "object" ? blockText(block as LooseBlock) : ""))
44
+ .filter((part) => part.length > 0)
45
+ .join("\n");
46
+ }
47
+
48
+ export function turnsFromMessages(messages: readonly LooseMessage[]): ConversationTurn[] {
49
+ const turns: ConversationTurn[] = [];
50
+ let firstUserTurn = true;
51
+ for (const message of messages) {
52
+ if (message.role === "system") continue;
53
+ const rawText = contentText(message.content);
54
+ const text = message.role === "user" && firstUserTurn ? visibleUserText(rawText) : rawText;
55
+ if (message.role === "user") firstUserTurn = false;
56
+ if (message.role === "assistant" && (message.stopReason === "error" || message.stopReason === "aborted")) {
57
+ // A failed model call often has no text; its error is the only record of what happened.
58
+ if (text) turns.push({ role: "assistant", text });
59
+ turns.push({ role: "assistant error", text: message.errorMessage?.trim() || `model stopped: ${message.stopReason}` });
60
+ continue;
61
+ }
62
+ if (!text) continue;
63
+ if (message.role === "toolResult") {
64
+ const name = message.toolName ?? "tool";
65
+ turns.push({ role: message.isError ? `tool ${name} error` : `tool ${name}`, text });
66
+ continue;
67
+ }
68
+ turns.push({ role: message.role ?? "message", text });
69
+ }
70
+ return turns;
71
+ }
72
+
73
+ export function formatConversation(turns: readonly ConversationTurn[]): string {
74
+ if (turns.length === 0) return "(no messages yet)";
75
+ return turns.map((turn) => `── ${turn.role} ──\n${turn.text}`).join("\n\n");
76
+ }
@@ -0,0 +1,176 @@
1
+ /**
2
+ * Passive fleet summary below the editor — counts only, no keyboard hooks.
3
+ *
4
+ * Format: `2 workers · 1 subagent · 1 monitor`
5
+ * workers = backgrounded shell tasks (not sync-waited)
6
+ * subagents = in-process subagent children
7
+ * monitors = active monitors
8
+ *
9
+ * Driven by WorkIndex changes. Does not poll the manager.
10
+ */
11
+ import { realClock, type Clock, type ClockTimer } from "../clock";
12
+ import { formatAge, type WorkIndex, type WorkItem } from "../work-index";
13
+ import { truncateToWidth } from "../tui/pi-tui-load";
14
+
15
+ export const FLEET_WIDGET_KEY = "pi-famulus-fleet";
16
+
17
+ function isActive(item: WorkItem): boolean {
18
+ return item.status === "pending" || item.status === "running";
19
+ }
20
+
21
+ export interface FleetTheme {
22
+ fg(color: string, text: string): string;
23
+ }
24
+
25
+ export interface FleetTui {
26
+ requestRender(): void;
27
+ }
28
+
29
+ export interface FleetWidgetComponent {
30
+ render(width: number): string[];
31
+ invalidate?(): void;
32
+ dispose?(): void;
33
+ }
34
+
35
+ export interface FleetUi {
36
+ setWidget(
37
+ key: string,
38
+ content:
39
+ | string[]
40
+ | undefined
41
+ | ((tui: FleetTui, theme: FleetTheme) => FleetWidgetComponent),
42
+ options?: { placement?: "aboveEditor" | "belowEditor" },
43
+ ): void;
44
+ }
45
+
46
+ export interface FleetStatusDeps {
47
+ index: WorkIndex;
48
+ getUi: () => FleetUi | null;
49
+ clock?: Clock;
50
+ }
51
+
52
+ function countLabel(n: number, singular: string, plural?: string): string {
53
+ return `${n} ${n === 1 ? singular : (plural ?? `${singular}s`)}`;
54
+ }
55
+
56
+ /** Counts part of the fleet line. "shell" = a backgrounded bash command. */
57
+ export function summaryLabel(shells: number, subagents: number, monitors: number): string {
58
+ const parts: string[] = [];
59
+ if (shells > 0) parts.push(countLabel(shells, "shell"));
60
+ if (subagents > 0) parts.push(countLabel(subagents, "subagent"));
61
+ if (monitors > 0) parts.push(countLabel(monitors, "monitor"));
62
+ return parts.join(" · ");
63
+ }
64
+
65
+ /**
66
+ * Passive fleet status line (below editor). No setStatus, no keyboard capture, no poll.
67
+ */
68
+ export class FleetWidget {
69
+ private readonly deps: FleetStatusDeps;
70
+ private started = false;
71
+ private widgetRegistered = false;
72
+ private tui: FleetTui | null = null;
73
+ private unsubscribe: (() => void) | null = null;
74
+ private ageTimer: ClockTimer | null = null;
75
+ private readonly clock: Clock;
76
+
77
+ constructor(deps: FleetStatusDeps) {
78
+ this.deps = deps;
79
+ this.clock = deps.clock ?? realClock;
80
+ }
81
+
82
+ start(): void {
83
+ if (this.started) return;
84
+ this.started = true;
85
+ this.unsubscribe = this.deps.index.onChange(() => this.refresh());
86
+ this.refresh();
87
+ }
88
+
89
+ refresh(): void {
90
+ const ui = this.deps.getUi();
91
+ if (!ui) return;
92
+ const items = this.deps.index.list(this.clock.now());
93
+ const counts = this.deps.index.counts();
94
+ const activeAgents = items.filter((item) => item.kind === "agent" && isActive(item));
95
+ const total = counts.workers + counts.subagents + counts.monitors;
96
+ if (activeAgents.length > 0 && !this.ageTimer) {
97
+ this.ageTimer = this.clock.setInterval(() => this.tui?.requestRender(), 5000);
98
+ this.clock.unref?.(this.ageTimer);
99
+ } else if (activeAgents.length === 0) {
100
+ this.clearAgeTimer();
101
+ }
102
+ if (total === 0) {
103
+ this.clearWidget(ui);
104
+ return;
105
+ }
106
+ if (!this.widgetRegistered) {
107
+ ui.setWidget(
108
+ FLEET_WIDGET_KEY,
109
+ (tui, theme) => {
110
+ this.tui = tui;
111
+ this.widgetRegistered = true;
112
+ return {
113
+ render: (width) => this.renderLine(width, theme),
114
+ invalidate: () => {},
115
+ dispose: () => {
116
+ if (this.tui === tui) {
117
+ this.tui = null;
118
+ this.widgetRegistered = false;
119
+ }
120
+ },
121
+ };
122
+ },
123
+ { placement: "belowEditor" },
124
+ );
125
+ return;
126
+ }
127
+ this.tui?.requestRender();
128
+ }
129
+
130
+ dispose(): void {
131
+ this.started = false;
132
+ this.unsubscribe?.();
133
+ this.unsubscribe = null;
134
+ const ui = this.deps.getUi();
135
+ this.clearAgeTimer();
136
+ if (ui) this.clearWidget(ui);
137
+ }
138
+
139
+ private clearAgeTimer(): void {
140
+ if (!this.ageTimer) return;
141
+ this.clock.clearInterval(this.ageTimer);
142
+ this.ageTimer = null;
143
+ }
144
+
145
+ private clearWidget(ui: FleetUi): void {
146
+ if (this.widgetRegistered || this.tui) {
147
+ ui.setWidget(FLEET_WIDGET_KEY, undefined, { placement: "belowEditor" });
148
+ this.widgetRegistered = false;
149
+ this.tui = null;
150
+ }
151
+ }
152
+
153
+ /**
154
+ * One line: `● 2 shells · 1 monitor · alpha 12s · beta 8s /tasks`.
155
+ * Live work only: anything that exited, failed, or was killed drops out (its
156
+ * wake pill and /tasks already report it), and the line clears when idle.
157
+ */
158
+ private renderLine(width: number, theme: FleetTheme): string[] {
159
+ const now = this.clock.now();
160
+ const items = this.deps.index.list(now);
161
+ const counts = this.deps.index.counts();
162
+ const parts: string[] = [];
163
+ const summary = summaryLabel(counts.workers, 0, counts.monitors);
164
+ if (summary) parts.push(theme.fg("muted", summary));
165
+ for (const item of items.filter((i) => i.kind === "agent" && isActive(i))) {
166
+ const name = item.name ?? item.title;
167
+ parts.push(`${name} ${theme.fg("dim", formatAge(item.startedAt, item.endedAt, now))}`);
168
+ }
169
+ if (parts.length === 0) return [];
170
+ const line = ` ${theme.fg("accent", "●")} ${parts.join(theme.fg("dim", " · "))} ${theme.fg("dim", "/tasks")}`;
171
+ return [truncateToWidth(line, Math.max(1, width), "…")];
172
+ }
173
+ }
174
+
175
+ /** @deprecated Use FleetWidget — kept as alias for older imports. */
176
+ export { FleetWidget as FleetStatus };
@@ -0,0 +1,108 @@
1
+ /**
2
+ * Fuzzy model spec resolution (design doc §4.6 "model resolution", appendix B).
3
+ *
4
+ * Pure and pi-free: candidates are plain {provider, id, name} records so the
5
+ * resolver is testable without a ModelRegistry. The caller decides the
6
+ * candidate set (scopedModels whitelist when non-empty, else all available).
7
+ *
8
+ * Spec grammar: `[provider/|provider:]id[:thinking]`
9
+ * - thinking suffix: minimal|low|medium|high|xhigh (unrecognized suffixes
10
+ * are treated as part of the id, e.g. OpenRouter's ":exacto")
11
+ * - provider prefix: "provider/id" or "provider:id" both accepted
12
+ * - bare id: exact unique match, else case-insensitive substring on id/name
13
+ */
14
+
15
+ export interface ModelCandidate {
16
+ provider: string;
17
+ id: string;
18
+ name?: string;
19
+ }
20
+
21
+ export type ModelResolution =
22
+ | { ok: true; provider: string; id: string; thinking?: string }
23
+ | { ok: false; error: "no-match" | "ambiguous"; candidates: string[]; thinking?: string };
24
+
25
+ const THINKING_LEVELS = new Set(["minimal", "low", "medium", "high", "xhigh"]);
26
+
27
+ function label(c: ModelCandidate): string {
28
+ return `${c.provider}/${c.id}`;
29
+ }
30
+
31
+ /** Strip a trailing ":<thinking>" when it is a valid level. */
32
+ export function splitThinkingSuffix(spec: string): { base: string; thinking?: string } {
33
+ const idx = spec.lastIndexOf(":");
34
+ if (idx <= 0 || idx === spec.length - 1) return { base: spec };
35
+ const suffix = spec.slice(idx + 1).toLowerCase();
36
+ if (!THINKING_LEVELS.has(suffix)) return { base: spec };
37
+ return { base: spec.slice(0, idx), thinking: suffix };
38
+ }
39
+
40
+ function splitProviderPrefix(spec: string): { provider: string; id: string } | null {
41
+ // "provider/id" or "provider:id" — first separator wins; the remainder may
42
+ // itself contain separators (model ids with colons, e.g. ":exacto").
43
+ for (const sep of ["/", ":"]) {
44
+ const idx = spec.indexOf(sep);
45
+ if (idx > 0 && idx < spec.length - 1) {
46
+ return { provider: spec.slice(0, idx), id: spec.slice(idx + 1) };
47
+ }
48
+ }
49
+ return null;
50
+ }
51
+
52
+ export function resolveModelSpec(spec: string, candidates: ModelCandidate[]): ModelResolution {
53
+ const { base, thinking } = splitThinkingSuffix(spec.trim());
54
+ if (base.length === 0) {
55
+ return { ok: false, error: "no-match", candidates: candidates.map(label), thinking };
56
+ }
57
+
58
+ // 1) provider-qualified exact match
59
+ const qualified = splitProviderPrefix(base);
60
+ if (qualified) {
61
+ const hit = candidates.find(
62
+ (c) => c.provider === qualified.provider && c.id === qualified.id,
63
+ );
64
+ if (hit) return { ok: true, provider: hit.provider, id: hit.id, thinking };
65
+ // Fall through: maybe the whole thing is a bare id containing a separator.
66
+ }
67
+
68
+ // 2) bare-id exact match (must be unique across providers)
69
+ const exact = candidates.filter((c) => c.id === base);
70
+ if (exact.length === 1) {
71
+ return { ok: true, provider: exact[0].provider, id: exact[0].id, thinking };
72
+ }
73
+ if (exact.length > 1) {
74
+ return { ok: false, error: "ambiguous", candidates: exact.map(label), thinking };
75
+ }
76
+
77
+ // 3) case-insensitive substring on id or display name
78
+ const needle = base.toLowerCase();
79
+ const fuzzy = candidates.filter(
80
+ (c) =>
81
+ c.id.toLowerCase().includes(needle) ||
82
+ (c.name !== undefined && c.name.toLowerCase().includes(needle)),
83
+ );
84
+ if (fuzzy.length === 1) {
85
+ return { ok: true, provider: fuzzy[0].provider, id: fuzzy[0].id, thinking };
86
+ }
87
+ if (fuzzy.length > 1) {
88
+ return { ok: false, error: "ambiguous", candidates: fuzzy.map(label), thinking };
89
+ }
90
+ return { ok: false, error: "no-match", candidates: candidates.map(label), thinking };
91
+ }
92
+
93
+ /** Render a resolution failure as an actionable error message. */
94
+ export function modelResolutionError(spec: string, res: ModelResolution & { ok: false }): string {
95
+ const list = res.candidates.slice(0, 20).join(", ");
96
+ const more = res.candidates.length > 20 ? `, … +${res.candidates.length - 20} more` : "";
97
+ if (res.error === "ambiguous") {
98
+ return (
99
+ `model spec "${spec}" is ambiguous (matches: ${list}${more}). ` +
100
+ `Qualify with "provider/<id>".`
101
+ );
102
+ }
103
+ return (
104
+ `model spec "${spec}" matched nothing. ` +
105
+ `Available: ${list || "none"}${more}. ` +
106
+ `Use subagent({action:"models"}) to list selectable models.`
107
+ );
108
+ }