@rynx-ai/runtime 0.1.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.
Files changed (69) hide show
  1. package/dist/claude/executor.d.ts +17 -0
  2. package/dist/claude/executor.js +28 -0
  3. package/dist/claude/models.d.ts +10 -0
  4. package/dist/claude/models.js +33 -0
  5. package/dist/claude/native-bridge.d.ts +133 -0
  6. package/dist/claude/native-bridge.js +299 -0
  7. package/dist/claude/native-hook-main.d.ts +2 -0
  8. package/dist/claude/native-hook-main.js +74 -0
  9. package/dist/claude/native-hooks.d.ts +41 -0
  10. package/dist/claude/native-hooks.js +73 -0
  11. package/dist/claude/native-integration.d.ts +213 -0
  12. package/dist/claude/native-integration.js +665 -0
  13. package/dist/claude/native-message-display-main.d.ts +2 -0
  14. package/dist/claude/native-message-display-main.js +51 -0
  15. package/dist/claude/native-status-main.d.ts +2 -0
  16. package/dist/claude/native-status-main.js +105 -0
  17. package/dist/claude/status.d.ts +23 -0
  18. package/dist/claude/status.js +118 -0
  19. package/dist/claude/transcript.d.ts +79 -0
  20. package/dist/claude/transcript.js +272 -0
  21. package/dist/claude/trust.d.ts +6 -0
  22. package/dist/claude/trust.js +85 -0
  23. package/dist/codex/rollout-synth.d.ts +37 -0
  24. package/dist/codex/rollout-synth.js +212 -0
  25. package/dist/codex-app-server/client.d.ts +138 -0
  26. package/dist/codex-app-server/client.js +341 -0
  27. package/dist/codex-app-server/forwarder.d.ts +92 -0
  28. package/dist/codex-app-server/forwarder.js +188 -0
  29. package/dist/codex-app-server/mapping.d.ts +19 -0
  30. package/dist/codex-app-server/mapping.js +189 -0
  31. package/dist/codex-app-server/protocol.d.ts +472 -0
  32. package/dist/codex-app-server/protocol.js +12 -0
  33. package/dist/codex-app-server/transport.d.ts +139 -0
  34. package/dist/codex-app-server/transport.js +422 -0
  35. package/dist/codex-app-server/ws-channel.d.ts +72 -0
  36. package/dist/codex-app-server/ws-channel.js +233 -0
  37. package/dist/codex-child-env.d.ts +1 -0
  38. package/dist/codex-child-env.js +27 -0
  39. package/dist/codex-home.d.ts +47 -0
  40. package/dist/codex-home.js +135 -0
  41. package/dist/codex-session-store.d.ts +42 -0
  42. package/dist/codex-session-store.js +126 -0
  43. package/dist/host.d.ts +324 -0
  44. package/dist/host.js +1323 -0
  45. package/dist/index.d.ts +18 -0
  46. package/dist/index.js +17 -0
  47. package/dist/models-catalog.d.ts +18 -0
  48. package/dist/models-catalog.js +27 -0
  49. package/dist/runner/child.d.ts +58 -0
  50. package/dist/runner/child.js +268 -0
  51. package/dist/runner/manager.d.ts +175 -0
  52. package/dist/runner/manager.js +458 -0
  53. package/dist/runner/protocol.d.ts +195 -0
  54. package/dist/runner/protocol.js +41 -0
  55. package/dist/runner/transport.d.ts +36 -0
  56. package/dist/runner/transport.js +72 -0
  57. package/dist/runner-main.d.ts +2 -0
  58. package/dist/runner-main.js +61 -0
  59. package/dist/runtime-status.d.ts +16 -0
  60. package/dist/runtime-status.js +80 -0
  61. package/dist/terminal/claude-tui.d.ts +27 -0
  62. package/dist/terminal/claude-tui.js +13 -0
  63. package/dist/terminal/codex-tui.d.ts +54 -0
  64. package/dist/terminal/codex-tui.js +26 -0
  65. package/dist/terminal/registry.d.ts +42 -0
  66. package/dist/terminal/registry.js +70 -0
  67. package/dist/terminal/tmux.d.ts +150 -0
  68. package/dist/terminal/tmux.js +364 -0
  69. package/package.json +32 -0
@@ -0,0 +1,233 @@
1
+ /**
2
+ * WebSocket {@link RpcChannel} for a codex `app-server --listen ws://IP:PORT`.
3
+ *
4
+ * Why a WebSocket and not the default stdio: stdio admits exactly one client
5
+ * (the process that spawned it), so it cannot be co-driven. omnigent's working
6
+ * codex-native uses a multi-client transport (ws / uds) so a separate `codex
7
+ * --remote` TUI can attach to the SAME app-server and resume the SAME thread.
8
+ * This channel owns that app-server child on a loopback ws port and connects a
9
+ * client to it; the TUI attaches to {@link WsRpcChannel.url}.
10
+ *
11
+ * Framing matches codex (verified from omnigent): one JSON-RPC object per
12
+ * WebSocket text frame — no newline delimiting. So `send` writes one frame per
13
+ * message and every inbound frame is one complete JSON object.
14
+ */
15
+ import { spawn } from "node:child_process";
16
+ import { createServer } from "node:net";
17
+ import { WebSocket } from "ws";
18
+ import { createCodexChildEnv } from "../codex-child-env.js";
19
+ /** Reserve a free loopback TCP port (best-effort; racy but fine for local use). */
20
+ async function freeLoopbackPort() {
21
+ return await new Promise((resolve, reject) => {
22
+ const srv = createServer();
23
+ srv.on("error", reject);
24
+ srv.listen(0, "127.0.0.1", () => {
25
+ const addr = srv.address();
26
+ const port = typeof addr === "object" && addr ? addr.port : 0;
27
+ srv.close(() => (port ? resolve(port) : reject(new Error("no port"))));
28
+ });
29
+ });
30
+ }
31
+ export class WsRpcChannel {
32
+ opts;
33
+ child = null;
34
+ ws = null;
35
+ lineCb = null;
36
+ closeCb = null;
37
+ closedEmitted = false;
38
+ readyTimeoutMs;
39
+ /** The `ws://IP:PORT` the app-server listens on — pass to the TUI's `--remote`. */
40
+ url = "";
41
+ constructor(opts) {
42
+ this.opts = opts;
43
+ this.readyTimeoutMs = opts.readyTimeoutMs ?? 15_000;
44
+ }
45
+ onLine(cb) {
46
+ this.lineCb = cb;
47
+ }
48
+ onClose(cb) {
49
+ this.closeCb = cb;
50
+ }
51
+ isOpen() {
52
+ return this.ws?.readyState === WebSocket.OPEN;
53
+ }
54
+ async start() {
55
+ const port = await freeLoopbackPort();
56
+ this.url = `ws://127.0.0.1:${port}`;
57
+ const args = [...this.opts.baseArgs, "--listen", this.url];
58
+ this.child = spawn(this.opts.cliPath, args, {
59
+ stdio: ["ignore", "ignore", "pipe"],
60
+ env: { ...createCodexChildEnv(process.env), ...(this.opts.extraEnv ?? {}) },
61
+ });
62
+ this.child.on("exit", (code, signal) => this.emitClose(code, signal, null));
63
+ this.child.on("error", (error) => this.emitClose(null, null, error));
64
+ this.ws = await this.connectWithRetry(this.url);
65
+ this.ws.on("message", (data) => {
66
+ // One JSON-RPC object per frame (codex ws framing).
67
+ this.lineCb?.(data.toString());
68
+ });
69
+ this.ws.on("close", (code) => this.emitClose(code ?? null, null, null));
70
+ this.ws.on("error", (error) => this.emitClose(null, null, error));
71
+ }
72
+ send(line) {
73
+ if (this.ws?.readyState !== WebSocket.OPEN) {
74
+ throw new Error("codex ws channel is not open");
75
+ }
76
+ // Codex expects one JSON object per frame; drop the NDJSON newline.
77
+ this.ws.send(line.endsWith("\n") ? line.slice(0, -1) : line);
78
+ }
79
+ async stop(signal = "SIGTERM") {
80
+ try {
81
+ this.ws?.close();
82
+ }
83
+ catch {
84
+ /* already closing */
85
+ }
86
+ const child = this.child;
87
+ if (child) {
88
+ try {
89
+ child.kill(signal);
90
+ }
91
+ catch {
92
+ /* already gone */
93
+ }
94
+ }
95
+ }
96
+ emitClose(code, signal, error) {
97
+ if (this.closedEmitted)
98
+ return;
99
+ this.closedEmitted = true;
100
+ this.closeCb?.({ code, signal, error });
101
+ }
102
+ async connectWithRetry(url) {
103
+ const deadline = Date.now() + this.readyTimeoutMs;
104
+ let lastError;
105
+ // The app-server needs a moment to bind; retry the connect until it accepts.
106
+ // eslint-disable-next-line no-constant-condition
107
+ while (Date.now() < deadline) {
108
+ try {
109
+ return await this.tryConnect(url);
110
+ }
111
+ catch (error) {
112
+ lastError = error;
113
+ await new Promise((r) => setTimeout(r, 150));
114
+ }
115
+ }
116
+ throw new Error(`codex app-server ws did not become ready at ${url}: ${lastError?.message ?? "timeout"}`);
117
+ }
118
+ tryConnect(url) {
119
+ return new Promise((resolve, reject) => {
120
+ const ws = new WebSocket(url);
121
+ const onOpen = () => {
122
+ ws.off("error", onError);
123
+ resolve(ws);
124
+ };
125
+ const onError = (error) => {
126
+ ws.off("open", onOpen);
127
+ try {
128
+ ws.close();
129
+ }
130
+ catch {
131
+ /* noop */
132
+ }
133
+ reject(error);
134
+ };
135
+ ws.once("open", onOpen);
136
+ ws.once("error", onError);
137
+ });
138
+ }
139
+ }
140
+ /**
141
+ * Connect-only {@link RpcChannel}: attaches an ADDITIONAL client to an app-server
142
+ * someone else already started (a {@link WsRpcChannel}'s `url`) — no spawn. This
143
+ * is how rynx runs omnigent's multi-connection codex-native model: the backend
144
+ * client owns the app-server + drives injection, while a SEPARATE forwarder
145
+ * connection `thread/resume`s the same thread to subscribe to its item/turn
146
+ * notifications (verified: codex delivers a thread's items to every connection
147
+ * subscribed to it, so the forwarder connection sees turns started by the TUI,
148
+ * the backend inject client, and anyone else).
149
+ */
150
+ export class ExternalWsChannel {
151
+ url;
152
+ ws = null;
153
+ lineCb = null;
154
+ closeCb = null;
155
+ closedEmitted = false;
156
+ readyTimeoutMs;
157
+ constructor(
158
+ /** The `ws://IP:PORT` of the already-running app-server to attach to. */
159
+ url, opts = {}) {
160
+ this.url = url;
161
+ this.readyTimeoutMs = opts.readyTimeoutMs ?? 15_000;
162
+ }
163
+ onLine(cb) {
164
+ this.lineCb = cb;
165
+ }
166
+ onClose(cb) {
167
+ this.closeCb = cb;
168
+ }
169
+ isOpen() {
170
+ return this.ws?.readyState === WebSocket.OPEN;
171
+ }
172
+ async start() {
173
+ const deadline = Date.now() + this.readyTimeoutMs;
174
+ let lastError;
175
+ while (Date.now() < deadline) {
176
+ try {
177
+ this.ws = await this.connect(this.url);
178
+ break;
179
+ }
180
+ catch (error) {
181
+ lastError = error;
182
+ await new Promise((r) => setTimeout(r, 150));
183
+ }
184
+ }
185
+ if (!this.ws) {
186
+ throw new Error(`could not attach to app-server ws ${this.url}: ${lastError?.message ?? "timeout"}`);
187
+ }
188
+ this.ws.on("message", (data) => this.lineCb?.(data.toString()));
189
+ this.ws.on("close", (code) => this.emitClose(code ?? null, null, null));
190
+ this.ws.on("error", (error) => this.emitClose(null, null, error));
191
+ }
192
+ send(line) {
193
+ if (this.ws?.readyState !== WebSocket.OPEN) {
194
+ throw new Error("external codex ws channel is not open");
195
+ }
196
+ this.ws.send(line.endsWith("\n") ? line.slice(0, -1) : line);
197
+ }
198
+ async stop() {
199
+ try {
200
+ this.ws?.close();
201
+ }
202
+ catch {
203
+ /* already closing */
204
+ }
205
+ }
206
+ emitClose(code, signal, error) {
207
+ if (this.closedEmitted)
208
+ return;
209
+ this.closedEmitted = true;
210
+ this.closeCb?.({ code, signal, error });
211
+ }
212
+ connect(url) {
213
+ return new Promise((resolve, reject) => {
214
+ const ws = new WebSocket(url);
215
+ const onOpen = () => {
216
+ ws.off("error", onError);
217
+ resolve(ws);
218
+ };
219
+ const onError = (error) => {
220
+ ws.off("open", onOpen);
221
+ try {
222
+ ws.close();
223
+ }
224
+ catch {
225
+ /* noop */
226
+ }
227
+ reject(error);
228
+ };
229
+ ws.once("open", onOpen);
230
+ ws.once("error", onError);
231
+ });
232
+ }
233
+ }
@@ -0,0 +1 @@
1
+ export declare function createCodexChildEnv(env: NodeJS.ProcessEnv): NodeJS.ProcessEnv;
@@ -0,0 +1,27 @@
1
+ const CODEX_CHILD_ENV_ALLOWLIST = [
2
+ "PATH",
3
+ "HOME",
4
+ "USER",
5
+ "SHELL",
6
+ "TMPDIR",
7
+ "CODEX_HOME",
8
+ "TRAE_HOME",
9
+ "HTTP_PROXY",
10
+ "HTTPS_PROXY",
11
+ "ALL_PROXY",
12
+ "NO_PROXY",
13
+ "http_proxy",
14
+ "https_proxy",
15
+ "all_proxy",
16
+ "no_proxy",
17
+ ];
18
+ export function createCodexChildEnv(env) {
19
+ const childEnv = {};
20
+ for (const key of CODEX_CHILD_ENV_ALLOWLIST) {
21
+ const value = env[key];
22
+ if (typeof value === "string" && value.length > 0) {
23
+ childEnv[key] = value;
24
+ }
25
+ }
26
+ return childEnv;
27
+ }
@@ -0,0 +1,47 @@
1
+ /**
2
+ * The deterministic private CODEX_HOME path for a rynx session. PER-SESSION
3
+ * (uid-scoped + `sha256(sessionId)[:32]`), mirroring omnigent's per-session
4
+ * `bridge_dir/codex-home` and rynx's own `claudeBridgeDir`. Pure — computes the
5
+ * path without touching disk, so the daemon (rollout-synth) and the runner child
6
+ * (app-server) both locate the SAME session's home from `sessionId` alone.
7
+ */
8
+ export declare function codexHomePath(sessionId: string): string;
9
+ /** The OLD uid-scoped shared home (pre per-session). Kept ONLY for back-compat
10
+ * resume fallback — a session's rollout may still live under here. */
11
+ export declare function legacyCodexHomePath(): string;
12
+ /**
13
+ * Prepare a private CODEX_HOME that inherits the user's login (symlinked
14
+ * `auth.json`) and settings (copied `config.toml`) but NOT the real home's pending
15
+ * update / first-run (NUX) state — so a co-driven `codex` app-server + `--remote`
16
+ * TUI never block on an "Update now / Press enter to continue" prompt (which would
17
+ * wedge terminal injection). Ports omnigent's `_CODEX_HOME_SYMLINK_FILES` /
18
+ * `_CODEX_HOME_COPY_FILES`.
19
+ *
20
+ * PER-SESSION: one private home per rynx session (matching omnigent), so concurrent
21
+ * agents never share a `skills/` dir. Idempotent: the symlink/copy are refreshed each
22
+ * call so a re-login/config change propagates. Returns the private home dir.
23
+ */
24
+ export declare function prepareCodexHome(sessionId: string, realHome?: string): string;
25
+ /**
26
+ * Link a resolved skill set into `<codexHome>/skills/<name>/` so the native Codex
27
+ * discovers them at `$CODEX_HOME/skills/` — the SAME filesystem mechanism omnigent
28
+ * uses (`populate_codex_skills_from_bundle` → `_populate_codex_skills`), NOT a
29
+ * `<skills_instructions>` block injected into the prompt. Codex's app-server
30
+ * watches this dir and re-scans on change, so a live `codex --remote` TUI — whose
31
+ * thread rynx never creates, so it can't carry `threadStart.developerInstructions`
32
+ * — still sees the agent's skills.
33
+ *
34
+ * Each skill is a symlink to its source dir; a filesystem without symlink support
35
+ * falls back to a recursive copy (matches omnigent's fallback).
36
+ *
37
+ * omnigent boots a fresh per-session CODEX_HOME, so it only ever links into an
38
+ * empty dir. rynx shares ONE private home per runtime (see {@link prepareCodexHome}),
39
+ * so this CONVERGES the dir to `skills`: it links the missing ones and removes
40
+ * entries no longer selected, honouring the agent spec's gating. (Concurrent
41
+ * sessions of DIFFERENT agents on one runtime share this dir — the same shared-home
42
+ * tradeoff already accepted for auth/config.)
43
+ */
44
+ export declare function populateCodexSkills(codexHome: string, skills: {
45
+ name: string;
46
+ dir: string;
47
+ }[]): void;
@@ -0,0 +1,135 @@
1
+ import { copyFileSync, cpSync, existsSync, mkdirSync, readdirSync, rmSync, symlinkSync } from "node:fs";
2
+ import { createHash } from "node:crypto";
3
+ import { homedir, tmpdir } from "node:os";
4
+ import { join } from "node:path";
5
+ /** Inherit the user's LIVE login by symlink (stays in sync). */
6
+ const SYMLINK_FILES = ["auth.json"];
7
+ /** Inherit the user's settings by snapshot copy (not the mutable NUX/update state). */
8
+ const COPY_FILES = ["config.toml"];
9
+ /** The user's real CODEX_HOME (env override, else `~/.codex`). */
10
+ function realCodexHome() {
11
+ return process.env.CODEX_HOME?.trim() || join(homedir(), ".codex");
12
+ }
13
+ /** Uid-scoped root so other users on a shared host can't read the tree. */
14
+ function rynxUidRoot() {
15
+ const uid = typeof process.getuid === "function" ? process.getuid() : "nouid";
16
+ return join(tmpdir(), `rynx-${uid}`);
17
+ }
18
+ /**
19
+ * The deterministic private CODEX_HOME path for a rynx session. PER-SESSION
20
+ * (uid-scoped + `sha256(sessionId)[:32]`), mirroring omnigent's per-session
21
+ * `bridge_dir/codex-home` and rynx's own `claudeBridgeDir`. Pure — computes the
22
+ * path without touching disk, so the daemon (rollout-synth) and the runner child
23
+ * (app-server) both locate the SAME session's home from `sessionId` alone.
24
+ */
25
+ export function codexHomePath(sessionId) {
26
+ const digest = createHash("sha256").update(sessionId).digest("hex").slice(0, 32);
27
+ return join(rynxUidRoot(), "codex-native", digest, "codex-home");
28
+ }
29
+ /** The OLD uid-scoped shared home (pre per-session). Kept ONLY for back-compat
30
+ * resume fallback — a session's rollout may still live under here. */
31
+ export function legacyCodexHomePath() {
32
+ return join(rynxUidRoot(), "codex-home");
33
+ }
34
+ /**
35
+ * Prepare a private CODEX_HOME that inherits the user's login (symlinked
36
+ * `auth.json`) and settings (copied `config.toml`) but NOT the real home's pending
37
+ * update / first-run (NUX) state — so a co-driven `codex` app-server + `--remote`
38
+ * TUI never block on an "Update now / Press enter to continue" prompt (which would
39
+ * wedge terminal injection). Ports omnigent's `_CODEX_HOME_SYMLINK_FILES` /
40
+ * `_CODEX_HOME_COPY_FILES`.
41
+ *
42
+ * PER-SESSION: one private home per rynx session (matching omnigent), so concurrent
43
+ * agents never share a `skills/` dir. Idempotent: the symlink/copy are refreshed each
44
+ * call so a re-login/config change propagates. Returns the private home dir.
45
+ */
46
+ export function prepareCodexHome(sessionId, realHome = realCodexHome()) {
47
+ const dir = codexHomePath(sessionId);
48
+ mkdirSync(dir, { recursive: true, mode: 0o700 });
49
+ for (const name of SYMLINK_FILES) {
50
+ const src = join(realHome, name);
51
+ const dst = join(dir, name);
52
+ try {
53
+ rmSync(dst, { force: true });
54
+ }
55
+ catch {
56
+ // absent — fine
57
+ }
58
+ if (existsSync(src)) {
59
+ try {
60
+ symlinkSync(src, dst);
61
+ }
62
+ catch {
63
+ // best-effort: a missing link just means codex re-prompts login (not worse than today)
64
+ }
65
+ }
66
+ }
67
+ for (const name of COPY_FILES) {
68
+ const src = join(realHome, name);
69
+ if (existsSync(src)) {
70
+ try {
71
+ copyFileSync(src, join(dir, name));
72
+ }
73
+ catch {
74
+ // best-effort
75
+ }
76
+ }
77
+ }
78
+ return dir;
79
+ }
80
+ /**
81
+ * Link a resolved skill set into `<codexHome>/skills/<name>/` so the native Codex
82
+ * discovers them at `$CODEX_HOME/skills/` — the SAME filesystem mechanism omnigent
83
+ * uses (`populate_codex_skills_from_bundle` → `_populate_codex_skills`), NOT a
84
+ * `<skills_instructions>` block injected into the prompt. Codex's app-server
85
+ * watches this dir and re-scans on change, so a live `codex --remote` TUI — whose
86
+ * thread rynx never creates, so it can't carry `threadStart.developerInstructions`
87
+ * — still sees the agent's skills.
88
+ *
89
+ * Each skill is a symlink to its source dir; a filesystem without symlink support
90
+ * falls back to a recursive copy (matches omnigent's fallback).
91
+ *
92
+ * omnigent boots a fresh per-session CODEX_HOME, so it only ever links into an
93
+ * empty dir. rynx shares ONE private home per runtime (see {@link prepareCodexHome}),
94
+ * so this CONVERGES the dir to `skills`: it links the missing ones and removes
95
+ * entries no longer selected, honouring the agent spec's gating. (Concurrent
96
+ * sessions of DIFFERENT agents on one runtime share this dir — the same shared-home
97
+ * tradeoff already accepted for auth/config.)
98
+ */
99
+ export function populateCodexSkills(codexHome, skills) {
100
+ const skillsDir = join(codexHome, "skills");
101
+ const want = new Map(skills.map((s) => [s.name, s.dir]));
102
+ // Converge: drop entries no longer selected (shared-home adaptation; omnigent's
103
+ // per-session home never needs this). `.system` holds codex's own embedded system
104
+ // skills (the app-server installs them into `$CODEX_HOME/skills/.system`) — never
105
+ // rynx-managed, so leave it untouched; only converge the entries we linked.
106
+ if (existsSync(skillsDir)) {
107
+ for (const name of readdirSync(skillsDir)) {
108
+ if (name === ".system")
109
+ continue;
110
+ if (!want.has(name))
111
+ rmSync(join(skillsDir, name), { recursive: true, force: true });
112
+ }
113
+ }
114
+ if (want.size === 0)
115
+ return;
116
+ mkdirSync(skillsDir, { recursive: true });
117
+ for (const [name, src] of want) {
118
+ const link = join(skillsDir, name);
119
+ if (existsSync(link))
120
+ continue; // already linked (a skill's dir for a name is stable)
121
+ try {
122
+ symlinkSync(src, link);
123
+ }
124
+ catch {
125
+ // no symlink support (e.g. some Windows configs) → copy. A skill-link failure
126
+ // must not break the terminal launch, so swallow a copy failure too.
127
+ try {
128
+ cpSync(src, link, { recursive: true });
129
+ }
130
+ catch {
131
+ // best-effort
132
+ }
133
+ }
134
+ }
135
+ }
@@ -0,0 +1,42 @@
1
+ import { type AgentRuntimeId } from "@rynx-ai/core";
2
+ import type { AppConfig } from "@rynx-ai/core";
3
+ export interface CodexSessionRecord {
4
+ localThreadId: string;
5
+ codexSessionId: string;
6
+ cwd: string;
7
+ model: string;
8
+ /** Runtime this thread is bound to. Legacy records backfill to `codex`. */
9
+ runtime: AgentRuntimeId;
10
+ /** Declarative agent spec name bound to this thread, if any. */
11
+ agent?: string;
12
+ /** The source rynx session this one was derived from (claude `/fork`). */
13
+ parentSessionId?: string;
14
+ updatedAt: string;
15
+ }
16
+ export interface CodexSessionStore {
17
+ readonly filePath: string;
18
+ get(localThreadId: string): Promise<CodexSessionRecord | null>;
19
+ set(record: CodexSessionRecord): Promise<void>;
20
+ delete(localThreadId: string): Promise<void>;
21
+ isWritable(): Promise<boolean>;
22
+ /** All stored records (newest-first not guaranteed). Used by the CLI mirror
23
+ * to enumerate conversations bound to a Codex thread. */
24
+ listAll(): Promise<CodexSessionRecord[]>;
25
+ /** Reverse lookup by Codex thread id (the rollout-file UUID). */
26
+ findByCodexSessionId(codexSessionId: string): Promise<CodexSessionRecord | null>;
27
+ }
28
+ export declare class FileCodexSessionStore implements CodexSessionStore {
29
+ readonly filePath: string;
30
+ private tail;
31
+ constructor(filePath: string);
32
+ get(localThreadId: string): Promise<CodexSessionRecord | null>;
33
+ set(record: CodexSessionRecord): Promise<void>;
34
+ delete(localThreadId: string): Promise<void>;
35
+ listAll(): Promise<CodexSessionRecord[]>;
36
+ findByCodexSessionId(codexSessionId: string): Promise<CodexSessionRecord | null>;
37
+ isWritable(): Promise<boolean>;
38
+ private readAll;
39
+ private writeAll;
40
+ private runExclusive;
41
+ }
42
+ export declare function resolveCodexSessionStorePath(config: AppConfig): string;
@@ -0,0 +1,126 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import { constants as fsConstants } from "node:fs";
3
+ import { access, mkdir, readFile, rename, stat, writeFile } from "node:fs/promises";
4
+ import path from "node:path";
5
+ import { normalizeRuntimeId } from "@rynx-ai/core";
6
+ export class FileCodexSessionStore {
7
+ filePath;
8
+ tail = Promise.resolve();
9
+ constructor(filePath) {
10
+ this.filePath = filePath;
11
+ }
12
+ async get(localThreadId) {
13
+ const data = await this.readAll();
14
+ return data.sessions?.[localThreadId] ?? null;
15
+ }
16
+ async set(record) {
17
+ await this.runExclusive(async () => {
18
+ const data = await this.readAll();
19
+ data.sessions ??= {};
20
+ data.sessions[record.localThreadId] = record;
21
+ await this.writeAll(data);
22
+ });
23
+ }
24
+ async delete(localThreadId) {
25
+ await this.runExclusive(async () => {
26
+ const data = await this.readAll();
27
+ if (!data.sessions?.[localThreadId]) {
28
+ return;
29
+ }
30
+ delete data.sessions[localThreadId];
31
+ await this.writeAll(data);
32
+ });
33
+ }
34
+ async listAll() {
35
+ const data = await this.readAll();
36
+ return Object.values(data.sessions ?? {});
37
+ }
38
+ async findByCodexSessionId(codexSessionId) {
39
+ const data = await this.readAll();
40
+ for (const record of Object.values(data.sessions ?? {})) {
41
+ if (record.codexSessionId === codexSessionId) {
42
+ return record;
43
+ }
44
+ }
45
+ return null;
46
+ }
47
+ async isWritable() {
48
+ try {
49
+ await mkdir(path.dirname(this.filePath), { recursive: true });
50
+ if (await pathExists(this.filePath)) {
51
+ await access(this.filePath, fsConstants.R_OK | fsConstants.W_OK);
52
+ }
53
+ else {
54
+ await access(path.dirname(this.filePath), fsConstants.R_OK | fsConstants.W_OK);
55
+ }
56
+ return true;
57
+ }
58
+ catch {
59
+ return false;
60
+ }
61
+ }
62
+ async readAll() {
63
+ try {
64
+ const content = await readFile(this.filePath, "utf8");
65
+ const parsed = JSON.parse(content);
66
+ if (!parsed || typeof parsed !== "object") {
67
+ return {};
68
+ }
69
+ // Backfill the runtime on legacy records written before multi-runtime
70
+ // support (default to codex), and normalize the renamed `traecli` runtime
71
+ // to `traex` so routing and thread-binding checks stay consistent.
72
+ if (parsed.sessions) {
73
+ for (const record of Object.values(parsed.sessions)) {
74
+ if (record) {
75
+ record.runtime = normalizeRuntimeId(record.runtime) ?? "codex";
76
+ }
77
+ }
78
+ }
79
+ return parsed;
80
+ }
81
+ catch (error) {
82
+ if (isMissingFileError(error)) {
83
+ return {};
84
+ }
85
+ throw error;
86
+ }
87
+ }
88
+ async writeAll(data) {
89
+ await mkdir(path.dirname(this.filePath), { recursive: true });
90
+ const tempPath = path.join(path.dirname(this.filePath), `.${path.basename(this.filePath)}.${process.pid}.${randomUUID()}.tmp`);
91
+ await writeFile(tempPath, `${JSON.stringify(data, null, 2)}\n`, "utf8");
92
+ await rename(tempPath, this.filePath);
93
+ }
94
+ async runExclusive(task) {
95
+ const previous = this.tail;
96
+ let release;
97
+ this.tail = new Promise((resolve) => {
98
+ release = resolve;
99
+ });
100
+ await previous.catch(() => undefined);
101
+ try {
102
+ return await task();
103
+ }
104
+ finally {
105
+ release();
106
+ }
107
+ }
108
+ }
109
+ export function resolveCodexSessionStorePath(config) {
110
+ return path.resolve(config.AGENT_SESSION_STORE_PATH ?? path.join(process.cwd(), ".codex-proxy", "sessions.json"));
111
+ }
112
+ async function pathExists(targetPath) {
113
+ try {
114
+ await stat(targetPath);
115
+ return true;
116
+ }
117
+ catch (error) {
118
+ if (isMissingFileError(error)) {
119
+ return false;
120
+ }
121
+ throw error;
122
+ }
123
+ }
124
+ function isMissingFileError(error) {
125
+ return Boolean(error && typeof error === "object" && "code" in error && error.code === "ENOENT");
126
+ }