@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.
- package/dist/claude/executor.d.ts +17 -0
- package/dist/claude/executor.js +28 -0
- package/dist/claude/models.d.ts +10 -0
- package/dist/claude/models.js +33 -0
- package/dist/claude/native-bridge.d.ts +133 -0
- package/dist/claude/native-bridge.js +299 -0
- package/dist/claude/native-hook-main.d.ts +2 -0
- package/dist/claude/native-hook-main.js +74 -0
- package/dist/claude/native-hooks.d.ts +41 -0
- package/dist/claude/native-hooks.js +73 -0
- package/dist/claude/native-integration.d.ts +213 -0
- package/dist/claude/native-integration.js +665 -0
- package/dist/claude/native-message-display-main.d.ts +2 -0
- package/dist/claude/native-message-display-main.js +51 -0
- package/dist/claude/native-status-main.d.ts +2 -0
- package/dist/claude/native-status-main.js +105 -0
- package/dist/claude/status.d.ts +23 -0
- package/dist/claude/status.js +118 -0
- package/dist/claude/transcript.d.ts +79 -0
- package/dist/claude/transcript.js +272 -0
- package/dist/claude/trust.d.ts +6 -0
- package/dist/claude/trust.js +85 -0
- package/dist/codex/rollout-synth.d.ts +37 -0
- package/dist/codex/rollout-synth.js +212 -0
- package/dist/codex-app-server/client.d.ts +138 -0
- package/dist/codex-app-server/client.js +341 -0
- package/dist/codex-app-server/forwarder.d.ts +92 -0
- package/dist/codex-app-server/forwarder.js +188 -0
- package/dist/codex-app-server/mapping.d.ts +19 -0
- package/dist/codex-app-server/mapping.js +189 -0
- package/dist/codex-app-server/protocol.d.ts +472 -0
- package/dist/codex-app-server/protocol.js +12 -0
- package/dist/codex-app-server/transport.d.ts +139 -0
- package/dist/codex-app-server/transport.js +422 -0
- package/dist/codex-app-server/ws-channel.d.ts +72 -0
- package/dist/codex-app-server/ws-channel.js +233 -0
- package/dist/codex-child-env.d.ts +1 -0
- package/dist/codex-child-env.js +27 -0
- package/dist/codex-home.d.ts +47 -0
- package/dist/codex-home.js +135 -0
- package/dist/codex-session-store.d.ts +42 -0
- package/dist/codex-session-store.js +126 -0
- package/dist/host.d.ts +324 -0
- package/dist/host.js +1323 -0
- package/dist/index.d.ts +18 -0
- package/dist/index.js +17 -0
- package/dist/models-catalog.d.ts +18 -0
- package/dist/models-catalog.js +27 -0
- package/dist/runner/child.d.ts +58 -0
- package/dist/runner/child.js +268 -0
- package/dist/runner/manager.d.ts +175 -0
- package/dist/runner/manager.js +458 -0
- package/dist/runner/protocol.d.ts +195 -0
- package/dist/runner/protocol.js +41 -0
- package/dist/runner/transport.d.ts +36 -0
- package/dist/runner/transport.js +72 -0
- package/dist/runner-main.d.ts +2 -0
- package/dist/runner-main.js +61 -0
- package/dist/runtime-status.d.ts +16 -0
- package/dist/runtime-status.js +80 -0
- package/dist/terminal/claude-tui.d.ts +27 -0
- package/dist/terminal/claude-tui.js +13 -0
- package/dist/terminal/codex-tui.d.ts +54 -0
- package/dist/terminal/codex-tui.js +26 -0
- package/dist/terminal/registry.d.ts +42 -0
- package/dist/terminal/registry.js +70 -0
- package/dist/terminal/tmux.d.ts +150 -0
- package/dist/terminal/tmux.js +364 -0
- 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
|
+
}
|