niahere 0.4.6 → 0.5.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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "niahere",
3
- "version": "0.4.6",
3
+ "version": "0.5.0",
4
4
  "description": "A personal AI assistant daemon — chat, scheduled jobs, persona system, extensible via skills.",
5
5
  "type": "module",
6
6
  "scripts": {
@@ -13,7 +13,7 @@
13
13
  "typecheck": "tsc --noEmit",
14
14
  "check:cycles": "madge --circular --extensions ts src/",
15
15
  "seed": "bun run src/db/seed.ts",
16
- "release": "npm version patch && npm publish && git push"
16
+ "release": "npm version patch && npm publish && git push --follow-tags"
17
17
  },
18
18
  "keywords": [
19
19
  "ai",
@@ -1,6 +1,6 @@
1
1
  import type { AgentEvent, Normalizer } from "../types";
2
2
  import { truncate } from "../../utils/format-activity";
3
- import { isRetryableApiError, isProviderDownError } from "../../utils/retry";
3
+ import { isRetryable, scopeOf, parseFailure } from "../failure";
4
4
 
5
5
  /**
6
6
  * Pure reducer: Claude Agent SDK messages → normalized `AgentEvent`s.
@@ -120,14 +120,13 @@ export class SdkNormalizer implements Normalizer {
120
120
  };
121
121
  }
122
122
  const raw = (msg.errors?.join(", ") as string) || "unknown error";
123
- // Two INDEPENDENT predicates from the same raw string:
124
- // - retryable: transient API failure → the session may retry internally.
125
- // - providerDown: blank/"unknown error" → the provider is down → failover.
123
+ // A transient error carries no scope yet — it only becomes one once the
124
+ // session has burned its retries.
126
125
  return {
127
126
  type: "error",
128
127
  message: raw,
129
- retryable: isRetryableApiError(raw),
130
- providerDown: isProviderDownError(raw),
128
+ retryable: isRetryable(raw),
129
+ failover: scopeOf(parseFailure(raw), "provider"),
131
130
  terminalReason: msg.terminal_reason,
132
131
  };
133
132
  }
@@ -151,11 +151,12 @@ class ClaudeSession implements AgentSession {
151
151
  retry = true;
152
152
  break;
153
153
  }
154
- // A retryable error that survived all our retries means the provider is
155
- // effectively down for us flag it so the consumer can fail over.
154
+ // A retryable error that survived every retry is a capacity problem
155
+ // with THIS model, not proof the provider is gone scope it to the
156
+ // model so a second model on the same provider still gets its turn.
156
157
  const out =
157
158
  ev.type === "error" && ev.retryable && this.retryCount >= MAX_SEND_RETRIES
158
- ? { ...ev, providerDown: true }
159
+ ? { ...ev, failover: ev.failover ?? ("model" as const) }
159
160
  : ev;
160
161
  yield out;
161
162
  if (out.type === "result" || out.type === "error") {
@@ -1,5 +1,6 @@
1
1
  import type { AgentEvent, Normalizer } from "../types";
2
2
  import { truncate } from "../../utils/format-activity";
3
+ import { scopeOf, parseFailure } from "../failure";
3
4
 
4
5
  /**
5
6
  * Pure reducer: Codex `codex exec --json` JSONL events → normalized `AgentEvent`s.
@@ -7,13 +8,15 @@ import { truncate } from "../../utils/format-activity";
7
8
  * Codex is batch (no token streaming): the assistant message arrives whole in a
8
9
  * single `item.completed`/`agent_message`, and `turn.completed` carries token
9
10
  * usage. So `text` is emitted once (full), then `result` on `turn.completed`.
10
- * No I/O — the session that drives it owns process lifecycle. Real errors are
11
- * detected from the process exit code by the session, not here (Codex `error`
12
- * items are often non-fatal warnings).
11
+ * No I/O — the session that drives it owns process lifecycle. A failed turn
12
+ * arrives as a top-level `error` and/or `turn.failed` carrying the upstream
13
+ * message; `error` *items* are non-fatal warnings (service tier, model metadata,
14
+ * skill budget) and are dropped.
13
15
  */
14
16
  export class CodexNormalizer implements Normalizer {
15
17
  private threadId = "";
16
18
  private agentText = "";
19
+ private failed = false;
17
20
 
18
21
  get backendSessionId(): string {
19
22
  return this.threadId;
@@ -28,6 +31,10 @@ export class CodexNormalizer implements Normalizer {
28
31
  case "item.started":
29
32
  case "item.completed":
30
33
  return this.consumeItem(e.type === "item.completed", e.item);
34
+ case "error":
35
+ return this.fail(typeof e.message === "string" ? e.message : "");
36
+ case "turn.failed":
37
+ return this.fail(typeof e.error?.message === "string" ? e.error.message : "");
31
38
  case "turn.completed":
32
39
  return [
33
40
  {
@@ -47,6 +54,15 @@ export class CodexNormalizer implements Normalizer {
47
54
  }
48
55
  }
49
56
 
57
+ /** Codex reports the same failure twice (`error` then `turn.failed`); only the
58
+ * first ends the turn. */
59
+ private fail(raw: string): AgentEvent[] {
60
+ if (this.failed) return [];
61
+ this.failed = true;
62
+ const failure = parseFailure(raw);
63
+ return [{ type: "error", message: failure.message, retryable: false, failover: scopeOf(failure) }];
64
+ }
65
+
50
66
  private consumeItem(completed: boolean, item: any): AgentEvent[] {
51
67
  if (!item) return [];
52
68
  switch (item.type) {
@@ -6,7 +6,7 @@ import type { Attachment } from "../../types/attachment";
6
6
  import type { McpSourceContext } from "../../mcp";
7
7
  import { CodexNormalizer } from "./codex-normalize";
8
8
  import { mintRun, revokeRun } from "../mcp-endpoint";
9
- import { isCliProviderDownError } from "../../utils/retry";
9
+ import { scopeOf, parseFailure } from "../failure";
10
10
 
11
11
  /**
12
12
  * Resolve the codex binary's absolute path. The daemon runs under launchd with a
@@ -42,21 +42,30 @@ export interface CliProc {
42
42
  }
43
43
  export type SpawnFn = (args: string[], opts: { cwd: string; env: Record<string, string> }) => CliProc;
44
44
 
45
- // Nia secrets that must never reach a third-party agent subprocess. Codex
46
- // authenticates via its own ~/.codex login, not these.
47
- const SCRUB = new Set([
48
- "ANTHROPIC_API_KEY",
49
- "SLACK_BOT_TOKEN",
50
- "SLACK_APP_TOKEN",
51
- "TELEGRAM_BOT_TOKEN",
52
- "TWILIO_AUTH_TOKEN",
53
- "DATABASE_URL",
45
+ // An allowlist, so a newly-added secret is excluded by default rather than
46
+ // leaking to a third-party subprocess. Codex authenticates via its own
47
+ // ~/.codex login, so it needs no credential of ours.
48
+ const ENV_ALLOW = new Set([
49
+ "PATH",
50
+ "HOME",
51
+ "USER",
52
+ "LOGNAME",
53
+ "SHELL",
54
+ "TERM",
55
+ "TMPDIR",
56
+ "TZ",
57
+ "LANG",
58
+ "XDG_CONFIG_HOME",
59
+ "XDG_CACHE_HOME",
60
+ "XDG_DATA_HOME",
54
61
  ]);
62
+ const ENV_ALLOW_PREFIX = ["LC_", "CODEX_"];
55
63
 
56
- function scrubbedEnv(extra: Record<string, string>): Record<string, string> {
64
+ function subprocessEnv(extra: Record<string, string>): Record<string, string> {
57
65
  const env: Record<string, string> = {};
58
66
  for (const [k, v] of Object.entries(process.env)) {
59
- if (!SCRUB.has(k) && v != null) env[k] = v;
67
+ if (v == null) continue;
68
+ if (ENV_ALLOW.has(k) || ENV_ALLOW_PREFIX.some((p) => k.startsWith(p))) env[k] = v;
60
69
  }
61
70
  return { ...env, ...extra };
62
71
  }
@@ -76,8 +85,9 @@ function defaultSpawn(args: string[], opts: { cwd: string; env: Record<string, s
76
85
  };
77
86
  }
78
87
 
79
- async function* readLines(stream: ReadableStream<Uint8Array>): AsyncGenerator<string> {
80
- const reader = stream.getReader();
88
+ /** Takes the reader rather than the stream so the caller can cancel a read that
89
+ * would otherwise never resolve. */
90
+ async function* readLines(reader: ReadableStreamDefaultReader<Uint8Array>): AsyncGenerator<string> {
81
91
  const decoder = new TextDecoder();
82
92
  let buf = "";
83
93
  while (true) {
@@ -93,28 +103,53 @@ async function* readLines(stream: ReadableStream<Uint8Array>): AsyncGenerator<st
93
103
  if (buf.trim()) yield buf;
94
104
  }
95
105
 
96
- async function readAll(stream: ReadableStream<Uint8Array>): Promise<string> {
97
- let out = "";
98
- const decoder = new TextDecoder();
99
- const reader = stream.getReader();
100
- while (true) {
101
- const { value, done } = await reader.read();
102
- if (done) break;
103
- out += decoder.decode(value, { stream: true });
104
- }
105
- return out;
106
+ const IDLE = Symbol("idle");
107
+
108
+ /** A cancellable deadline, used to race a read that may never resolve. */
109
+ function idleAfter(ms: number): { promise: Promise<typeof IDLE>; cancel: () => void } {
110
+ let timer: ReturnType<typeof setTimeout>;
111
+ const promise = new Promise<typeof IDLE>((resolve) => {
112
+ timer = setTimeout(() => resolve(IDLE), ms);
113
+ });
114
+ return { promise, cancel: () => clearTimeout(timer) };
115
+ }
116
+
117
+ const STDERR_CAP = 16_000;
118
+
119
+ /**
120
+ * Consume stderr to EOF, keeping only the tail. Must run alongside the process:
121
+ * an undrained pipe blocks the child once its buffer fills.
122
+ */
123
+ function drainStderr(stream: ReadableStream<Uint8Array>): Promise<string> {
124
+ return (async () => {
125
+ let out = "";
126
+ const decoder = new TextDecoder();
127
+ const reader = stream.getReader();
128
+ while (true) {
129
+ const { value, done } = await reader.read();
130
+ if (done) break;
131
+ out += decoder.decode(value, { stream: true });
132
+ if (out.length > STDERR_CAP) out = out.slice(-STDERR_CAP);
133
+ }
134
+ return out;
135
+ })().catch(() => "");
106
136
  }
107
137
 
138
+ /** A codex that emits nothing for this long is wedged, not working. */
139
+ const DEFAULT_IDLE_TIMEOUT_MS = 10 * 60 * 1000;
140
+
108
141
  export class CodexBackend implements AgentBackend {
109
142
  readonly name = "codex" as const;
110
143
  private spawnFn: SpawnFn;
144
+ private idleTimeoutMs: number;
111
145
 
112
- constructor(deps?: { spawnFn?: SpawnFn }) {
146
+ constructor(deps?: { spawnFn?: SpawnFn; idleTimeoutMs?: number }) {
113
147
  this.spawnFn = deps?.spawnFn ?? defaultSpawn;
148
+ this.idleTimeoutMs = deps?.idleTimeoutMs ?? DEFAULT_IDLE_TIMEOUT_MS;
114
149
  }
115
150
 
116
151
  async openSession(ctx: AgentSessionContext): Promise<AgentSession> {
117
- return new CodexSession(ctx, this.spawnFn);
152
+ return new CodexSession(ctx, this.spawnFn, this.idleTimeoutMs);
118
153
  }
119
154
 
120
155
  async canResume(): Promise<boolean> {
@@ -127,10 +162,12 @@ class CodexSession implements AgentSession {
127
162
  private _sessionId: string | null = null;
128
163
  private aborted: string | null = null;
129
164
  private proc: CliProc | null = null;
165
+ private idledOut = false;
130
166
 
131
167
  constructor(
132
168
  private ctx: AgentSessionContext,
133
169
  private spawnFn: SpawnFn,
170
+ private idleTimeoutMs: number,
134
171
  ) {}
135
172
 
136
173
  get backendSessionId(): string | null {
@@ -157,15 +194,32 @@ class CodexSession implements AgentSession {
157
194
  ];
158
195
  if (this.ctx.model && this.ctx.model !== "default") args.push("-m", this.ctx.model);
159
196
 
160
- const proc = this.spawnFn(args, { cwd: this.ctx.cwd, env: scrubbedEnv({ NIA_MCP_TOKEN: token }) });
197
+ const proc = this.spawnFn(args, { cwd: this.ctx.cwd, env: subprocessEnv({ NIA_MCP_TOKEN: token }) });
161
198
  this.proc = proc;
162
199
 
200
+ // Started now, not after exit: an undrained pipe blocks the child.
201
+ const stderr = drainStderr(proc.stderr);
202
+
163
203
  const normalizer = new CodexNormalizer();
164
- let sawResult = false;
204
+ const stdout = proc.stdout.getReader();
205
+ const lines = readLines(stdout)[Symbol.asyncIterator]();
206
+ let sawTerminal = false;
165
207
  try {
166
- for await (const line of readLines(proc.stdout)) {
208
+ while (true) {
209
+ // Race the read rather than trusting kill() to close the pipe — a child
210
+ // that ignores the signal would otherwise hang the run forever.
211
+ const idle = idleAfter(this.idleTimeoutMs);
212
+ const next = await Promise.race([lines.next(), idle.promise]);
213
+ idle.cancel();
214
+ if (next === IDLE) {
215
+ this.idledOut = true;
216
+ proc.kill();
217
+ break;
218
+ }
219
+ if (next.done) break;
220
+
167
221
  if (this.aborted) throw new Error(this.aborted);
168
- const trimmed = line.trim();
222
+ const trimmed = next.value.trim();
169
223
  if (!trimmed) continue;
170
224
  let parsed: unknown;
171
225
  try {
@@ -177,22 +231,32 @@ class CodexSession implements AgentSession {
177
231
  if (ev.type === "session" || ev.type === "result") {
178
232
  this._sessionId = ev.backendSessionId || this._sessionId;
179
233
  }
180
- if (ev.type === "result") sawResult = true;
234
+ if (ev.type === "result" || ev.type === "error") sawTerminal = true;
181
235
  yield ev;
182
236
  }
183
237
  }
238
+ if (this.idledOut) {
239
+ yield {
240
+ type: "error",
241
+ message: `codex produced no output for ${Math.round(this.idleTimeoutMs / 1000)}s`,
242
+ retryable: false,
243
+ failover: "provider",
244
+ };
245
+ return;
246
+ }
184
247
  const exit = await proc.exited;
185
248
  if (this.aborted) throw new Error(this.aborted);
186
- if (exit !== 0 && !sawResult) {
187
- const stderr = await readAll(proc.stderr);
249
+ if (exit !== 0 && !sawTerminal) {
250
+ const text = await stderr;
188
251
  yield {
189
252
  type: "error",
190
- message: stderr.trim() || `codex exited ${exit}`,
253
+ message: text.trim() || `codex exited ${exit}`,
191
254
  retryable: false,
192
- providerDown: isCliProviderDownError(stderr),
255
+ failover: scopeOf(parseFailure(text), "provider"),
193
256
  };
194
257
  }
195
258
  } finally {
259
+ await stdout.cancel().catch(() => {});
196
260
  revokeRun(token);
197
261
  this.proc = null;
198
262
  }
@@ -0,0 +1,60 @@
1
+ import type { AgentBackend, FailoverScope } from "./types";
2
+ import { providerHealth, type ProviderHealth } from "./health";
3
+
4
+ /** One attempt: a backend paired with the model to run it on. */
5
+ export interface ChainEntry {
6
+ backend: AgentBackend;
7
+ /** Absent → let the backend pick its own default. */
8
+ model?: string;
9
+ }
10
+
11
+ /** `provider:model` for logs. */
12
+ export function describeEntry(entry: ChainEntry): string {
13
+ return `${entry.backend.name}:${entry.model ?? "default"}`;
14
+ }
15
+
16
+ /**
17
+ * Walks the resolved chain. A model-scoped failure advances one entry — possibly
18
+ * the same provider on another model; a provider-scoped one writes that provider
19
+ * off for the rest of the run.
20
+ */
21
+ export class ChainCursor {
22
+ private index: number;
23
+ private readonly down = new Set<string>();
24
+
25
+ constructor(
26
+ private readonly chain: ChainEntry[],
27
+ private readonly health: ProviderHealth = providerHealth,
28
+ ) {
29
+ // Skip a provider that recently failed, but never strand the run: if every
30
+ // provider is cooling down, start at the head and let it try.
31
+ const usable = chain.findIndex((e) => !health.isDown(e.backend.name));
32
+ this.index = usable === -1 ? 0 : usable;
33
+ }
34
+
35
+ get current(): ChainEntry | undefined {
36
+ return this.chain[this.index];
37
+ }
38
+
39
+ get atHead(): boolean {
40
+ return this.index === 0;
41
+ }
42
+
43
+ /** Position in the chain, for deciding whether a fresh cursor would differ. */
44
+ get entry(): ChainEntry | undefined {
45
+ return this.chain[this.index];
46
+ }
47
+
48
+ /** Record the failure and move on. Undefined when nothing usable is left. */
49
+ advance(scope: FailoverScope): ChainEntry | undefined {
50
+ if (scope === "provider") {
51
+ const name = this.chain[this.index]!.backend.name;
52
+ this.down.add(name);
53
+ this.health.markDown(name);
54
+ }
55
+ const next = this.chain.findIndex((e, i) => i > this.index && !this.down.has(e.backend.name));
56
+ if (next === -1) return undefined;
57
+ this.index = next;
58
+ return this.chain[next];
59
+ }
60
+ }
@@ -0,0 +1,90 @@
1
+ import type { FailoverScope } from "./types";
2
+
3
+ /**
4
+ * Failure classification shared by every backend adapter.
5
+ *
6
+ * Prefer the structured signal. Agents talk about logins, credentials and API
7
+ * keys all day, and the codex binary bundles an AWS SDK that mentions them too,
8
+ * so prose is only consulted when there is no HTTP status to go on — and the
9
+ * patterns that read prose are anchored to failure wording rather than to the
10
+ * bare nouns.
11
+ */
12
+
13
+ const RETRYABLE = [/\b500\b/i, /internal server error/i, /overloaded/i, /529/, /rate limit/i];
14
+
15
+ const MODEL_SCOPED = [
16
+ /model .*not (found|supported|available|allowed)/i,
17
+ /(unknown|invalid|unsupported) model/i,
18
+ /model .*(does not exist|is not supported)/i,
19
+ /context ?window ?exceeded/i,
20
+ ];
21
+
22
+ const PROVIDER_SCOPED = [
23
+ /not logged in/i,
24
+ /failed to (authenticate|authorize)/i,
25
+ /(authentication|authorization) (failed|required|error)/i,
26
+ /\b(authorizationfailed|authorizationrequired|noauthorizationsupport)\b/i,
27
+ /failed to (load|refresh)\b.{0,40}\b(credential|token|auth)/i,
28
+ /(session|token|refresh token|credentials) (has |have )?expired/i,
29
+ /\b(tokenexpired|tokenrefreshfailed|refreshtokenfailed|tokenexchangefailed)\b/i,
30
+ /invalid (api[ -]?key|credentials|token)/i,
31
+ /\b(401 unauthorized|403 forbidden)\b/i,
32
+ /\bstatus:? (401|403)\b/i,
33
+ /(usage limit reached|quota exceeded)/i,
34
+ /\b(usagelimitreached|quotaexceeded)\b/i,
35
+ /\b(econnrefused|enotfound|etimedout|econnreset)\b/i,
36
+ /connection (refused|reset|timed out)/i,
37
+ /network (error|unreachable)/i,
38
+ /failed to refresh available models/i,
39
+ ];
40
+
41
+ export interface Failure {
42
+ message: string;
43
+ /** HTTP status, when the backend reported a structured error. */
44
+ status?: number;
45
+ /** Provider's own error type, e.g. `invalid_request_error`. */
46
+ type?: string;
47
+ }
48
+
49
+ /** Unwrap a JSON error envelope; plain text passes through. */
50
+ export function parseFailure(raw: string | null | undefined): Failure {
51
+ const text = (raw ?? "").trim();
52
+ try {
53
+ const parsed = JSON.parse(text);
54
+ const inner = parsed?.error?.message ?? parsed?.message;
55
+ return {
56
+ message: typeof inner === "string" && inner.trim() ? inner : text,
57
+ status: typeof parsed?.status === "number" ? parsed.status : undefined,
58
+ type: typeof parsed?.error?.type === "string" ? parsed.error.type : undefined,
59
+ };
60
+ } catch {
61
+ return { message: text };
62
+ }
63
+ }
64
+
65
+ /** Transient enough for the backend to retry in place. */
66
+ export function isRetryable(text: string | null | undefined): boolean {
67
+ const t = text?.trim();
68
+ return !!t && RETRYABLE.some((p) => p.test(t));
69
+ }
70
+
71
+ function scopeOfStatus(status: number, message: string): FailoverScope | undefined {
72
+ if (status === 404) return "model";
73
+ if (status === 401 || status === 403 || status === 408 || status === 429 || status >= 500) return "provider";
74
+ if (MODEL_SCOPED.some((p) => p.test(message))) return "model";
75
+ return undefined;
76
+ }
77
+
78
+ /**
79
+ * How far the chain should skip. `blank` is what an empty or opaque message
80
+ * implies: a run that reported nothing points at the provider, a structured
81
+ * error that merely lacks a message does not.
82
+ */
83
+ export function scopeOf(failure: Failure, blank?: FailoverScope): FailoverScope | undefined {
84
+ const t = failure.message.trim();
85
+ if (!t || t.toLowerCase() === "unknown error") return blank;
86
+ if (failure.status !== undefined) return scopeOfStatus(failure.status, t);
87
+ if (MODEL_SCOPED.some((p) => p.test(t))) return "model";
88
+ if (PROVIDER_SCOPED.some((p) => p.test(t))) return "provider";
89
+ return undefined;
90
+ }
@@ -0,0 +1,41 @@
1
+ /**
2
+ * Process-wide provider cooldown. Without it every job and every chat turn
3
+ * re-discovers the same outage from scratch, paying the full retry ladder before
4
+ * failing over; with it a provider that just failed is skipped until it has had
5
+ * time to recover, and picked up again once the cooldown lapses.
6
+ */
7
+
8
+ const DEFAULT_COOLDOWN_MS = 5 * 60 * 1000;
9
+
10
+ export interface ProviderHealth {
11
+ markDown(provider: string): void;
12
+ isDown(provider: string): boolean;
13
+ /** Test seam. */
14
+ clear(): void;
15
+ }
16
+
17
+ export function createProviderHealth(
18
+ cooldownMs: number = DEFAULT_COOLDOWN_MS,
19
+ now: () => number = Date.now,
20
+ ): ProviderHealth {
21
+ const downUntil = new Map<string, number>();
22
+ return {
23
+ markDown(provider) {
24
+ downUntil.set(provider, now() + cooldownMs);
25
+ },
26
+ isDown(provider) {
27
+ const until = downUntil.get(provider);
28
+ if (until === undefined) return false;
29
+ if (now() > until) {
30
+ downUntil.delete(provider);
31
+ return false;
32
+ }
33
+ return true;
34
+ },
35
+ clear() {
36
+ downUntil.clear();
37
+ },
38
+ };
39
+ }
40
+
41
+ export const providerHealth = createProviderHealth();
@@ -8,5 +8,7 @@ export type {
8
8
  Normalizer,
9
9
  } from "./types";
10
10
  export { isResultEvent } from "./types";
11
- export { getBackend, setBackend, setBackendChain, resolveBackends } from "./registry";
11
+ export type { FailoverScope } from "./types";
12
+ export { getBackend, setBackend, setBackendChain, resolveChain, buildChain } from "./registry";
13
+ export { ChainCursor, describeEntry, type ChainEntry } from "./chain";
12
14
  export { resolveSdkModel } from "./backends/claude";
@@ -4,6 +4,7 @@ import { randomBytes, randomUUID } from "crypto";
4
4
  import type { NiaTool } from "../mcp/tools/types";
5
5
  import type { McpSourceContext } from "../mcp";
6
6
  import { log } from "../utils/log";
7
+ import { gateSideEffects } from "../mcp/gate";
7
8
 
8
9
  /**
9
10
  * Loopback MCP endpoint — how out-of-process CLI backends (Codex/Gemini) reach
@@ -33,7 +34,7 @@ let endpointTools: NiaTool[] = [];
33
34
  /** Build a per-run MCP server whose tool closures bake in the frozen context. */
34
35
  function buildRunServer(ctx: McpSourceContext, tools: NiaTool[]): McpServer {
35
36
  const mcp = new McpServer({ name: "nia", version: "0.1.0" });
36
- for (const t of tools) {
37
+ for (const t of gateSideEffects(tools)) {
37
38
  mcp.registerTool(t.name, { description: t.description, inputSchema: t.schema }, async (args: unknown) => ({
38
39
  content: [{ type: "text" as const, text: await t.handler(args, ctx) }],
39
40
  }));
@@ -0,0 +1,59 @@
1
+ import type { BackendName } from "../types/config";
2
+
3
+ /** Model → provider resolution. Config names models, never backends, so one
4
+ * provider's model id can never reach another. */
5
+
6
+ export type ProviderName = BackendName;
7
+
8
+ export interface ModelRef {
9
+ provider: ProviderName;
10
+ /** Absent → let the provider pick. */
11
+ model?: string;
12
+ }
13
+
14
+ /** Order of the implicit cross-provider tail. */
15
+ export const PROVIDER_ORDER: readonly ProviderName[] = ["claude", "codex", "gemini"];
16
+
17
+ /** Selects a provider without naming a model. */
18
+ const BARE_PROVIDERS: Record<string, ProviderName> = {
19
+ default: "claude",
20
+ claude: "claude",
21
+ codex: "codex",
22
+ gemini: "gemini",
23
+ };
24
+
25
+ /** Short names the provider understands directly. */
26
+ const ALIASES: Record<string, ProviderName> = {
27
+ sonnet: "claude",
28
+ opus: "claude",
29
+ opusplan: "claude",
30
+ haiku: "claude",
31
+ };
32
+
33
+ const PREFIXES: [RegExp, ProviderName][] = [
34
+ [/^claude[-.]/, "claude"],
35
+ [/^(gpt|o[134]|codex)[-.]/, "codex"],
36
+ [/^gemini[-.]/, "gemini"],
37
+ ];
38
+
39
+ /** Unrecognized names are assumed to be Claude's — a rejected model is a
40
+ * model-scoped failure, so a typo degrades to the next entry. */
41
+ export function resolveModel(name: string): ModelRef {
42
+ const model = name.trim();
43
+ const key = model.toLowerCase();
44
+
45
+ const bare = BARE_PROVIDERS[key];
46
+ if (bare) return { provider: bare };
47
+
48
+ const alias = ALIASES[key];
49
+ if (alias) return { provider: alias, model };
50
+
51
+ for (const [pattern, provider] of PREFIXES) {
52
+ if (pattern.test(key)) return { provider, model };
53
+ }
54
+ return { provider: "claude", model };
55
+ }
56
+
57
+ export function providerDefault(provider: ProviderName): ModelRef {
58
+ return { provider };
59
+ }