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 +3 -3
- package/src/agent/auth.ts +146 -0
- package/src/agent/backends/claude-normalize.ts +47 -1
- package/src/agent/backends/claude.ts +1 -0
- package/src/agent/backends/codex-normalize.ts +29 -1
- package/src/agent/backends/codex.ts +113 -15
- package/src/agent/catalog.ts +40 -0
- package/src/agent/failure.ts +5 -1
- package/src/agent/health.ts +25 -0
- package/src/agent/index.ts +1 -0
- package/src/agent/models.ts +38 -0
- package/src/agent/pricing.ts +67 -0
- package/src/agent/registry.ts +10 -31
- package/src/agent/types.ts +9 -0
- package/src/channels/common/coalesce.ts +178 -0
- package/src/channels/common/watch-judgement.ts +61 -0
- package/src/channels/slack.ts +147 -106
- package/src/chat/engine.ts +19 -3
- package/src/cli/usage.ts +26 -2
- package/src/core/alive.ts +18 -0
- package/src/core/health.ts +116 -0
- package/src/core/runner.ts +5 -2
- package/src/db/models/usage.ts +0 -0
- package/src/types/engine.ts +4 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "niahere",
|
|
3
|
-
"version": "0.5.
|
|
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.
|
|
48
|
-
"@anthropic-ai/sdk": "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
|
|
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(
|
|
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
|
-
|
|
157
|
-
|
|
158
|
-
return
|
|
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
|
|
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,
|
|
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
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
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
|
|
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
|
+
}
|
package/src/agent/failure.ts
CHANGED
|
@@ -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 ||
|
|
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;
|
package/src/agent/health.ts
CHANGED
|
@@ -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
|
}
|
package/src/agent/index.ts
CHANGED
|
@@ -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";
|