niahere 0.4.5 → 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.5",
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,6 +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 { scopeOf, parseFailure } from "../failure";
9
10
 
10
11
  /**
11
12
  * Resolve the codex binary's absolute path. The daemon runs under launchd with a
@@ -41,21 +42,30 @@ export interface CliProc {
41
42
  }
42
43
  export type SpawnFn = (args: string[], opts: { cwd: string; env: Record<string, string> }) => CliProc;
43
44
 
44
- // Nia secrets that must never reach a third-party agent subprocess. Codex
45
- // authenticates via its own ~/.codex login, not these.
46
- const SCRUB = new Set([
47
- "ANTHROPIC_API_KEY",
48
- "SLACK_BOT_TOKEN",
49
- "SLACK_APP_TOKEN",
50
- "TELEGRAM_BOT_TOKEN",
51
- "TWILIO_AUTH_TOKEN",
52
- "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",
53
61
  ]);
62
+ const ENV_ALLOW_PREFIX = ["LC_", "CODEX_"];
54
63
 
55
- function scrubbedEnv(extra: Record<string, string>): Record<string, string> {
64
+ function subprocessEnv(extra: Record<string, string>): Record<string, string> {
56
65
  const env: Record<string, string> = {};
57
66
  for (const [k, v] of Object.entries(process.env)) {
58
- 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;
59
69
  }
60
70
  return { ...env, ...extra };
61
71
  }
@@ -75,8 +85,9 @@ function defaultSpawn(args: string[], opts: { cwd: string; env: Record<string, s
75
85
  };
76
86
  }
77
87
 
78
- async function* readLines(stream: ReadableStream<Uint8Array>): AsyncGenerator<string> {
79
- 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> {
80
91
  const decoder = new TextDecoder();
81
92
  let buf = "";
82
93
  while (true) {
@@ -92,28 +103,53 @@ async function* readLines(stream: ReadableStream<Uint8Array>): AsyncGenerator<st
92
103
  if (buf.trim()) yield buf;
93
104
  }
94
105
 
95
- async function readAll(stream: ReadableStream<Uint8Array>): Promise<string> {
96
- let out = "";
97
- const decoder = new TextDecoder();
98
- const reader = stream.getReader();
99
- while (true) {
100
- const { value, done } = await reader.read();
101
- if (done) break;
102
- out += decoder.decode(value, { stream: true });
103
- }
104
- 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(() => "");
105
136
  }
106
137
 
138
+ /** A codex that emits nothing for this long is wedged, not working. */
139
+ const DEFAULT_IDLE_TIMEOUT_MS = 10 * 60 * 1000;
140
+
107
141
  export class CodexBackend implements AgentBackend {
108
142
  readonly name = "codex" as const;
109
143
  private spawnFn: SpawnFn;
144
+ private idleTimeoutMs: number;
110
145
 
111
- constructor(deps?: { spawnFn?: SpawnFn }) {
146
+ constructor(deps?: { spawnFn?: SpawnFn; idleTimeoutMs?: number }) {
112
147
  this.spawnFn = deps?.spawnFn ?? defaultSpawn;
148
+ this.idleTimeoutMs = deps?.idleTimeoutMs ?? DEFAULT_IDLE_TIMEOUT_MS;
113
149
  }
114
150
 
115
151
  async openSession(ctx: AgentSessionContext): Promise<AgentSession> {
116
- return new CodexSession(ctx, this.spawnFn);
152
+ return new CodexSession(ctx, this.spawnFn, this.idleTimeoutMs);
117
153
  }
118
154
 
119
155
  async canResume(): Promise<boolean> {
@@ -126,10 +162,12 @@ class CodexSession implements AgentSession {
126
162
  private _sessionId: string | null = null;
127
163
  private aborted: string | null = null;
128
164
  private proc: CliProc | null = null;
165
+ private idledOut = false;
129
166
 
130
167
  constructor(
131
168
  private ctx: AgentSessionContext,
132
169
  private spawnFn: SpawnFn,
170
+ private idleTimeoutMs: number,
133
171
  ) {}
134
172
 
135
173
  get backendSessionId(): string | null {
@@ -156,15 +194,32 @@ class CodexSession implements AgentSession {
156
194
  ];
157
195
  if (this.ctx.model && this.ctx.model !== "default") args.push("-m", this.ctx.model);
158
196
 
159
- 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 }) });
160
198
  this.proc = proc;
161
199
 
200
+ // Started now, not after exit: an undrained pipe blocks the child.
201
+ const stderr = drainStderr(proc.stderr);
202
+
162
203
  const normalizer = new CodexNormalizer();
163
- let sawResult = false;
204
+ const stdout = proc.stdout.getReader();
205
+ const lines = readLines(stdout)[Symbol.asyncIterator]();
206
+ let sawTerminal = false;
164
207
  try {
165
- 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
+
166
221
  if (this.aborted) throw new Error(this.aborted);
167
- const trimmed = line.trim();
222
+ const trimmed = next.value.trim();
168
223
  if (!trimmed) continue;
169
224
  let parsed: unknown;
170
225
  try {
@@ -176,22 +231,32 @@ class CodexSession implements AgentSession {
176
231
  if (ev.type === "session" || ev.type === "result") {
177
232
  this._sessionId = ev.backendSessionId || this._sessionId;
178
233
  }
179
- if (ev.type === "result") sawResult = true;
234
+ if (ev.type === "result" || ev.type === "error") sawTerminal = true;
180
235
  yield ev;
181
236
  }
182
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
+ }
183
247
  const exit = await proc.exited;
184
248
  if (this.aborted) throw new Error(this.aborted);
185
- if (exit !== 0 && !sawResult) {
186
- const stderr = await readAll(proc.stderr);
249
+ if (exit !== 0 && !sawTerminal) {
250
+ const text = await stderr;
187
251
  yield {
188
252
  type: "error",
189
- message: stderr.trim() || `codex exited ${exit}`,
253
+ message: text.trim() || `codex exited ${exit}`,
190
254
  retryable: false,
191
- providerDown: false,
255
+ failover: scopeOf(parseFailure(text), "provider"),
192
256
  };
193
257
  }
194
258
  } finally {
259
+ await stdout.cancel().catch(() => {});
195
260
  revokeRun(token);
196
261
  this.proc = null;
197
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
+ }