niahere 0.5.4 → 0.5.5

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.5.4",
3
+ "version": "0.5.5",
4
4
  "description": "A personal AI assistant daemon — chat, scheduled jobs, persona system, extensible via skills.",
5
5
  "type": "module",
6
6
  "scripts": {
@@ -44,8 +44,8 @@
44
44
  "license": "MIT",
45
45
  "private": false,
46
46
  "dependencies": {
47
- "@anthropic-ai/claude-agent-sdk": "0.3.220",
48
- "@anthropic-ai/sdk": "0.115.0",
47
+ "@anthropic-ai/claude-agent-sdk": "0.3.233",
48
+ "@anthropic-ai/sdk": "0.117.1",
49
49
  "@modelcontextprotocol/sdk": "1.30.0",
50
50
  "@slack/bolt": "^4.6.0",
51
51
  "cron-parser": "^5.5.0",
@@ -0,0 +1,146 @@
1
+ import { existsSync, readFileSync } from "fs";
2
+ import { homedir } from "os";
3
+ import { join } from "path";
4
+ import type { ProviderName } from "./models";
5
+
6
+ /**
7
+ * Provider sign-in state, read from whatever each CLI stores on disk.
8
+ *
9
+ * The distinction that matters: an *access* token expiring is routine — it
10
+ * lives hours and is renewed on demand, so a daemon idle overnight legitimately
11
+ * holds an expired one. A *refresh* token expiring is terminal: nothing can
12
+ * renew it and the only fix is signing in again.
13
+ *
14
+ * Reporting the first as a failure would cry wolf nightly. Reporting neither is
15
+ * how Nia spent sixteen days answering as codex on a Claude token that expired
16
+ * at 22:42 on a Thursday and told nobody.
17
+ */
18
+
19
+ export type AuthState = "ok" | "stale" | "expiring" | "expired" | "unknown";
20
+
21
+ export interface AuthStatus {
22
+ provider: ProviderName;
23
+ state: AuthState;
24
+ detail: string;
25
+ /** When the short-lived access token lapses, ms epoch. */
26
+ accessExpiresAt?: number;
27
+ /** When re-authentication becomes unavoidable, ms epoch. */
28
+ refreshExpiresAt?: number;
29
+ }
30
+
31
+ /** Re-auth this close to being forced is worth saying out loud. */
32
+ export const REFRESH_WARN_MS = 3 * 24 * 60 * 60 * 1000;
33
+
34
+ export interface AuthReader {
35
+ exists: (path: string) => boolean;
36
+ read: (path: string) => string;
37
+ env: (key: string) => string | undefined;
38
+ }
39
+
40
+ const defaultReader: AuthReader = {
41
+ exists: existsSync,
42
+ read: (p) => readFileSync(p, "utf8"),
43
+ env: (k) => process.env[k],
44
+ };
45
+
46
+ export function claudeCredentialsPath(): string {
47
+ return join(homedir(), ".claude", ".credentials.json");
48
+ }
49
+
50
+ export function codexAuthPath(): string {
51
+ return join(process.env.CODEX_HOME || join(homedir(), ".codex"), "auth.json");
52
+ }
53
+
54
+ function ago(ms: number): string {
55
+ const abs = Math.abs(ms);
56
+ const hours = abs / 3_600_000;
57
+ if (hours < 1) return `${Math.round(abs / 60_000)}m`;
58
+ if (hours < 48) return `${hours.toFixed(1)}h`;
59
+ return `${Math.round(hours / 24)}d`;
60
+ }
61
+
62
+ export function claudeAuthStatus(now: number = Date.now(), reader: AuthReader = defaultReader): AuthStatus {
63
+ const base = { provider: "claude" as const };
64
+
65
+ if (reader.env("ANTHROPIC_API_KEY")) {
66
+ return { ...base, state: "ok", detail: "API key (no expiry)" };
67
+ }
68
+
69
+ const path = claudeCredentialsPath();
70
+ if (!reader.exists(path)) {
71
+ // macOS can keep these in the Keychain instead, which a background daemon
72
+ // must not prompt for. Saying "unknown" beats guessing "broken".
73
+ return { ...base, state: "unknown", detail: "no credentials file (keychain, or not signed in)" };
74
+ }
75
+
76
+ let oauth: Record<string, unknown>;
77
+ try {
78
+ oauth = (JSON.parse(reader.read(path)) as Record<string, Record<string, unknown>>).claudeAiOauth ?? {};
79
+ } catch {
80
+ return { ...base, state: "unknown", detail: "credentials file is not readable JSON" };
81
+ }
82
+
83
+ const access = typeof oauth.expiresAt === "number" ? oauth.expiresAt : undefined;
84
+ const refresh = typeof oauth.refreshTokenExpiresAt === "number" ? oauth.refreshTokenExpiresAt : undefined;
85
+ const plan = typeof oauth.subscriptionType === "string" ? ` (${oauth.subscriptionType})` : "";
86
+ const status = { ...base, accessExpiresAt: access, refreshExpiresAt: refresh };
87
+
88
+ if (refresh !== undefined && refresh <= now) {
89
+ return { ...status, state: "expired", detail: `sign-in expired ${ago(now - refresh)} ago — run \`claude\` to sign in again` };
90
+ }
91
+ if (refresh !== undefined && refresh - now < REFRESH_WARN_MS) {
92
+ return { ...status, state: "expiring", detail: `sign-in must be renewed within ${ago(refresh - now)}${plan}` };
93
+ }
94
+ if (access !== undefined && access <= now) {
95
+ // Routine on its own. It only means something alongside a chain that has
96
+ // stopped using this provider, which the failover check supplies.
97
+ return { ...status, state: "stale", detail: `access token lapsed ${ago(now - access)} ago, renewable${plan}` };
98
+ }
99
+ if (access !== undefined) {
100
+ return { ...status, state: "ok", detail: `valid for ${ago(access - now)}${plan}` };
101
+ }
102
+ return { ...status, state: "unknown", detail: "credentials file carries no expiry" };
103
+ }
104
+
105
+ export function codexAuthStatus(now: number = Date.now(), reader: AuthReader = defaultReader): AuthStatus {
106
+ const base = { provider: "codex" as const };
107
+ const path = codexAuthPath();
108
+ if (!reader.exists(path)) return { ...base, state: "unknown", detail: "not signed in" };
109
+
110
+ let auth: Record<string, unknown>;
111
+ try {
112
+ auth = JSON.parse(reader.read(path)) as Record<string, unknown>;
113
+ } catch {
114
+ return { ...base, state: "unknown", detail: "auth file is not readable JSON" };
115
+ }
116
+
117
+ if (typeof auth.OPENAI_API_KEY === "string" && auth.OPENAI_API_KEY) {
118
+ return { ...base, state: "ok", detail: "API key (no expiry)" };
119
+ }
120
+
121
+ const tokens = (auth.tokens ?? {}) as Record<string, unknown>;
122
+ const exp = jwtExpiry(typeof tokens.id_token === "string" ? tokens.id_token : undefined);
123
+ const mode = typeof auth.auth_mode === "string" ? auth.auth_mode : "oauth";
124
+ if (exp === undefined) return { ...base, state: "unknown", detail: `${mode} sign-in, no readable expiry` };
125
+ if (exp <= now) return { ...base, state: "stale", detail: `${mode} token lapsed ${ago(now - exp)} ago, renewable` };
126
+ return { ...base, state: "ok", detail: `${mode}, valid for ${ago(exp - now)}` };
127
+ }
128
+
129
+ /** `exp` out of a JWT payload, in ms. Signature is irrelevant here — we are
130
+ * reading our own stored token, not trusting one. */
131
+ export function jwtExpiry(token: string | undefined): number | undefined {
132
+ const payload = token?.split(".")[1];
133
+ if (!payload) return undefined;
134
+ try {
135
+ const json = JSON.parse(Buffer.from(payload.replace(/-/g, "+").replace(/_/g, "/"), "base64").toString("utf8"));
136
+ return typeof json.exp === "number" ? json.exp * 1000 : undefined;
137
+ } catch {
138
+ return undefined;
139
+ }
140
+ }
141
+
142
+ export function authStatusFor(provider: ProviderName, now?: number, reader?: AuthReader): AuthStatus {
143
+ if (provider === "codex") return codexAuthStatus(now, reader);
144
+ if (provider === "claude") return claudeAuthStatus(now, reader);
145
+ return { provider, state: "unknown", detail: "no adapter" };
146
+ }
@@ -10,13 +10,58 @@ import type { FailoverScope } from "../types";
10
10
  */
11
11
  const DEAD_TURNS: Record<string, FailoverScope | undefined> = {
12
12
  api_error: "provider",
13
+ // This account is out of capacity, not this model — another provider can
14
+ // still answer. Left unmapped, an exhausted plan reads as a completed turn.
15
+ blocking_limit: "provider",
16
+ rapid_refill_breaker: "provider",
17
+ // The model is the problem; the next one in the chain may not be.
18
+ model_error: "model",
19
+ prompt_too_long: "model",
13
20
  budget_exhausted: undefined,
21
+ // No provider will parse the image differently, so there is nothing to fail
22
+ // over to — stop and say so.
23
+ image_error: undefined,
14
24
  malformed_tool_use_exhausted: undefined,
15
25
  structured_output_retry_exhausted: undefined,
16
26
  tool_deferred_unavailable: undefined,
17
27
  turn_setup_failed: undefined,
18
28
  };
19
29
 
30
+ /**
31
+ * Say as much about a failed turn as the result message allows.
32
+ *
33
+ * `errors` is routinely empty on an errored result. Collapsing that to the
34
+ * string "unknown error" cost sixteen days of Nia answering as Codex with 649
35
+ * failures that named no cause and so raised no question. Anything the message
36
+ * still carries beats a word that means nothing.
37
+ */
38
+ export function describeFailure(msg: {
39
+ errors?: unknown;
40
+ subtype?: unknown;
41
+ stop_reason?: unknown;
42
+ terminal_reason?: unknown;
43
+ api_error_status?: unknown;
44
+ result?: unknown;
45
+ }): string {
46
+ const errors = Array.isArray(msg.errors) ? msg.errors.filter((e) => typeof e === "string" && e.trim()) : [];
47
+ if (errors.length > 0) return errors.join(", ");
48
+
49
+ const parts: string[] = [];
50
+ const add = (label: string, value: unknown) => {
51
+ if (typeof value === "string" && value.trim()) parts.push(`${label}=${value.trim()}`);
52
+ else if (typeof value === "number") parts.push(`${label}=${value}`);
53
+ };
54
+ add("http", msg.api_error_status);
55
+ add("subtype", msg.subtype);
56
+ add("stop_reason", msg.stop_reason);
57
+ add("terminal_reason", msg.terminal_reason);
58
+ if (typeof msg.result === "string" && msg.result.trim()) parts.push(`result=${truncate(msg.result.trim(), 200)}`);
59
+
60
+ return parts.length > 0
61
+ ? `claude reported an error with no message (${parts.join(" ")})`
62
+ : "claude reported an error with no message and no detail";
63
+ }
64
+
20
65
  /**
21
66
  * Stamp the chain's provider over the SDK's own, which names the deployment
22
67
  * target (firstParty/bedrock/vertex) — a different axis from which backend ran
@@ -156,6 +201,7 @@ export class SdkNormalizer implements Normalizer {
156
201
  usage: { costUsd: msg.total_cost_usd ?? 0, turns: msg.num_turns ?? 0 },
157
202
  backendSessionId: msg.session_id ?? "",
158
203
  terminalReason: msg.terminal_reason,
204
+ ...(msg.structured_output === undefined ? {} : { structured: msg.structured_output }),
159
205
  metadata: {
160
206
  cost_usd: msg.total_cost_usd,
161
207
  turns: msg.num_turns,
@@ -170,7 +216,7 @@ export class SdkNormalizer implements Normalizer {
170
216
  },
171
217
  };
172
218
  }
173
- const raw = (msg.errors?.join(", ") as string) || "unknown error";
219
+ const raw = describeFailure(msg);
174
220
  // A transient error carries no scope yet — it only becomes one once the
175
221
  // session has burned its retries.
176
222
  return {
@@ -106,6 +106,7 @@ class ClaudeSession implements AgentSession {
106
106
  // same cwd; jobs always run with a unique id and never auto-continued.
107
107
  if (this.ctx.interactive) options.continue = false;
108
108
  }
109
+ if (this.ctx.outputSchema) options.outputFormat = { type: "json_schema", schema: this.ctx.outputSchema };
109
110
  if (this.ctx.mcpServers) options.mcpServers = this.ctx.mcpServers;
110
111
  if (this.ctx.subagents && Object.keys(this.ctx.subagents).length > 0) options.agents = this.ctx.subagents;
111
112
 
@@ -1,6 +1,7 @@
1
1
  import type { AgentEvent, Normalizer } from "../types";
2
2
  import { truncate } from "../../utils/format-activity";
3
3
  import { scopeOf, parseFailure } from "../failure";
4
+ import { estimateCodexCost } from "../pricing";
4
5
 
5
6
  /**
6
7
  * Pure reducer: Codex `codex exec --json` JSONL events → normalized `AgentEvent`s.
@@ -13,10 +14,24 @@ import { scopeOf, parseFailure } from "../failure";
13
14
  * message; `error` *items* are non-fatal warnings (service tier, model metadata,
14
15
  * skill budget) and are dropped.
15
16
  */
17
+ /** codex answers a schema by making its final message the JSON object. */
18
+ export function parseStructured(text: string): unknown {
19
+ const trimmed = text.trim().replace(/^```(?:json)?\s*|\s*```$/g, "");
20
+ if (!trimmed) return undefined;
21
+ try {
22
+ return JSON.parse(trimmed);
23
+ } catch {
24
+ return undefined;
25
+ }
26
+ }
27
+
16
28
  export class CodexNormalizer implements Normalizer {
17
29
  /** The model this run was launched on, for usage attribution — codex's own
18
30
  * events don't name it. */
19
- constructor(private readonly model?: string) {}
31
+ constructor(
32
+ private readonly model?: string,
33
+ private readonly expectsSchema = false,
34
+ ) {}
20
35
 
21
36
  private threadId = "";
22
37
  private agentText = "";
@@ -46,12 +61,21 @@ export class CodexNormalizer implements Normalizer {
46
61
  const cacheRead = e.usage?.cached_input_tokens ?? 0;
47
62
  const input = Math.max(0, (e.usage?.input_tokens ?? 0) - cacheRead);
48
63
  const output = e.usage?.output_tokens ?? 0;
64
+ const estimated = estimateCodexCost(this.model, {
65
+ inputTokens: input,
66
+ outputTokens: output,
67
+ cacheReadInputTokens: cacheRead,
68
+ cacheCreationInputTokens: e.usage?.cache_write_input_tokens ?? 0,
69
+ });
49
70
  return [
50
71
  {
51
72
  type: "result",
52
73
  text: this.agentText,
53
74
  usage: { tokens: { input, output } },
54
75
  backendSessionId: this.threadId,
76
+ ...(this.expectsSchema && parseStructured(this.agentText) !== undefined
77
+ ? { structured: parseStructured(this.agentText) }
78
+ : {}),
55
79
  // Same shape the Claude path emits, so one accumulator serves both
56
80
  // and a failed-over turn is attributable to the provider that ran it.
57
81
  // No cost: codex reports none, and an invented zero would read as a
@@ -64,6 +88,10 @@ export class CodexNormalizer implements Normalizer {
64
88
  outputTokens: output,
65
89
  cacheReadInputTokens: cacheRead,
66
90
  cacheCreationInputTokens: e.usage?.cache_write_input_tokens ?? 0,
91
+ // What the API would have charged. Deliberately not `costUSD`:
92
+ // the subscription covers these tokens, so this is a
93
+ // projection and must never be added to a reported bill.
94
+ ...(estimated === null ? {} : { estimatedCostUSD: estimated }),
67
95
  },
68
96
  },
69
97
  },
@@ -1,4 +1,5 @@
1
- import { existsSync, readdirSync } from "fs";
1
+ import { existsSync, readdirSync, mkdtempSync, writeFileSync, rmSync } from "fs";
2
+ import { tmpdir } from "os";
2
3
  import { homedir } from "os";
3
4
  import { join, dirname } from "path";
4
5
  import type { AgentBackend, AgentSession, AgentSessionContext, AgentEvent } from "../types";
@@ -34,6 +35,52 @@ export function resolveCodexBin(): string {
34
35
  return cachedCodexBin;
35
36
  }
36
37
 
38
+ /**
39
+ * `codex exec -i` takes a path, not bytes. Channels that cache their uploads to
40
+ * disk (Slack, Telegram) give us one; anything held only in memory cannot be
41
+ * passed without materializing it, so it is reported rather than dropped.
42
+ */
43
+ export function attachmentPaths(attachments?: Attachment[]): { paths: string[]; skipped: number } {
44
+ const paths: string[] = [];
45
+ let skipped = 0;
46
+ for (const a of attachments ?? []) {
47
+ if (a.sourcePath && existsSync(a.sourcePath)) paths.push(a.sourcePath);
48
+ else skipped++;
49
+ }
50
+ return { paths, skipped };
51
+ }
52
+
53
+ /** codex writes rollouts to `$CODEX_HOME/sessions/YYYY/MM/DD/rollout-<ts>-<uuid>.jsonl`. */
54
+ export function codexHome(): string {
55
+ return process.env.CODEX_HOME || join(homedir(), ".codex");
56
+ }
57
+
58
+ /**
59
+ * Only recent days are searched. Resume exists to continue the conversation in
60
+ * front of us; anything older replays from Nia's own transcript instead, and
61
+ * walking every rollout ever written (thousands of files) to prove a negative
62
+ * would cost more than the resume saves.
63
+ */
64
+ const RESUME_WINDOW_DAYS = 7;
65
+
66
+ export function findRollout(sessionId: string, now: Date = new Date()): string | null {
67
+ if (!/^[0-9a-f-]{32,}$/i.test(sessionId)) return null;
68
+ const pad = (n: number) => String(n).padStart(2, "0");
69
+ for (let i = 0; i < RESUME_WINDOW_DAYS; i++) {
70
+ const d = new Date(now.getTime() - i * 86_400_000);
71
+ const dir = join(codexHome(), "sessions", String(d.getFullYear()), pad(d.getMonth() + 1), pad(d.getDate()));
72
+ let entries: string[];
73
+ try {
74
+ entries = readdirSync(dir);
75
+ } catch {
76
+ continue; // no sessions that day
77
+ }
78
+ const hit = entries.find((f) => f.startsWith("rollout-") && f.endsWith(`${sessionId}.jsonl`));
79
+ if (hit) return join(dir, hit);
80
+ }
81
+ return null;
82
+ }
83
+
37
84
  /** Minimal spawned-process surface, injectable so the session is unit-testable. */
38
85
  export interface CliProc {
39
86
  stdout: ReadableStream<Uint8Array>;
@@ -75,6 +122,10 @@ function defaultSpawn(args: string[], opts: { cwd: string; env: Record<string, s
75
122
  const proc = Bun.spawn([resolveCodexBin(), ...args], {
76
123
  cwd: opts.cwd,
77
124
  env: opts.env,
125
+ // The prompt goes in as an argument. `codex exec` still appends piped stdin
126
+ // as a <stdin> block, so leaving it to whatever the daemon inherited lets
127
+ // the parent's descriptor become part of the prompt.
128
+ stdin: "ignore",
78
129
  stdout: "pipe",
79
130
  stderr: "pipe",
80
131
  });
@@ -117,6 +168,33 @@ function idleAfter(ms: number): { promise: Promise<typeof IDLE>; cancel: () => v
117
168
 
118
169
  const STDERR_CAP = 16_000;
119
170
 
171
+ /** Progress chatter codex writes to stderr on a healthy run. Reporting it as
172
+ * the cause of a failure is how "Reading additional input from stdin..." came
173
+ * to be the recorded error for every job that ever failed. */
174
+ const STDERR_NOISE = [
175
+ /^reading additional input from stdin/i,
176
+ /^\s*$/,
177
+ /^\[?\d{4}-\d{2}-\d{2}T[\d:.]+Z?\]?\s*$/,
178
+ /^workdir:/i,
179
+ /^model:/i,
180
+ /^provider:/i,
181
+ /^approval:/i,
182
+ /^sandbox:/i,
183
+ /^reasoning (effort|summaries):/i,
184
+ /^--------$/,
185
+ /^openai codex v/i,
186
+ ];
187
+
188
+ /** Drop the chatter, keep the diagnosis. Empty means codex said nothing useful. */
189
+ export function meaningfulStderr(text: string): string {
190
+ return text
191
+ .split("\n")
192
+ .map((l) => l.trimEnd())
193
+ .filter((l) => l.trim() && !STDERR_NOISE.some((p) => p.test(l.trim())))
194
+ .join("\n")
195
+ .trim();
196
+ }
197
+
120
198
  /**
121
199
  * Consume stderr to EOF, keeping only the tail. Must run alongside the process:
122
200
  * an undrained pipe blocks the child once its buffer fills.
@@ -153,14 +231,14 @@ export class CodexBackend implements AgentBackend {
153
231
  return new CodexSession(ctx, this.spawnFn, this.idleTimeoutMs);
154
232
  }
155
233
 
156
- async canResume(): Promise<boolean> {
157
- // v1: no thread resume; failover/continuity replays history from Nia's DB.
158
- return false;
234
+ /** codex keys rollouts by session id alone — cwd does not narrow the search. */
235
+ async canResume(backendSessionId: string, _cwd: string): Promise<boolean> {
236
+ return findRollout(backendSessionId) !== null;
159
237
  }
160
238
  }
161
239
 
162
240
  class CodexSession implements AgentSession {
163
- private _sessionId: string | null = null;
241
+ private _sessionId: string | null;
164
242
  private aborted: string | null = null;
165
243
  private proc: CliProc | null = null;
166
244
  private idledOut = false;
@@ -169,20 +247,24 @@ class CodexSession implements AgentSession {
169
247
  private ctx: AgentSessionContext,
170
248
  private spawnFn: SpawnFn,
171
249
  private idleTimeoutMs: number,
172
- ) {}
250
+ ) {
251
+ this._sessionId = typeof ctx.resume === "string" ? ctx.resume : null;
252
+ }
173
253
 
174
254
  get backendSessionId(): string | null {
175
255
  return this._sessionId;
176
256
  }
177
257
 
178
- async *send(text: string, _attachments?: Attachment[]): AsyncIterable<AgentEvent> {
258
+ async *send(text: string, attachments?: Attachment[]): AsyncIterable<AgentEvent> {
179
259
  const source: McpSourceContext = this.ctx.source ?? { channel: this.ctx.channel, room: this.ctx.room };
180
260
  const { url, token } = await mintRun(source);
181
261
 
182
- const fullPrompt = `${this.ctx.systemPrompt}\n\n---\n\n${text}`;
183
- const args = [
184
- "exec",
185
- fullPrompt,
262
+ // Resuming carries the system prompt with the thread, so re-sending it would
263
+ // stack a second copy on every turn.
264
+ const resumable = this._sessionId && findRollout(this._sessionId);
265
+ const prompt = resumable ? text : `${this.ctx.systemPrompt}\n\n---\n\n${text}`;
266
+ const args = resumable ? ["exec", "resume", this._sessionId!, prompt] : ["exec", prompt];
267
+ args.push(
186
268
  "--json",
187
269
  "--skip-git-repo-check",
188
270
  "--dangerously-bypass-approvals-and-sandbox",
@@ -192,8 +274,23 @@ class CodexSession implements AgentSession {
192
274
  `mcp_servers.nia.url="${url}"`,
193
275
  "-c",
194
276
  `mcp_servers.nia.bearer_token_env_var="NIA_MCP_TOKEN"`,
195
- ];
277
+ );
278
+ // codex takes the schema as a file path, so it needs somewhere to live for
279
+ // the length of the run.
280
+ let schemaDir: string | null = null;
281
+ if (this.ctx.outputSchema) {
282
+ schemaDir = mkdtempSync(join(tmpdir(), "nia-codex-schema-"));
283
+ const schemaPath = join(schemaDir, "schema.json");
284
+ writeFileSync(schemaPath, JSON.stringify(this.ctx.outputSchema));
285
+ args.push("--output-schema", schemaPath);
286
+ }
287
+
288
+ const media = attachmentPaths(attachments);
289
+ for (const path of media.paths) args.push("-i", path);
196
290
  if (this.ctx.model && this.ctx.model !== "default") args.push("-m", this.ctx.model);
291
+ if (media.skipped > 0) {
292
+ yield { type: "thinking", delta: `${media.skipped} attachment(s) had no file on disk and were not sent to codex` };
293
+ }
197
294
 
198
295
  const proc = this.spawnFn(args, { cwd: this.ctx.cwd, env: subprocessEnv({ NIA_MCP_TOKEN: token }) });
199
296
  this.proc = proc;
@@ -201,7 +298,7 @@ class CodexSession implements AgentSession {
201
298
  // Started now, not after exit: an undrained pipe blocks the child.
202
299
  const stderr = drainStderr(proc.stderr);
203
300
 
204
- const normalizer = new CodexNormalizer(this.ctx.model);
301
+ const normalizer = new CodexNormalizer(this.ctx.model, !!this.ctx.outputSchema);
205
302
  const stdout = proc.stdout.getReader();
206
303
  const lines = readLines(stdout)[Symbol.asyncIterator]();
207
304
  let sawTerminal = false;
@@ -248,10 +345,10 @@ class CodexSession implements AgentSession {
248
345
  const exit = await proc.exited;
249
346
  if (this.aborted) throw new Error(this.aborted);
250
347
  if (exit !== 0 && !sawTerminal) {
251
- const text = await stderr;
348
+ const text = meaningfulStderr(await stderr);
252
349
  yield {
253
350
  type: "error",
254
- message: text.trim() || `codex exited ${exit}`,
351
+ message: text || `codex exited ${exit} without reporting a cause`,
255
352
  retryable: false,
256
353
  failover: scopeOf(parseFailure(text), "provider"),
257
354
  };
@@ -259,6 +356,7 @@ class CodexSession implements AgentSession {
259
356
  } finally {
260
357
  await ignore(stdout.cancel(), "cancel codex stdout reader");
261
358
  revokeRun(token);
359
+ if (schemaDir) rmSync(schemaDir, { recursive: true, force: true });
262
360
  this.proc = null;
263
361
  }
264
362
  }
@@ -0,0 +1,40 @@
1
+ import { existsSync } from "fs";
2
+ import { resolveCodexBin } from "./backends/codex";
3
+
4
+ /** Whether a codex backend can run on this host at all. */
5
+ export function codexAvailable(): boolean {
6
+ return existsSync(resolveCodexBin());
7
+ }
8
+
9
+ /** Minimal spawned-process surface, injectable so the probe is unit-testable. */
10
+ export type CatalogRunner = (args: string[]) => Promise<{ stdout: string; exitCode: number }>;
11
+
12
+ const defaultRunner: CatalogRunner = async (args) => {
13
+ const proc = Bun.spawn([resolveCodexBin(), ...args], { stdout: "pipe", stderr: "ignore" });
14
+ const stdout = await new Response(proc.stdout).text();
15
+ return { stdout, exitCode: await proc.exited };
16
+ };
17
+
18
+ export function parseCodexModels(stdout: string): string[] | null {
19
+ try {
20
+ const parsed = JSON.parse(stdout) as { models?: { slug?: unknown }[] };
21
+ const slugs = (parsed.models ?? []).map((m) => m.slug).filter((s): s is string => typeof s === "string" && s !== "");
22
+ return slugs.length > 0 ? slugs : null;
23
+ } catch {
24
+ return null;
25
+ }
26
+ }
27
+
28
+ /**
29
+ * Model slugs this codex install will accept. Null when the catalog cannot be
30
+ * read — callers treat that as "unknown", never as "the model is gone", so a
31
+ * broken probe can't manufacture a false alarm.
32
+ */
33
+ export async function codexModelSlugs(run: CatalogRunner = defaultRunner): Promise<string[] | null> {
34
+ try {
35
+ const { stdout, exitCode } = await run(["debug", "models"]);
36
+ return exitCode === 0 ? parseCodexModels(stdout) : null;
37
+ } catch {
38
+ return null;
39
+ }
40
+ }
@@ -12,6 +12,10 @@ import type { FailoverScope } from "./types";
12
12
 
13
13
  const RETRYABLE = [/\b500\b/i, /internal server error/i, /overloaded/i, /529/, /rate limit/i];
14
14
 
15
+ /** Messages that name no cause. They still have to fail over — a backend that
16
+ * cannot say what went wrong has not said it is healthy. */
17
+ const OPAQUE = [/^unknown error$/i, /reported an error with no message/i];
18
+
15
19
  const MODEL_SCOPED = [
16
20
  /model .*not (found|supported|available|allowed)/i,
17
21
  /(unknown|invalid|unsupported) model/i,
@@ -85,7 +89,7 @@ export function scopeOf(failure: Failure, blank?: FailoverScope): FailoverScope
85
89
  // A status is authoritative even when the message is empty — the prose that
86
90
  // accompanies a 429 or 529 is routinely unhelpful.
87
91
  if (failure.status !== undefined) return scopeOfStatus(failure.status, t);
88
- if (!t || t.toLowerCase() === "unknown error") return blank;
92
+ if (!t || OPAQUE.some((p) => p.test(t))) return blank;
89
93
  if (MODEL_SCOPED.some((p) => p.test(t))) return "model";
90
94
  if (PROVIDER_SCOPED.some((p) => p.test(t))) return "provider";
91
95
  return undefined;
@@ -10,6 +10,13 @@ const DEFAULT_COOLDOWN_MS = 5 * 60 * 1000;
10
10
  export interface ProviderHealth {
11
11
  markDown(provider: string): void;
12
12
  isDown(provider: string): boolean;
13
+ /** A turn was served. `atHead` means the chain's primary answered it. */
14
+ markServed(provider: string, atHead: boolean): void;
15
+ /** How long the chain has been answering exclusively from a fallback, in ms,
16
+ * or null when the primary is still serving (or nothing has run yet). */
17
+ fallbackStreakMs(): number | null;
18
+ /** Who has been covering, for the alert text. */
19
+ lastServer(): string | null;
13
20
  /** Test seam. */
14
21
  clear(): void;
15
22
  }
@@ -19,10 +26,26 @@ export function createProviderHealth(
19
26
  now: () => number = Date.now,
20
27
  ): ProviderHealth {
21
28
  const downUntil = new Map<string, number>();
29
+ // When the primary last answered, and when a fallback first had to cover for
30
+ // it. Failover is designed to be silent, so nothing else in the system can
31
+ // tell the difference between a blip and a provider that has been gone a week.
32
+ let fallbackSince: number | null = null;
33
+ let server: string | null = null;
22
34
  return {
23
35
  markDown(provider) {
24
36
  downUntil.set(provider, now() + cooldownMs);
25
37
  },
38
+ markServed(provider, atHead) {
39
+ server = provider;
40
+ if (atHead) fallbackSince = null;
41
+ else fallbackSince ??= now();
42
+ },
43
+ fallbackStreakMs() {
44
+ return fallbackSince === null ? null : now() - fallbackSince;
45
+ },
46
+ lastServer() {
47
+ return server;
48
+ },
26
49
  isDown(provider) {
27
50
  const until = downUntil.get(provider);
28
51
  if (until === undefined) return false;
@@ -34,6 +57,8 @@ export function createProviderHealth(
34
57
  },
35
58
  clear() {
36
59
  downUntil.clear();
60
+ fallbackSince = null;
61
+ server = null;
37
62
  },
38
63
  };
39
64
  }
@@ -11,4 +11,5 @@ export { isResultEvent } from "./types";
11
11
  export type { FailoverScope } from "./types";
12
12
  export { getBackend, setBackend, setBackendChain, resolveChain, buildChain } from "./registry";
13
13
  export { ChainCursor, describeEntry, type ChainEntry } from "./chain";
14
+ export { providerHealth, type ProviderHealth } from "./health";
14
15
  export { resolveSdkModel } from "./backends/claude";