niahere 0.3.12 → 0.4.1

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.3.12",
3
+ "version": "0.4.1",
4
4
  "description": "A personal AI assistant daemon — chat, scheduled jobs, persona system, extensible via skills.",
5
5
  "type": "module",
6
6
  "scripts": {
@@ -0,0 +1,142 @@
1
+ import type { AgentEvent, Normalizer } from "../types";
2
+ import { truncate, formatToolUse } from "../../utils/format-activity";
3
+ import { isRetryableApiError, isProviderDownError } from "../../utils/retry";
4
+
5
+ /**
6
+ * Pure reducer: Claude Agent SDK messages → normalized `AgentEvent`s.
7
+ *
8
+ * Ports the consume-loop handling that lived inline in `engine.ts` and
9
+ * `runner.ts`, so the two paths share one implementation. Holds only the
10
+ * thinking-accumulation state those loops kept as locals. No I/O, no timers —
11
+ * the session that drives it owns all orchestration.
12
+ *
13
+ * Display strings (truncation, `formatToolUse`, the `$ ` Bash prefix) are
14
+ * produced here so behavior is byte-identical to the old loops and consumers
15
+ * stay backend-agnostic.
16
+ */
17
+ export class SdkNormalizer implements Normalizer {
18
+ private accumulatedThinking = "";
19
+ private lastThinkingLine = "";
20
+
21
+ consume(message: unknown): AgentEvent[] {
22
+ const msg = message as any;
23
+
24
+ if (msg.type === "system" && msg.subtype === "init") {
25
+ return [{ type: "session", backendSessionId: msg.session_id }];
26
+ }
27
+
28
+ if (msg.type === "stream_event") {
29
+ return this.consumeStreamEvent(msg.event);
30
+ }
31
+
32
+ if (msg.type === "tool_use_summary") {
33
+ return [
34
+ {
35
+ type: "tool",
36
+ name: msg.tool_name || "tool",
37
+ summary: formatToolUse(msg.tool_name || "tool", msg.tool_input),
38
+ },
39
+ ];
40
+ }
41
+
42
+ if (msg.type === "tool_progress") {
43
+ if (msg.tool_name === "Bash" && msg.content) {
44
+ return [{ type: "tool", name: "Bash", summary: `$ ${truncate(msg.content, 60)}` }];
45
+ }
46
+ if (msg.content) {
47
+ return [{ type: "tool", name: msg.tool_name || "tool", summary: truncate(msg.content, 70) }];
48
+ }
49
+ return [];
50
+ }
51
+
52
+ if (msg.type === "system") {
53
+ // Subagent/task lifecycle (subtype init handled above).
54
+ if (msg.subtype === "task_started" && msg.description) {
55
+ return [{ type: "tool", name: "task", summary: truncate(msg.description, 60) }];
56
+ }
57
+ if (msg.subtype === "task_progress" && msg.last_tool_name) {
58
+ return [{ type: "tool", name: msg.last_tool_name, summary: msg.summary || msg.last_tool_name }];
59
+ }
60
+ return [];
61
+ }
62
+
63
+ if (msg.type === "result") {
64
+ return [this.consumeResult(msg)];
65
+ }
66
+
67
+ return [];
68
+ }
69
+
70
+ private consumeStreamEvent(event: any): AgentEvent[] {
71
+ if (event?.type === "content_block_delta") {
72
+ const delta = event.delta;
73
+ if (delta?.type === "text_delta" && delta.text) {
74
+ return [{ type: "text", delta: delta.text }];
75
+ }
76
+ if (delta?.type === "thinking_delta" && delta.thinking) {
77
+ return this.consumeThinkingDelta(delta.thinking);
78
+ }
79
+ return [];
80
+ }
81
+ if (event?.type === "content_block_start" && event.content_block?.type === "thinking") {
82
+ this.accumulatedThinking = "";
83
+ this.lastThinkingLine = "";
84
+ return [{ type: "thinking", delta: "thinking..." }];
85
+ }
86
+ if (event?.type === "content_block_stop") {
87
+ this.accumulatedThinking = "";
88
+ this.lastThinkingLine = "";
89
+ return [];
90
+ }
91
+ return [];
92
+ }
93
+
94
+ /** Emit a thinking line only on a newline boundary (the last COMPLETE line). */
95
+ private consumeThinkingDelta(thinking: string): AgentEvent[] {
96
+ this.accumulatedThinking += thinking;
97
+ const lines = this.accumulatedThinking.split("\n");
98
+ if (lines.length > 1) {
99
+ const completeLine = lines[lines.length - 2]?.trim();
100
+ if (completeLine && completeLine !== this.lastThinkingLine) {
101
+ this.lastThinkingLine = completeLine;
102
+ return [{ type: "thinking", delta: truncate(completeLine, 70) }];
103
+ }
104
+ }
105
+ return [];
106
+ }
107
+
108
+ private consumeResult(msg: any): AgentEvent {
109
+ if (!msg.is_error) {
110
+ return {
111
+ type: "result",
112
+ text: (msg.result as string) || "",
113
+ usage: { costUsd: msg.total_cost_usd ?? 0, turns: msg.num_turns ?? 0 },
114
+ backendSessionId: msg.session_id ?? "",
115
+ terminalReason: msg.terminal_reason,
116
+ metadata: {
117
+ cost_usd: msg.total_cost_usd,
118
+ turns: msg.num_turns,
119
+ duration_ms: msg.duration_ms,
120
+ duration_api_ms: msg.duration_api_ms,
121
+ stop_reason: msg.stop_reason,
122
+ terminal_reason: msg.terminal_reason,
123
+ session_id: msg.session_id,
124
+ subtype: msg.subtype,
125
+ usage: msg.usage,
126
+ model_usage: msg.modelUsage,
127
+ },
128
+ };
129
+ }
130
+ const raw = (msg.errors?.join(", ") as string) || "unknown error";
131
+ // Two INDEPENDENT predicates from the same raw string:
132
+ // - retryable: transient API failure → the session may retry internally.
133
+ // - providerDown: blank/"unknown error" → the provider is down → failover.
134
+ return {
135
+ type: "error",
136
+ message: raw,
137
+ retryable: isRetryableApiError(raw),
138
+ providerDown: isProviderDownError(raw),
139
+ terminalReason: msg.terminal_reason,
140
+ };
141
+ }
142
+ }
@@ -0,0 +1,181 @@
1
+ import { query } from "@anthropic-ai/claude-agent-sdk";
2
+ import { randomUUID } from "crypto";
3
+ import { existsSync } from "fs";
4
+ import { join } from "path";
5
+ import { homedir } from "os";
6
+ import type { AgentBackend, AgentSession, AgentSessionContext, AgentEvent } from "../types";
7
+ import type { Attachment } from "../../types/attachment";
8
+ import { SdkNormalizer } from "./claude-normalize";
9
+ import { MessageStream } from "../message-stream";
10
+ import { getSdkSkillsSetting } from "../../core/skills";
11
+ import { getSdkHooks } from "../../core/sdk-hooks";
12
+ import { getConfig } from "../../utils/config";
13
+ import { sleep } from "../../utils/retry";
14
+
15
+ /** The shape of the SDK `query()` handle the session consumes. Injected so the
16
+ * session is unit-testable without spawning Claude. */
17
+ export type QueryHandle = AsyncIterable<unknown> & { close(): void };
18
+ export type QueryFn = (args: { prompt: unknown; options: unknown }) => QueryHandle;
19
+
20
+ const MAX_SEND_RETRIES = 2;
21
+ const DEFAULT_RETRY_DELAYS = [3_000, 8_000];
22
+
23
+ /** The SDK persists sessions at ~/.claude/projects/<encoded-cwd>/<id>.jsonl. */
24
+ function sessionFileExists(sessionId: string, cwd: string): boolean {
25
+ const encoded = cwd.replace(/\//g, "-");
26
+ return existsSync(join(homedir(), ".claude", "projects", encoded, `${sessionId}.jsonl`));
27
+ }
28
+
29
+ /** Resolve a context/config model to the SDK's `model` option ("default" → unset). */
30
+ export function resolveSdkModel(model?: string | null): string | undefined {
31
+ const m = model || getConfig().model;
32
+ return m && m !== "default" ? m : undefined;
33
+ }
34
+
35
+ export class ClaudeBackend implements AgentBackend {
36
+ readonly name = "claude" as const;
37
+ private queryFn: QueryFn;
38
+
39
+ constructor(deps?: { queryFn?: QueryFn }) {
40
+ this.queryFn = deps?.queryFn ?? (query as unknown as QueryFn);
41
+ }
42
+
43
+ async openSession(ctx: AgentSessionContext): Promise<AgentSession> {
44
+ return new ClaudeSession(ctx, this.queryFn);
45
+ }
46
+
47
+ async canResume(backendSessionId: string, cwd: string): Promise<boolean> {
48
+ return sessionFileExists(backendSessionId, cwd);
49
+ }
50
+ }
51
+
52
+ /**
53
+ * A warm Claude session: one `query()` subprocess + `MessageStream` reused
54
+ * across turns (the latency optimization). Each `send()` pushes a turn and
55
+ * yields its normalized events until a terminal `result`/`error`.
56
+ *
57
+ * Invariants (from the plan review):
58
+ * - exactly ONE `session` event per `send()`, even across an internal retry
59
+ * (a retry resumes the same session id, so the post-retry init is swallowed);
60
+ * - retry teardown+restart is internal and atomic w.r.t. `abort()`.
61
+ */
62
+ class ClaudeSession implements AgentSession {
63
+ private _sessionId: string | null;
64
+ private handle: QueryHandle | null = null;
65
+ private iterator: AsyncIterator<unknown> | null = null;
66
+ private stream: MessageStream | null = null;
67
+ private aborted: string | null = null;
68
+ private retryCount = 0;
69
+ private readonly retryDelays: number[];
70
+
71
+ constructor(
72
+ private ctx: AgentSessionContext & { retryDelaysMs?: number[] },
73
+ private queryFn: QueryFn,
74
+ ) {
75
+ this._sessionId = typeof ctx.resume === "string" ? ctx.resume : null;
76
+ this.retryDelays = ctx.retryDelaysMs ?? DEFAULT_RETRY_DELAYS;
77
+ }
78
+
79
+ get backendSessionId(): string | null {
80
+ return this._sessionId;
81
+ }
82
+
83
+ private startQuery(): void {
84
+ this.stream = new MessageStream();
85
+ const options: Record<string, unknown> = {
86
+ systemPrompt: this.ctx.systemPrompt,
87
+ cwd: this.ctx.cwd,
88
+ permissionMode: "bypassPermissions",
89
+ skills: getSdkSkillsSetting(),
90
+ hooks: getSdkHooks(),
91
+ };
92
+ // Interactive (chat) sessions stream partials and load project/user settings;
93
+ // headless one-shot jobs keep the leaner option set they had pre-refactor.
94
+ if (this.ctx.interactive) {
95
+ options.includePartialMessages = true;
96
+ options.settingSources = ["project", "user"];
97
+ }
98
+ const model = resolveSdkModel(this.ctx.model);
99
+ if (model) options.model = model;
100
+ if (this._sessionId) {
101
+ options.resume = this._sessionId;
102
+ } else {
103
+ options.sessionId = randomUUID();
104
+ // Interactive sessions also forbid auto-continue of a prior session in the
105
+ // same cwd; jobs always run with a unique id and never auto-continued.
106
+ if (this.ctx.interactive) options.continue = false;
107
+ }
108
+ if (this.ctx.mcpServers) options.mcpServers = this.ctx.mcpServers;
109
+ if (this.ctx.subagents && Object.keys(this.ctx.subagents).length > 0) options.agents = this.ctx.subagents;
110
+
111
+ this.handle = this.queryFn({ prompt: this.stream, options });
112
+ this.iterator = this.handle[Symbol.asyncIterator]();
113
+ }
114
+
115
+ async *send(text: string, attachments?: Attachment[]): AsyncIterable<AgentEvent> {
116
+ let sawSession = false;
117
+ while (true) {
118
+ if (!this.iterator || !this.stream) this.startQuery();
119
+ this.stream!.push(text, attachments);
120
+ const normalizer = new SdkNormalizer();
121
+ let retry = false;
122
+
123
+ while (true) {
124
+ let res: IteratorResult<unknown>;
125
+ try {
126
+ res = await this.iterator!.next();
127
+ } catch (err) {
128
+ if (this.aborted) throw new Error(this.aborted);
129
+ throw err instanceof Error ? err : new Error(String(err));
130
+ }
131
+ if (this.aborted) throw new Error(this.aborted);
132
+ if (res.done) {
133
+ if (this.aborted) throw new Error(this.aborted);
134
+ throw new Error("stream ended without result");
135
+ }
136
+
137
+ for (const ev of normalizer.consume(res.value)) {
138
+ if (ev.type === "session") {
139
+ this._sessionId = ev.backendSessionId;
140
+ if (!sawSession) {
141
+ sawSession = true;
142
+ yield ev;
143
+ }
144
+ continue;
145
+ }
146
+ if (ev.type === "error" && ev.retryable && this.retryCount < MAX_SEND_RETRIES) {
147
+ this.retryCount++;
148
+ yield { type: "thinking", delta: "retrying after API error..." };
149
+ await this.teardown();
150
+ await sleep(this.retryDelays[this.retryCount - 1] ?? 8_000);
151
+ retry = true;
152
+ break;
153
+ }
154
+ yield ev;
155
+ if (ev.type === "result" || ev.type === "error") {
156
+ this.retryCount = 0;
157
+ return;
158
+ }
159
+ }
160
+ if (retry) break; // restart the outer loop: startQuery() resumes the same session id
161
+ }
162
+ }
163
+ }
164
+
165
+ abort(reason: string): void {
166
+ this.aborted = reason;
167
+ this.handle?.close();
168
+ }
169
+
170
+ private async teardown(): Promise<void> {
171
+ this.stream?.end();
172
+ this.handle?.close();
173
+ this.stream = null;
174
+ this.handle = null;
175
+ this.iterator = null;
176
+ }
177
+
178
+ async close(): Promise<void> {
179
+ await this.teardown();
180
+ }
181
+ }
@@ -0,0 +1,76 @@
1
+ import type { AgentEvent, Normalizer } from "../types";
2
+ import { truncate } from "../../utils/format-activity";
3
+
4
+ /**
5
+ * Pure reducer: Codex `codex exec --json` JSONL events → normalized `AgentEvent`s.
6
+ *
7
+ * Codex is batch (no token streaming): the assistant message arrives whole in a
8
+ * single `item.completed`/`agent_message`, and `turn.completed` carries token
9
+ * 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).
13
+ */
14
+ export class CodexNormalizer implements Normalizer {
15
+ private threadId = "";
16
+ private agentText = "";
17
+
18
+ get backendSessionId(): string {
19
+ return this.threadId;
20
+ }
21
+
22
+ consume(message: unknown): AgentEvent[] {
23
+ const e = message as any;
24
+ switch (e.type) {
25
+ case "thread.started":
26
+ this.threadId = e.thread_id ?? "";
27
+ return this.threadId ? [{ type: "session", backendSessionId: this.threadId }] : [];
28
+ case "item.started":
29
+ case "item.completed":
30
+ return this.consumeItem(e.type === "item.completed", e.item);
31
+ case "turn.completed":
32
+ return [
33
+ {
34
+ type: "result",
35
+ text: this.agentText,
36
+ usage: {
37
+ tokens: {
38
+ input: e.usage?.input_tokens ?? 0,
39
+ output: e.usage?.output_tokens ?? 0,
40
+ },
41
+ },
42
+ backendSessionId: this.threadId,
43
+ },
44
+ ];
45
+ default:
46
+ return [];
47
+ }
48
+ }
49
+
50
+ private consumeItem(completed: boolean, item: any): AgentEvent[] {
51
+ if (!item) return [];
52
+ switch (item.type) {
53
+ case "command_execution":
54
+ // Surface the command as activity once, when it starts.
55
+ if (!completed && item.command)
56
+ return [{ type: "tool", name: "command", summary: truncate(String(item.command), 70) }];
57
+ return [];
58
+ case "mcp_tool_call": {
59
+ if (completed) return [];
60
+ const name = item.server ? `${item.server}.${item.tool ?? "tool"}` : item.tool || "mcp";
61
+ return [{ type: "tool", name, summary: item.tool }];
62
+ }
63
+ case "reasoning":
64
+ if (completed && item.text) return [{ type: "thinking", delta: truncate(String(item.text), 70) }];
65
+ return [];
66
+ case "agent_message":
67
+ if (completed && typeof item.text === "string") {
68
+ this.agentText = item.text;
69
+ return [{ type: "text", delta: item.text }];
70
+ }
71
+ return [];
72
+ default:
73
+ return [];
74
+ }
75
+ }
76
+ }
@@ -0,0 +1,208 @@
1
+ import { existsSync, readdirSync } from "fs";
2
+ import { homedir } from "os";
3
+ import { join, dirname } from "path";
4
+ import type { AgentBackend, AgentSession, AgentSessionContext, AgentEvent } from "../types";
5
+ import type { Attachment } from "../../types/attachment";
6
+ import type { McpSourceContext } from "../../mcp";
7
+ import { CodexNormalizer } from "./codex-normalize";
8
+ import { mintRun, revokeRun } from "../mcp-endpoint";
9
+
10
+ /**
11
+ * Resolve the codex binary's absolute path. The daemon runs under launchd with a
12
+ * minimal PATH (`/usr/bin:/bin:...`) that excludes nvm/homebrew bins, so a bare
13
+ * `codex` spawn would fail. Search the likely install locations (env override,
14
+ * the runtime's own bin, homebrew, every nvm node bin, bun) and fall back to
15
+ * PATH only as a last resort. Cached after first resolution.
16
+ */
17
+ let cachedCodexBin: string | null = null;
18
+ export function resolveCodexBin(): string {
19
+ if (cachedCodexBin) return cachedCodexBin;
20
+ const candidates: string[] = [];
21
+ if (process.env.CODEX_PATH) candidates.push(process.env.CODEX_PATH);
22
+ candidates.push(join(dirname(process.execPath), "codex")); // sibling of bun/node
23
+ candidates.push("/opt/homebrew/bin/codex", "/usr/local/bin/codex");
24
+ try {
25
+ const nvm = join(homedir(), ".nvm", "versions", "node");
26
+ for (const v of readdirSync(nvm)) candidates.push(join(nvm, v, "bin", "codex"));
27
+ } catch {
28
+ /* no nvm */
29
+ }
30
+ candidates.push(join(homedir(), ".bun", "bin", "codex"));
31
+ cachedCodexBin = candidates.find((p) => existsSync(p)) ?? "codex";
32
+ return cachedCodexBin;
33
+ }
34
+
35
+ /** Minimal spawned-process surface, injectable so the session is unit-testable. */
36
+ export interface CliProc {
37
+ stdout: ReadableStream<Uint8Array>;
38
+ stderr: ReadableStream<Uint8Array>;
39
+ exited: Promise<number>;
40
+ kill(): void;
41
+ }
42
+ export type SpawnFn = (args: string[], opts: { cwd: string; env: Record<string, string> }) => CliProc;
43
+
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",
53
+ ]);
54
+
55
+ function scrubbedEnv(extra: Record<string, string>): Record<string, string> {
56
+ const env: Record<string, string> = {};
57
+ for (const [k, v] of Object.entries(process.env)) {
58
+ if (!SCRUB.has(k) && v != null) env[k] = v;
59
+ }
60
+ return { ...env, ...extra };
61
+ }
62
+
63
+ function defaultSpawn(args: string[], opts: { cwd: string; env: Record<string, string> }): CliProc {
64
+ const proc = Bun.spawn([resolveCodexBin(), ...args], {
65
+ cwd: opts.cwd,
66
+ env: opts.env,
67
+ stdout: "pipe",
68
+ stderr: "pipe",
69
+ });
70
+ return {
71
+ stdout: proc.stdout as ReadableStream<Uint8Array>,
72
+ stderr: proc.stderr as ReadableStream<Uint8Array>,
73
+ exited: proc.exited,
74
+ kill: () => proc.kill(),
75
+ };
76
+ }
77
+
78
+ async function* readLines(stream: ReadableStream<Uint8Array>): AsyncGenerator<string> {
79
+ const reader = stream.getReader();
80
+ const decoder = new TextDecoder();
81
+ let buf = "";
82
+ while (true) {
83
+ const { value, done } = await reader.read();
84
+ if (done) break;
85
+ buf += decoder.decode(value, { stream: true });
86
+ let idx: number;
87
+ while ((idx = buf.indexOf("\n")) >= 0) {
88
+ yield buf.slice(0, idx);
89
+ buf = buf.slice(idx + 1);
90
+ }
91
+ }
92
+ if (buf.trim()) yield buf;
93
+ }
94
+
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;
105
+ }
106
+
107
+ export class CodexBackend implements AgentBackend {
108
+ readonly name = "codex" as const;
109
+ private spawnFn: SpawnFn;
110
+
111
+ constructor(deps?: { spawnFn?: SpawnFn }) {
112
+ this.spawnFn = deps?.spawnFn ?? defaultSpawn;
113
+ }
114
+
115
+ async openSession(ctx: AgentSessionContext): Promise<AgentSession> {
116
+ return new CodexSession(ctx, this.spawnFn);
117
+ }
118
+
119
+ async canResume(): Promise<boolean> {
120
+ // v1: no thread resume; failover/continuity replays history from Nia's DB.
121
+ return false;
122
+ }
123
+ }
124
+
125
+ class CodexSession implements AgentSession {
126
+ private _sessionId: string | null = null;
127
+ private aborted: string | null = null;
128
+ private proc: CliProc | null = null;
129
+
130
+ constructor(
131
+ private ctx: AgentSessionContext,
132
+ private spawnFn: SpawnFn,
133
+ ) {}
134
+
135
+ get backendSessionId(): string | null {
136
+ return this._sessionId;
137
+ }
138
+
139
+ async *send(text: string, _attachments?: Attachment[]): AsyncIterable<AgentEvent> {
140
+ const source: McpSourceContext = this.ctx.source ?? { channel: this.ctx.channel, room: this.ctx.room };
141
+ const { url, token } = await mintRun(source);
142
+
143
+ const fullPrompt = `${this.ctx.systemPrompt}\n\n---\n\n${text}`;
144
+ const args = [
145
+ "exec",
146
+ fullPrompt,
147
+ "--json",
148
+ "--skip-git-repo-check",
149
+ "--dangerously-bypass-approvals-and-sandbox",
150
+ "-C",
151
+ this.ctx.cwd,
152
+ "-c",
153
+ `mcp_servers.nia.url="${url}"`,
154
+ "-c",
155
+ `mcp_servers.nia.bearer_token_env_var="NIA_MCP_TOKEN"`,
156
+ ];
157
+ if (this.ctx.model && this.ctx.model !== "default") args.push("-m", this.ctx.model);
158
+
159
+ const proc = this.spawnFn(args, { cwd: this.ctx.cwd, env: scrubbedEnv({ NIA_MCP_TOKEN: token }) });
160
+ this.proc = proc;
161
+
162
+ const normalizer = new CodexNormalizer();
163
+ let sawResult = false;
164
+ try {
165
+ for await (const line of readLines(proc.stdout)) {
166
+ if (this.aborted) throw new Error(this.aborted);
167
+ const trimmed = line.trim();
168
+ if (!trimmed) continue;
169
+ let parsed: unknown;
170
+ try {
171
+ parsed = JSON.parse(trimmed);
172
+ } catch {
173
+ continue;
174
+ }
175
+ for (const ev of normalizer.consume(parsed)) {
176
+ if (ev.type === "session" || ev.type === "result") {
177
+ this._sessionId = ev.backendSessionId || this._sessionId;
178
+ }
179
+ if (ev.type === "result") sawResult = true;
180
+ yield ev;
181
+ }
182
+ }
183
+ const exit = await proc.exited;
184
+ if (this.aborted) throw new Error(this.aborted);
185
+ if (exit !== 0 && !sawResult) {
186
+ const stderr = await readAll(proc.stderr);
187
+ yield {
188
+ type: "error",
189
+ message: stderr.trim() || `codex exited ${exit}`,
190
+ retryable: false,
191
+ providerDown: false,
192
+ };
193
+ }
194
+ } finally {
195
+ revokeRun(token);
196
+ this.proc = null;
197
+ }
198
+ }
199
+
200
+ abort(reason: string): void {
201
+ this.aborted = reason;
202
+ this.proc?.kill();
203
+ }
204
+
205
+ async close(): Promise<void> {
206
+ // codex exec is one-shot per send; nothing persistent to tear down.
207
+ }
208
+ }
@@ -0,0 +1,12 @@
1
+ export type {
2
+ AgentBackend,
3
+ AgentSession,
4
+ AgentSessionContext,
5
+ AgentEvent,
6
+ AgentUsage,
7
+ AgentDef,
8
+ Normalizer,
9
+ } from "./types";
10
+ export { isResultEvent } from "./types";
11
+ export { getBackend, setBackend, setBackendChain, resolveBackends } from "./registry";
12
+ export { resolveSdkModel } from "./backends/claude";