@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
package/dist/host.js
ADDED
|
@@ -0,0 +1,1323 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
|
+
import { mkdtemp, rm } from "node:fs/promises";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
import readline from "node:readline";
|
|
6
|
+
import { resolveAgent, resolveAgentExecution, buildAgentSkillEnvironment, SessionNormalizer, } from "@rynx-ai/core";
|
|
7
|
+
import { getRuntimeProfile, } from "@rynx-ai/core";
|
|
8
|
+
import { resolveRuntimeBinary, resolveRuntimeModel, } from "@rynx-ai/core";
|
|
9
|
+
import { createCodexChildEnv } from "./codex-child-env.js";
|
|
10
|
+
import { prepareCodexHome, populateCodexSkills } from "./codex-home.js";
|
|
11
|
+
import { materializeSkillPlugin } from "./claude/executor.js";
|
|
12
|
+
import { listClaudeModels } from "./claude/models.js";
|
|
13
|
+
import { CodexAppServerClient, buildTextUserInput, } from "./codex-app-server/client.js";
|
|
14
|
+
import { buildAppServerBaseArgs } from "./codex-app-server/transport.js";
|
|
15
|
+
import { WsRpcChannel, ExternalWsChannel } from "./codex-app-server/ws-channel.js";
|
|
16
|
+
import { CodexSessionForwarder } from "./codex-app-server/forwarder.js";
|
|
17
|
+
import { buildCodexRemoteArgs } from "./terminal/codex-tui.js";
|
|
18
|
+
import { buildClaudeTuiArgs } from "./terminal/claude-tui.js";
|
|
19
|
+
import { ensureProjectTrusted } from "./claude/trust.js";
|
|
20
|
+
import { claudeTranscriptPath } from "./claude/transcript.js";
|
|
21
|
+
import { claudeBridgeDir, prepareClaudeBridgeDir } from "./claude/native-bridge.js";
|
|
22
|
+
import { ClaudeLiveSession, injectViaTerminal, } from "./claude/native-integration.js";
|
|
23
|
+
import { buildClaudeHookSettings } from "./claude/native-hooks.js";
|
|
24
|
+
import { FileCodexSessionStore, resolveCodexSessionStorePath, } from "./codex-session-store.js";
|
|
25
|
+
import { AgentRuntimeError as CodexRuntimeError } from "@rynx-ai/core";
|
|
26
|
+
import { newSessionId as makeSessionId } from "@rynx-ai/core";
|
|
27
|
+
import { resolveAllowedRoots, } from "@rynx-ai/core";
|
|
28
|
+
export { FileCodexSessionStore, resolveCodexSessionStorePath, };
|
|
29
|
+
// The runtime error type lives in @rynx-ai/core (as AgentRuntimeError) so channels
|
|
30
|
+
// can `instanceof`-check it without depending on this package. Imported above
|
|
31
|
+
// under its legacy name and re-exported here for intra-package call sites.
|
|
32
|
+
export { CodexRuntimeError };
|
|
33
|
+
export class SpawnCodexCommandRunner {
|
|
34
|
+
cliPath;
|
|
35
|
+
killGraceMs;
|
|
36
|
+
constructor(cliPath, { killGraceMs = 5_000 } = {}) {
|
|
37
|
+
this.cliPath = cliPath;
|
|
38
|
+
this.killGraceMs = killGraceMs;
|
|
39
|
+
}
|
|
40
|
+
start({ args, cwd, signal, }) {
|
|
41
|
+
const child = spawn(this.cliPath, args, {
|
|
42
|
+
cwd,
|
|
43
|
+
env: createCodexChildEnv(process.env),
|
|
44
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
45
|
+
});
|
|
46
|
+
const queue = [];
|
|
47
|
+
let stdout = "";
|
|
48
|
+
let stderr = "";
|
|
49
|
+
let streamClosed = false;
|
|
50
|
+
let childError = null;
|
|
51
|
+
let notify = null;
|
|
52
|
+
let childClosed = false;
|
|
53
|
+
let killTimer;
|
|
54
|
+
const wake = () => {
|
|
55
|
+
notify?.();
|
|
56
|
+
notify = null;
|
|
57
|
+
};
|
|
58
|
+
const push = (chunk) => {
|
|
59
|
+
queue.push(chunk);
|
|
60
|
+
wake();
|
|
61
|
+
};
|
|
62
|
+
const bindLineReader = (kind, input) => {
|
|
63
|
+
if (!input) {
|
|
64
|
+
return;
|
|
65
|
+
}
|
|
66
|
+
const lineReader = readline.createInterface({
|
|
67
|
+
input,
|
|
68
|
+
crlfDelay: Infinity,
|
|
69
|
+
});
|
|
70
|
+
lineReader.on("line", (line) => {
|
|
71
|
+
if (kind === "stdout") {
|
|
72
|
+
stdout += `${line}\n`;
|
|
73
|
+
}
|
|
74
|
+
else {
|
|
75
|
+
stderr += `${line}\n`;
|
|
76
|
+
}
|
|
77
|
+
push({ stream: kind, line });
|
|
78
|
+
});
|
|
79
|
+
};
|
|
80
|
+
bindLineReader("stdout", child.stdout);
|
|
81
|
+
bindLineReader("stderr", child.stderr);
|
|
82
|
+
child.on("error", (error) => {
|
|
83
|
+
childError = error;
|
|
84
|
+
streamClosed = true;
|
|
85
|
+
wake();
|
|
86
|
+
});
|
|
87
|
+
const completion = new Promise((resolve, reject) => {
|
|
88
|
+
child.on("close", (code) => {
|
|
89
|
+
childClosed = true;
|
|
90
|
+
if (killTimer) {
|
|
91
|
+
clearTimeout(killTimer);
|
|
92
|
+
}
|
|
93
|
+
streamClosed = true;
|
|
94
|
+
wake();
|
|
95
|
+
if (childError) {
|
|
96
|
+
reject(childError);
|
|
97
|
+
return;
|
|
98
|
+
}
|
|
99
|
+
resolve({
|
|
100
|
+
exitCode: code ?? 1,
|
|
101
|
+
stdout,
|
|
102
|
+
stderr,
|
|
103
|
+
});
|
|
104
|
+
});
|
|
105
|
+
});
|
|
106
|
+
if (signal) {
|
|
107
|
+
const abortHandler = () => {
|
|
108
|
+
if (childClosed) {
|
|
109
|
+
return;
|
|
110
|
+
}
|
|
111
|
+
child.kill("SIGTERM");
|
|
112
|
+
killTimer ??= setTimeout(() => {
|
|
113
|
+
if (!childClosed) {
|
|
114
|
+
child.kill("SIGKILL");
|
|
115
|
+
}
|
|
116
|
+
}, this.killGraceMs);
|
|
117
|
+
};
|
|
118
|
+
if (signal.aborted) {
|
|
119
|
+
abortHandler();
|
|
120
|
+
}
|
|
121
|
+
else {
|
|
122
|
+
signal.addEventListener("abort", abortHandler, { once: true });
|
|
123
|
+
void completion
|
|
124
|
+
.finally(() => {
|
|
125
|
+
signal.removeEventListener("abort", abortHandler);
|
|
126
|
+
if (killTimer) {
|
|
127
|
+
clearTimeout(killTimer);
|
|
128
|
+
}
|
|
129
|
+
})
|
|
130
|
+
.catch(() => undefined);
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
const stream = (async function* streamLines() {
|
|
134
|
+
while (!streamClosed || queue.length > 0) {
|
|
135
|
+
if (queue.length === 0) {
|
|
136
|
+
await new Promise((resolve) => {
|
|
137
|
+
notify = resolve;
|
|
138
|
+
});
|
|
139
|
+
continue;
|
|
140
|
+
}
|
|
141
|
+
const next = queue.shift();
|
|
142
|
+
if (next) {
|
|
143
|
+
yield next;
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
if (childError) {
|
|
147
|
+
throw childError;
|
|
148
|
+
}
|
|
149
|
+
})();
|
|
150
|
+
return {
|
|
151
|
+
stream,
|
|
152
|
+
completion,
|
|
153
|
+
};
|
|
154
|
+
}
|
|
155
|
+
async capture({ args, cwd = process.cwd(), }) {
|
|
156
|
+
const running = this.start({ args, cwd });
|
|
157
|
+
for await (const _ of running.stream) {
|
|
158
|
+
// Drain output so completion captures stdout/stderr for parsing.
|
|
159
|
+
}
|
|
160
|
+
return running.completion;
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
/** Fraction of an agent's `contextWindow` budget at which the orchestrator
|
|
164
|
+
* emits a one-time `context.threshold` observability signal. Mirrors omnigent's
|
|
165
|
+
* compaction `trigger_threshold` (0.8). codex self-compacts via its token
|
|
166
|
+
* budget (set proactively); claude has no programmatic compaction, so this
|
|
167
|
+
* signal is the lever consumers surface to the user. */
|
|
168
|
+
const CONTEXT_WARN_RATIO = 0.8;
|
|
169
|
+
/**
|
|
170
|
+
* The `OPENAI_*` retry env for a codex-lineage app-server spawn, or `undefined`
|
|
171
|
+
* when the budget declares no retry or the runtime is claude. claude applies
|
|
172
|
+
* its retry budget per-run in `buildOptions` (see the claude executor), so it
|
|
173
|
+
* never needs a dedicated app-server.
|
|
174
|
+
*/
|
|
175
|
+
export function codexRetryEnv(runtime, budget) {
|
|
176
|
+
if (runtime === "claude") {
|
|
177
|
+
return undefined;
|
|
178
|
+
}
|
|
179
|
+
const retry = budget?.retry;
|
|
180
|
+
if (!retry) {
|
|
181
|
+
return undefined;
|
|
182
|
+
}
|
|
183
|
+
const env = {};
|
|
184
|
+
if (retry.maxRetries != null) {
|
|
185
|
+
env.OPENAI_MAX_RETRIES = String(retry.maxRetries);
|
|
186
|
+
}
|
|
187
|
+
if (retry.timeoutPerRequestSeconds != null) {
|
|
188
|
+
env.OPENAI_TIMEOUT = String(Math.round(retry.timeoutPerRequestSeconds));
|
|
189
|
+
}
|
|
190
|
+
return Object.keys(env).length > 0 ? env : undefined;
|
|
191
|
+
}
|
|
192
|
+
/**
|
|
193
|
+
* The cache key for a runtime backend. A retry budget on a codex-lineage
|
|
194
|
+
* runtime forks a dedicated app-server (its own retry env) under a composite
|
|
195
|
+
* `${runtime}::${hash}` key; everything else shares the base `${runtime}`
|
|
196
|
+
* backend — so budget-less agents keep today's shared-process behaviour.
|
|
197
|
+
*/
|
|
198
|
+
function hashRetryEnv(env) {
|
|
199
|
+
return Object.keys(env)
|
|
200
|
+
.sort()
|
|
201
|
+
.map((key) => `${key}=${env[key]}`)
|
|
202
|
+
.join("&");
|
|
203
|
+
}
|
|
204
|
+
export function codexBackendKey(runtime, budget) {
|
|
205
|
+
const env = codexRetryEnv(runtime, budget);
|
|
206
|
+
return env ? `${runtime}::${hashRetryEnv(env)}` : runtime;
|
|
207
|
+
}
|
|
208
|
+
export class LocalAgentHost {
|
|
209
|
+
config;
|
|
210
|
+
sessionStore;
|
|
211
|
+
allowedRoots;
|
|
212
|
+
defaultRuntime;
|
|
213
|
+
// DI overrides applied to the *default* runtime backend only (used by tests).
|
|
214
|
+
// `injectedAppServerClient === null` forces exec mode; `undefined` means
|
|
215
|
+
// auto-create. The command runner override backs the status probe.
|
|
216
|
+
injectedCommandRunner;
|
|
217
|
+
injectedAppServerClient;
|
|
218
|
+
clock;
|
|
219
|
+
backendIdleTtlMs;
|
|
220
|
+
// Builds the SEPARATE forwarder connection for a live codex session (omnigent's
|
|
221
|
+
// multi-connection model). Defaults to an ExternalWsChannel client attached to
|
|
222
|
+
// the backend's app-server; tests inject a fake.
|
|
223
|
+
forwarderClientFactory;
|
|
224
|
+
// Keyed by `codexBackendKey` — the bare runtime id for budget-less agents, or
|
|
225
|
+
// a `${runtime}::${retryHash}` composite for a per-agent retry budget.
|
|
226
|
+
backends = new Map();
|
|
227
|
+
// The rynx session this host serves (set from `RYNX_RUNNER_SESSION`); the private
|
|
228
|
+
// CODEX_HOME is scoped to it so concurrent agents never share a `skills/` dir.
|
|
229
|
+
sessionId;
|
|
230
|
+
// Per-agent codex knobs resolved at live-session start, applied to this
|
|
231
|
+
// session's app-server (host is per-session, so the app-server is private to
|
|
232
|
+
// this agent). Absent ⇒ fall back to the daemon `config.*` default.
|
|
233
|
+
sessionSandbox;
|
|
234
|
+
sessionApprovalPolicy;
|
|
235
|
+
// A private CODEX_HOME (login symlinked, settings copied, update/NUX state NOT
|
|
236
|
+
// inherited) for this session's codex app-server + TUI — lazily prepared.
|
|
237
|
+
cachedCodexHome;
|
|
238
|
+
// Per-session codex-native live forwarders, keyed by localThreadId. The
|
|
239
|
+
// single-writer of the session's canonical events + the target of turn injection.
|
|
240
|
+
liveSessions = new Map();
|
|
241
|
+
// Per-session claude-native live forwarders (parallel to liveSessions; claude
|
|
242
|
+
// has no app-server, so it tails the bridge + transcript instead of RPC).
|
|
243
|
+
liveClaudeSessions = new Map();
|
|
244
|
+
// In-flight `ensureLiveCodexSession` calls, so concurrent triggers (a web
|
|
245
|
+
// `live.ensure` racing a `term.open`) share ONE forwarder — never two.
|
|
246
|
+
liveEnsuring = new Map();
|
|
247
|
+
constructor({ config, commandRunner, sessionStore = new FileCodexSessionStore(resolveCodexSessionStorePath(config)), allowedRoots = resolveAllowedRoots(config), appServerClient, now = () => Date.now(), backendIdleTtlMs = 300_000, forwarderClientFactory, sessionId, }) {
|
|
248
|
+
this.config = config;
|
|
249
|
+
this.sessionId = sessionId ?? "__default__";
|
|
250
|
+
this.sessionStore = sessionStore;
|
|
251
|
+
this.allowedRoots = allowedRoots;
|
|
252
|
+
this.defaultRuntime = config.AGENT_RUNTIME ?? "codex";
|
|
253
|
+
this.injectedCommandRunner = commandRunner;
|
|
254
|
+
this.injectedAppServerClient = appServerClient;
|
|
255
|
+
this.clock = now;
|
|
256
|
+
this.backendIdleTtlMs = backendIdleTtlMs;
|
|
257
|
+
this.forwarderClientFactory =
|
|
258
|
+
forwarderClientFactory ??
|
|
259
|
+
((appServerUrl) => new CodexAppServerClient({ channel: new ExternalWsChannel(appServerUrl) }));
|
|
260
|
+
}
|
|
261
|
+
getBackend(runtime, budget) {
|
|
262
|
+
this.reapIdleBackends();
|
|
263
|
+
// In forced exec mode (the default runtime's injected app-server is null) a
|
|
264
|
+
// retry budget must NOT fork a composite backend: there is no app-server to
|
|
265
|
+
// carry the retry env, and forking one would spawn a real app-server and
|
|
266
|
+
// defeat the forced-exec override. Fall back to the base key — codex retry is
|
|
267
|
+
// then a no-op in exec mode (consistent with the broader codex-retry caveat).
|
|
268
|
+
const execMode = runtime === this.defaultRuntime && this.injectedAppServerClient === null;
|
|
269
|
+
const retryEnv = execMode ? undefined : codexRetryEnv(runtime, budget);
|
|
270
|
+
const key = retryEnv ? `${runtime}::${hashRetryEnv(retryEnv)}` : runtime;
|
|
271
|
+
const existing = this.backends.get(key);
|
|
272
|
+
if (existing) {
|
|
273
|
+
existing.lastUsedAt = this.clock();
|
|
274
|
+
return existing;
|
|
275
|
+
}
|
|
276
|
+
const backend = this.createBackend(runtime, retryEnv);
|
|
277
|
+
backend.lastUsedAt = this.clock();
|
|
278
|
+
this.backends.set(key, backend);
|
|
279
|
+
return backend;
|
|
280
|
+
}
|
|
281
|
+
/**
|
|
282
|
+
* Stop + drop per-budget composite backends that have been idle longer than
|
|
283
|
+
* the TTL and have no in-flight run. Base per-runtime backends (key === the
|
|
284
|
+
* bare runtime id) are never reaped — they live for the process lifetime, as
|
|
285
|
+
* before. There are no per-conversation close hooks, so this lazy sweep (run
|
|
286
|
+
* on every `getBackend`) is how dedicated app-servers get reclaimed.
|
|
287
|
+
*/
|
|
288
|
+
reapIdleBackends() {
|
|
289
|
+
const now = this.clock();
|
|
290
|
+
for (const [key, backend] of this.backends) {
|
|
291
|
+
if (!key.includes("::")) {
|
|
292
|
+
continue;
|
|
293
|
+
}
|
|
294
|
+
if ((backend.inFlight ?? 0) > 0 || backend.lastUsedAt === undefined) {
|
|
295
|
+
continue;
|
|
296
|
+
}
|
|
297
|
+
if (now - backend.lastUsedAt < this.backendIdleTtlMs) {
|
|
298
|
+
continue;
|
|
299
|
+
}
|
|
300
|
+
void backend.appServerClient?.stop();
|
|
301
|
+
this.backends.delete(key);
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
createBackend(runtime, retryEnv) {
|
|
305
|
+
const profile = getRuntimeProfile(runtime);
|
|
306
|
+
// A composite (per-budget) backend is inherently non-default: it gets a
|
|
307
|
+
// fresh app-server with the retry env, never the test-injected default deps.
|
|
308
|
+
const isComposite = retryEnv !== undefined;
|
|
309
|
+
const isDefault = runtime === this.defaultRuntime && !isComposite;
|
|
310
|
+
const commandRunner = (isDefault && this.injectedCommandRunner) ||
|
|
311
|
+
new SpawnCodexCommandRunner(resolveRuntimeBinary(runtime));
|
|
312
|
+
// The claude runtime is not codex-lineage: it has no app-server (its live
|
|
313
|
+
// co-drive tails a tmux pane + transcript), so only the command runner backs
|
|
314
|
+
// its status probe.
|
|
315
|
+
if (runtime === "claude") {
|
|
316
|
+
return { runtime, profile, appServerClient: null, commandRunner };
|
|
317
|
+
}
|
|
318
|
+
// codex-lineage: the app-server connection backs live turn injection; a
|
|
319
|
+
// missing injected client (tests) forces null.
|
|
320
|
+
const appServerClient = isDefault
|
|
321
|
+
? this.injectedAppServerClient === null
|
|
322
|
+
? null
|
|
323
|
+
: this.injectedAppServerClient ?? this.createAppServerClient(runtime)
|
|
324
|
+
: this.createAppServerClient(runtime, retryEnv);
|
|
325
|
+
return { runtime, profile, appServerClient, commandRunner };
|
|
326
|
+
}
|
|
327
|
+
/** This session's private CODEX_HOME (prepared once), shared by its app-server + TUI. */
|
|
328
|
+
codexHome() {
|
|
329
|
+
return (this.cachedCodexHome ??= prepareCodexHome(this.sessionId));
|
|
330
|
+
}
|
|
331
|
+
createAppServerClient(runtime, extraEnv) {
|
|
332
|
+
const cliPath = resolveRuntimeBinary(runtime);
|
|
333
|
+
// Per-agent knobs (resolved at live-session start) win over the daemon
|
|
334
|
+
// default — the app-server is private to this session's agent.
|
|
335
|
+
const approvalPolicy = this.sessionApprovalPolicy ?? this.config.AGENT_APPROVAL_POLICY;
|
|
336
|
+
const sandbox = this.sessionSandbox ?? this.config.AGENT_SANDBOX;
|
|
337
|
+
const policy = approvalPolicy === "never" ? "auto-approve-session" : "auto-decline";
|
|
338
|
+
// Native co-drive: run the app-server on a loopback ws (multi-client) so a
|
|
339
|
+
// `codex --remote` TUI can attach to the same thread (stdio admits only one
|
|
340
|
+
// client). This is the sole transport — always native, aligning with omnigent.
|
|
341
|
+
// Point it at the private CODEX_HOME so it shares the TUI's login/settings and
|
|
342
|
+
// never trips the real home's update/NUX state.
|
|
343
|
+
const channel = new WsRpcChannel({
|
|
344
|
+
cliPath,
|
|
345
|
+
baseArgs: buildAppServerBaseArgs(sandbox),
|
|
346
|
+
extraEnv: { ...(extraEnv ?? {}), CODEX_HOME: this.codexHome() },
|
|
347
|
+
});
|
|
348
|
+
return new CodexAppServerClient({
|
|
349
|
+
channel,
|
|
350
|
+
approvalDecisionPolicy: policy,
|
|
351
|
+
interactiveApprovals: this.config.AGENT_INTERACTIVE_APPROVALS,
|
|
352
|
+
});
|
|
353
|
+
}
|
|
354
|
+
/**
|
|
355
|
+
* Deliver a user's decision for a pending interactive approval (Phase D).
|
|
356
|
+
* Routes to the codex app-server client for the thread's runtime. Returns
|
|
357
|
+
* false when no matching pending approval exists (unknown id / already
|
|
358
|
+
* resolved / claude runtime).
|
|
359
|
+
*/
|
|
360
|
+
async resolveApproval(localThreadId, approvalId, decision) {
|
|
361
|
+
const record = await this.sessionStore.get(localThreadId);
|
|
362
|
+
const runtime = record?.runtime ?? this.defaultRuntime;
|
|
363
|
+
if (runtime === "claude") {
|
|
364
|
+
return false;
|
|
365
|
+
}
|
|
366
|
+
const client = this.getBackend(runtime).appServerClient;
|
|
367
|
+
return client ? client.resolveApproval(approvalId, decision) : false;
|
|
368
|
+
}
|
|
369
|
+
/**
|
|
370
|
+
* The command to run in a session's live terminal so it co-drives the codex
|
|
371
|
+
* app-server thread (Phase D). Returns `null` when live-terminal is off, the
|
|
372
|
+
* runtime is claude (not codex-lineage), or the app-server has no ws endpoint —
|
|
373
|
+
* the caller then falls back to a plain shell. Ensures the app-server is up so
|
|
374
|
+
* its ws url is available; `codex --remote resume <threadId>` attaches to the
|
|
375
|
+
* SAME thread the structured data channel drives.
|
|
376
|
+
*/
|
|
377
|
+
async codexTerminalSpec(localThreadId) {
|
|
378
|
+
const record = await this.sessionStore.get(localThreadId);
|
|
379
|
+
// The live session (created by ensureLiveCodexSession with the agent-resolved
|
|
380
|
+
// runtime) is the source of truth for which runtime this session co-drives;
|
|
381
|
+
// fall back to the store record / default only when it isn't up yet.
|
|
382
|
+
if (this.liveClaudeSessions.has(localThreadId)) {
|
|
383
|
+
return this.claudeTerminalSpec(localThreadId, record);
|
|
384
|
+
}
|
|
385
|
+
const runtime = record?.runtime ?? this.defaultRuntime;
|
|
386
|
+
if (runtime === "claude") {
|
|
387
|
+
return this.claudeTerminalSpec(localThreadId, record);
|
|
388
|
+
}
|
|
389
|
+
const backend = this.getBackend(runtime);
|
|
390
|
+
const client = backend.appServerClient;
|
|
391
|
+
if (!client) {
|
|
392
|
+
throw new CodexRuntimeError(`no app-server for runtime ${runtime}`, 500, "app_server_unavailable");
|
|
393
|
+
}
|
|
394
|
+
// Native-only: an app-server failure surfaces as a terminal error, never a
|
|
395
|
+
// silent bash fallback (aligns with omnigent). ensureInitialized throws on its own.
|
|
396
|
+
await client.ensureInitialized();
|
|
397
|
+
const remoteUrl = client.terminalRemoteUrl();
|
|
398
|
+
if (!remoteUrl) {
|
|
399
|
+
throw new CodexRuntimeError("codex app-server has no remote url", 500, "app_server_unavailable");
|
|
400
|
+
}
|
|
401
|
+
// The TUI shares this session's app-server, so it inherits the per-agent
|
|
402
|
+
// sandbox already applied at boot; keep the network-access override aligned
|
|
403
|
+
// to the same effective sandbox.
|
|
404
|
+
const configOverrides = (this.sessionSandbox ?? this.config.AGENT_SANDBOX) === "workspace-write"
|
|
405
|
+
? ["sandbox_workspace_write.network_access=true"]
|
|
406
|
+
: [];
|
|
407
|
+
// Apply the agent-spec model to the TUI (the private CODEX_HOME config.toml is
|
|
408
|
+
// shared per-runtime, so a per-session model rides a launch `-c model=` instead).
|
|
409
|
+
const launchModel = this.liveSessions.get(localThreadId)?.model;
|
|
410
|
+
if (launchModel)
|
|
411
|
+
configOverrides.push(`model=${JSON.stringify(launchModel)}`);
|
|
412
|
+
return {
|
|
413
|
+
command: resolveRuntimeBinary(runtime),
|
|
414
|
+
args: buildCodexRemoteArgs({
|
|
415
|
+
remoteUrl,
|
|
416
|
+
...(record?.codexSessionId ? { threadId: record.codexSessionId } : {}),
|
|
417
|
+
configOverrides,
|
|
418
|
+
}),
|
|
419
|
+
cwd: record?.cwd ?? process.cwd(),
|
|
420
|
+
// Share the app-server's private CODEX_HOME so the TUI inherits the same
|
|
421
|
+
// login/settings and skips the real home's update/NUX prompt. RYNX_SESSION_ID
|
|
422
|
+
// scopes agent-run CLIs (rynx-emulator) to this session at the daemon.
|
|
423
|
+
env: {
|
|
424
|
+
...createCodexChildEnv(process.env),
|
|
425
|
+
CODEX_HOME: this.codexHome(),
|
|
426
|
+
RYNX_SESSION_ID: localThreadId,
|
|
427
|
+
},
|
|
428
|
+
};
|
|
429
|
+
}
|
|
430
|
+
/**
|
|
431
|
+
* Bring up (idempotently) a session's persistent codex forwarder and emit its
|
|
432
|
+
* mirrored {@link SessionEvent}s. omnigent's codex-native single-writer model:
|
|
433
|
+
* this forwarder is the SOLE producer of the session's canonical events — every
|
|
434
|
+
* turn on the thread, web-composer-injected AND co-driving-`codex --remote`-TUI
|
|
435
|
+
* initiated. Each turn gets a {@link SessionNormalizer} whose `responseId` is
|
|
436
|
+
* DETERMINISTIC (`resp_codex_<turnId>`) so items converge across snapshot/live.
|
|
437
|
+
*
|
|
438
|
+
* Returns false for claude / no app-server (the caller falls back to the
|
|
439
|
+
* streaming run path). This client CREATES the codex thread (`threadStart`) so
|
|
440
|
+
* it owns the thread's item/turn notification stream — the app-server delivers
|
|
441
|
+
* items to the thread-creating connection, for turns started by ANY client (web
|
|
442
|
+
* inject AND the co-driving `codex --remote` TUI). The TUI pane (launched by the
|
|
443
|
+
* runner child, which owns the terminal registry) merely `resume`s this thread
|
|
444
|
+
* to display it. The thread id is persisted so `codexTerminalSpec` resumes it.
|
|
445
|
+
*/
|
|
446
|
+
async ensureLiveCodexSession(localThreadId, emit, opts) {
|
|
447
|
+
if (this.liveSessions.has(localThreadId) || this.liveClaudeSessions.has(localThreadId))
|
|
448
|
+
return true;
|
|
449
|
+
// Dedupe concurrent triggers so only one forwarder is ever created per
|
|
450
|
+
// session (two would double-mirror every turn).
|
|
451
|
+
const inflight = this.liveEnsuring.get(localThreadId);
|
|
452
|
+
if (inflight)
|
|
453
|
+
return inflight;
|
|
454
|
+
const started = this.startLiveCodexSession(localThreadId, emit, opts);
|
|
455
|
+
this.liveEnsuring.set(localThreadId, started);
|
|
456
|
+
try {
|
|
457
|
+
return await started;
|
|
458
|
+
}
|
|
459
|
+
finally {
|
|
460
|
+
this.liveEnsuring.delete(localThreadId);
|
|
461
|
+
}
|
|
462
|
+
}
|
|
463
|
+
async startLiveCodexSession(localThreadId, emit, opts) {
|
|
464
|
+
// cancel-before-recreate (omnigent Layer 2, runner/app.py `_cancel_auto_forwarder_task`):
|
|
465
|
+
// never run a new forwarder alongside a stale one for this id. ensureLive's has()
|
|
466
|
+
// fast-path normally makes this a no-op; it closes the stop→re-ensure race that
|
|
467
|
+
// could otherwise leave a half-dead forwarder mirroring in parallel.
|
|
468
|
+
if (this.liveSessions.has(localThreadId) || this.liveClaudeSessions.has(localThreadId)) {
|
|
469
|
+
this.stopLiveCodexSession(localThreadId);
|
|
470
|
+
}
|
|
471
|
+
const record = await this.sessionStore.get(localThreadId);
|
|
472
|
+
// Runtime precedence: the thread's bound runtime, else the agent-resolved
|
|
473
|
+
// hint from the control plane, else the env default. This is what makes live
|
|
474
|
+
// co-drive follow the agent spec instead of the global default.
|
|
475
|
+
const runtime = record?.runtime ?? opts?.runtime ?? this.defaultRuntime;
|
|
476
|
+
if (runtime === "claude")
|
|
477
|
+
return this.startLiveClaudeSession(localThreadId, emit, record, opts);
|
|
478
|
+
const cwd = record?.cwd ?? process.cwd();
|
|
479
|
+
// Spec-load errors (a rejected legacy `skills` form, malformed JSON, …) are
|
|
480
|
+
// logged with the agent context and fail the live start loudly — never a
|
|
481
|
+
// bare mystery `false`.
|
|
482
|
+
let liveCfg;
|
|
483
|
+
try {
|
|
484
|
+
liveCfg = await this.resolveLiveAgentConfig(localThreadId, opts, runtime, cwd);
|
|
485
|
+
}
|
|
486
|
+
catch (err) {
|
|
487
|
+
console.error(`[agent-spec] session=${localThreadId} agent=${opts?.agentName ?? "-"} failed to resolve: ${err instanceof Error ? err.message : String(err)}`);
|
|
488
|
+
return false;
|
|
489
|
+
}
|
|
490
|
+
// Link this session's skills into its private CODEX_HOME's `skills/` BEFORE the
|
|
491
|
+
// app-server boots (and before the `--remote` TUI attaches) so the native Codex
|
|
492
|
+
// discovers them — omnigent's filesystem mechanism. The live thread is created by
|
|
493
|
+
// the TUI, not rynx, so skills can't ride `threadStart.developerInstructions` (as
|
|
494
|
+
// the non-live run did); `$CODEX_HOME/skills/` is the only channel that reaches it.
|
|
495
|
+
// The agent spec is the single skill source: its refs were replayed into a
|
|
496
|
+
// session-scoped dir (see resolveLiveAgentConfig); no spec / no `skills` means
|
|
497
|
+
// zero skills — the owner's catalog is never consulted.
|
|
498
|
+
const liveSkills = liveCfg.selectedSkills.map((s) => ({ name: s.name, dir: s.dir }));
|
|
499
|
+
populateCodexSkills(this.codexHome(), liveSkills);
|
|
500
|
+
// Apply this agent's sandbox / approval policy to the session's app-server
|
|
501
|
+
// BEFORE it boots (getBackend below lazily creates it). Host is per-session,
|
|
502
|
+
// so these knobs are the agent's own — no cross-session bleed. Set even when
|
|
503
|
+
// absent (undefined) so a re-used host doesn't inherit a prior agent's values.
|
|
504
|
+
this.sessionSandbox = liveCfg.sandbox;
|
|
505
|
+
this.sessionApprovalPolicy = liveCfg.approvalPolicy;
|
|
506
|
+
// Backend client owns the app-server + drives injection. Every failed-start
|
|
507
|
+
// path below removes the session skills dir it would otherwise leak.
|
|
508
|
+
const abandon = () => {
|
|
509
|
+
void liveCfg.skillsCleanup();
|
|
510
|
+
return false;
|
|
511
|
+
};
|
|
512
|
+
const injectClient = this.getBackend(runtime).appServerClient;
|
|
513
|
+
if (!injectClient)
|
|
514
|
+
return abandon();
|
|
515
|
+
try {
|
|
516
|
+
await injectClient.ensureInitialized();
|
|
517
|
+
}
|
|
518
|
+
catch {
|
|
519
|
+
return abandon();
|
|
520
|
+
}
|
|
521
|
+
const appServerUrl = injectClient.terminalRemoteUrl();
|
|
522
|
+
if (!appServerUrl)
|
|
523
|
+
return abandon(); // needs the ws app-server (live-terminal mode)
|
|
524
|
+
// omnigent multi-connection: a SEPARATE forwarder connection to the SAME
|
|
525
|
+
// app-server, which `thread/resume`s to subscribe to the thread's item/turn
|
|
526
|
+
// notifications (the backend client injects; this one only observes).
|
|
527
|
+
const forwarderClient = this.forwarderClientFactory(appServerUrl);
|
|
528
|
+
try {
|
|
529
|
+
await forwarderClient.ensureInitialized();
|
|
530
|
+
}
|
|
531
|
+
catch {
|
|
532
|
+
return abandon();
|
|
533
|
+
}
|
|
534
|
+
// Apply the agent-spec model (skills were applied above via `$CODEX_HOME/skills/`).
|
|
535
|
+
// A resumed thread keeps its stored model; otherwise use the agent-spec model (or
|
|
536
|
+
// the runtime default). The model rides a launch `-c model=` on the TUI (see
|
|
537
|
+
// `codexTerminalSpec`) / `turn/start` on injection, since rynx doesn't create the thread.
|
|
538
|
+
const model = getCodexModel(this.config, record?.model ?? liveCfg.model);
|
|
539
|
+
let markReady;
|
|
540
|
+
const ready = new Promise((resolve) => {
|
|
541
|
+
markReady = resolve;
|
|
542
|
+
});
|
|
543
|
+
const live = {
|
|
544
|
+
injectClient,
|
|
545
|
+
forwarderClient,
|
|
546
|
+
forwarder: null,
|
|
547
|
+
runtime,
|
|
548
|
+
cwd,
|
|
549
|
+
model,
|
|
550
|
+
threadId: record?.codexSessionId ?? null,
|
|
551
|
+
ready,
|
|
552
|
+
markReady,
|
|
553
|
+
injectLock: Promise.resolve(),
|
|
554
|
+
subscribing: false,
|
|
555
|
+
stopped: false,
|
|
556
|
+
skillsCleanup: liveCfg.skillsCleanup,
|
|
557
|
+
};
|
|
558
|
+
let normalizer = null;
|
|
559
|
+
const startNormalizer = (turnId) => {
|
|
560
|
+
normalizer = new SessionNormalizer({
|
|
561
|
+
sessionId: localThreadId,
|
|
562
|
+
// turnId unknown → fixed literal (NEVER random): items arriving before
|
|
563
|
+
// `turn/started` land on ONE response instead of splitting into random
|
|
564
|
+
// ids (the DB `resp_codex_<uuid>` doubling). Mirrors omnigent `_response_id`.
|
|
565
|
+
responseId: turnId ? `resp_codex_${turnId}` : "resp_codex_native",
|
|
566
|
+
model,
|
|
567
|
+
});
|
|
568
|
+
return normalizer;
|
|
569
|
+
};
|
|
570
|
+
const sink = {
|
|
571
|
+
onTurnStart: (turnId) => startNormalizer(turnId),
|
|
572
|
+
onUserMessage: (text) => {
|
|
573
|
+
const n = normalizer ?? startNormalizer();
|
|
574
|
+
for (const se of n.userInput(text))
|
|
575
|
+
emit(se);
|
|
576
|
+
},
|
|
577
|
+
onEvent: (event) => {
|
|
578
|
+
const n = normalizer ?? startNormalizer();
|
|
579
|
+
for (const se of n.next(event))
|
|
580
|
+
emit(se);
|
|
581
|
+
},
|
|
582
|
+
onTurnEnd: (usage) => {
|
|
583
|
+
if (!normalizer)
|
|
584
|
+
return;
|
|
585
|
+
if (usage)
|
|
586
|
+
for (const se of normalizer.next({ type: "turn_completed", usage }))
|
|
587
|
+
emit(se);
|
|
588
|
+
for (const se of normalizer.next({ type: "done" }))
|
|
589
|
+
emit(se);
|
|
590
|
+
normalizer = null;
|
|
591
|
+
},
|
|
592
|
+
onTurnError: (error) => {
|
|
593
|
+
if (!normalizer)
|
|
594
|
+
return;
|
|
595
|
+
for (const se of normalizer.fail({ code: "codex_error", message: error.message, source: "execution" }))
|
|
596
|
+
emit(se);
|
|
597
|
+
normalizer = null;
|
|
598
|
+
},
|
|
599
|
+
// The TUI creates the thread; the forwarder connection sees its broadcast
|
|
600
|
+
// `thread/started`. Persist the id, unblock injection, and start the
|
|
601
|
+
// resume-subscribe loop (which parks until the thread's first turn).
|
|
602
|
+
onThreadStarted: (threadId) => this.onLiveThreadStarted(live, localThreadId, opts?.agentName ?? record?.agent, threadId),
|
|
603
|
+
onThreadActive: () => live.releaseActive?.(),
|
|
604
|
+
};
|
|
605
|
+
const forwarder = new CodexSessionForwarder(forwarderClient, sink);
|
|
606
|
+
live.forwarder = forwarder;
|
|
607
|
+
this.liveSessions.set(localThreadId, live);
|
|
608
|
+
forwarder.start();
|
|
609
|
+
// A resumed session already has a thread id — bind immediately; a fresh
|
|
610
|
+
// session waits for the TUI's broadcast `thread/started` (via onThreadStarted).
|
|
611
|
+
if (record?.codexSessionId) {
|
|
612
|
+
this.onLiveThreadStarted(live, localThreadId, record.agent, record.codexSessionId);
|
|
613
|
+
}
|
|
614
|
+
return true;
|
|
615
|
+
}
|
|
616
|
+
/** Bind a session's codex thread id once known (TUI broadcast or store): persist
|
|
617
|
+
* it, unblock injection, and kick off the resume-subscribe loop (once). */
|
|
618
|
+
onLiveThreadStarted(live, localThreadId, agent, threadId) {
|
|
619
|
+
if (live.subscribing)
|
|
620
|
+
return;
|
|
621
|
+
live.subscribing = true;
|
|
622
|
+
live.threadId = threadId;
|
|
623
|
+
void this.sessionStore
|
|
624
|
+
.set({
|
|
625
|
+
localThreadId,
|
|
626
|
+
codexSessionId: threadId,
|
|
627
|
+
cwd: live.cwd,
|
|
628
|
+
// Persist the agent-spec-resolved model (was the config default), so a
|
|
629
|
+
// resume/reconnect keeps running the agent's model, not the global one.
|
|
630
|
+
model: live.model,
|
|
631
|
+
runtime: live.runtime,
|
|
632
|
+
agent,
|
|
633
|
+
updatedAt: new Date().toISOString(),
|
|
634
|
+
})
|
|
635
|
+
.catch(() => undefined);
|
|
636
|
+
live.markReady(); // thread id known → injection can turn/start
|
|
637
|
+
void this.subscribeUntilReady(live, threadId);
|
|
638
|
+
}
|
|
639
|
+
/**
|
|
640
|
+
* Subscribe the forwarder connection to a thread (omnigent's
|
|
641
|
+
* `_subscribe_until_ready`). A fresh TUI thread has no rollout until its first
|
|
642
|
+
* turn, so `thread/resume` is retried: park until the forwarder observes the
|
|
643
|
+
* thread active, then retry WITHOUT `excludeTurns` so the response backfills the
|
|
644
|
+
* first turn's items (replayed, deduped against the live stream). Once resume
|
|
645
|
+
* succeeds, subsequent turns arrive as live notifications on this connection.
|
|
646
|
+
*/
|
|
647
|
+
async subscribeUntilReady(live, threadId) {
|
|
648
|
+
let sawNotReady = false;
|
|
649
|
+
while (!live.stopped) {
|
|
650
|
+
try {
|
|
651
|
+
const resp = await live.forwarderClient.threadResume({
|
|
652
|
+
threadId,
|
|
653
|
+
cwd: live.cwd,
|
|
654
|
+
excludeTurns: !sawNotReady,
|
|
655
|
+
});
|
|
656
|
+
if (sawNotReady && Array.isArray(resp.thread.turns) && resp.thread.turns.length > 0) {
|
|
657
|
+
live.forwarder.replayBackfill(resp.thread.turns);
|
|
658
|
+
}
|
|
659
|
+
return; // subscribed — live item/turn notifications now flow to the forwarder
|
|
660
|
+
}
|
|
661
|
+
catch (error) {
|
|
662
|
+
if (!isThreadNotReadyError(error)) {
|
|
663
|
+
return; // other failure — injection still works via the backend client
|
|
664
|
+
}
|
|
665
|
+
sawNotReady = true;
|
|
666
|
+
// Park until the thread goes active (its first turn materializes the
|
|
667
|
+
// rollout); a short poll covers the flush race after "active".
|
|
668
|
+
await new Promise((resolve) => {
|
|
669
|
+
live.releaseActive = resolve;
|
|
670
|
+
setTimeout(resolve, 1500);
|
|
671
|
+
});
|
|
672
|
+
live.releaseActive = undefined;
|
|
673
|
+
}
|
|
674
|
+
}
|
|
675
|
+
}
|
|
676
|
+
/** Await a live session's thread binding (bounded). Returns false on timeout /
|
|
677
|
+
* no live session. Injection and the runner's `live.ready` gate on this. */
|
|
678
|
+
async waitLiveReady(localThreadId, timeoutMs = 20_000) {
|
|
679
|
+
const claude = this.liveClaudeSessions.get(localThreadId);
|
|
680
|
+
if (claude)
|
|
681
|
+
return raceReady(claude.ready, timeoutMs);
|
|
682
|
+
const live = this.liveSessions.get(localThreadId);
|
|
683
|
+
if (!live)
|
|
684
|
+
return false;
|
|
685
|
+
let timer;
|
|
686
|
+
const timeout = new Promise((resolve) => {
|
|
687
|
+
timer = setTimeout(() => resolve(false), timeoutMs);
|
|
688
|
+
});
|
|
689
|
+
const ok = await Promise.race([live.ready.then(() => true), timeout]);
|
|
690
|
+
if (timer)
|
|
691
|
+
clearTimeout(timer);
|
|
692
|
+
return ok;
|
|
693
|
+
}
|
|
694
|
+
/**
|
|
695
|
+
* Inject a user turn into a session's live codex thread — omnigent's
|
|
696
|
+
* single-writer web send. `turn/steer` when a turn is open (mid-turn
|
|
697
|
+
* supplement), else `turn/start`. NEVER creates a thread (the TUI owns creation;
|
|
698
|
+
* this targets the id the forwarder captured). Serialized per session so two
|
|
699
|
+
* injects can't double-open a turn.
|
|
700
|
+
*
|
|
701
|
+
* Returns an {@link InjectOutcome}: `notLive` when this session has no live
|
|
702
|
+
* forwarder (caller may use the run path); `notReady`/`failed` are hard errors
|
|
703
|
+
* the caller reports WITHOUT re-running (re-running double-writes alongside the
|
|
704
|
+
* forwarder). Park-until-ready (~60s), aligning omnigent's executor waiting for
|
|
705
|
+
* the bridge instead of a short race that falls back to a second output path.
|
|
706
|
+
*/
|
|
707
|
+
async injectMessage(localThreadId, text) {
|
|
708
|
+
const claude = this.liveClaudeSessions.get(localThreadId);
|
|
709
|
+
if (claude)
|
|
710
|
+
return this.injectClaude(claude, localThreadId, text);
|
|
711
|
+
const live = this.liveSessions.get(localThreadId);
|
|
712
|
+
if (!live)
|
|
713
|
+
return "notLive";
|
|
714
|
+
const run = live.injectLock.then(async () => {
|
|
715
|
+
// Park until the thread binds (~60s, omnigent codex_native_executor:177-186),
|
|
716
|
+
// not a 20s race that returns false and lets the caller re-run on a 2nd path.
|
|
717
|
+
const bound = await this.waitLiveReady(localThreadId, 60_000);
|
|
718
|
+
const threadId = live.threadId ?? live.forwarder.threadId();
|
|
719
|
+
if (!bound || !threadId)
|
|
720
|
+
return "notReady";
|
|
721
|
+
const input = buildTextUserInput(text);
|
|
722
|
+
try {
|
|
723
|
+
// Inject via the BACKEND client (the forwarder connection only observes).
|
|
724
|
+
if (live.forwarder.isTurnOpen()) {
|
|
725
|
+
const turnId = live.forwarder.currentTurnId();
|
|
726
|
+
if (turnId) {
|
|
727
|
+
await live.injectClient.turnSteer({ threadId, expectedTurnId: turnId, input });
|
|
728
|
+
return "injected";
|
|
729
|
+
}
|
|
730
|
+
}
|
|
731
|
+
// Carry the agent-spec model on the turn so a web-injected turn runs the
|
|
732
|
+
// agent's model even if the TUI's config default differs.
|
|
733
|
+
await live.injectClient.turnStart({ threadId, input, cwd: live.cwd, model: live.model });
|
|
734
|
+
return "injected";
|
|
735
|
+
}
|
|
736
|
+
catch {
|
|
737
|
+
// RPC failed → hard error; caller reports it and does NOT re-run
|
|
738
|
+
// (omnigent: inject failure ⇒ response.failed, never local re-run).
|
|
739
|
+
return "failed";
|
|
740
|
+
}
|
|
741
|
+
});
|
|
742
|
+
live.injectLock = run.then(() => undefined, () => undefined);
|
|
743
|
+
return run;
|
|
744
|
+
}
|
|
745
|
+
/**
|
|
746
|
+
* Interrupt the session's active turn — the web Stop button. codex: the
|
|
747
|
+
* app-server `turn/interrupt` on the active `{threadId, turnId}` (exactly what
|
|
748
|
+
* codex's own TUI interrupt key sends — omnigent `_handle_codex_native_interrupt`).
|
|
749
|
+
* claude: an Escape keystroke into the pane (no app-server — omnigent
|
|
750
|
+
* `inject_interrupt`). Returns false as a no-op when there is no active turn.
|
|
751
|
+
*/
|
|
752
|
+
async interruptLive(localThreadId) {
|
|
753
|
+
const claude = this.liveClaudeSessions.get(localThreadId);
|
|
754
|
+
if (claude) {
|
|
755
|
+
if (!claude.injector)
|
|
756
|
+
return false;
|
|
757
|
+
const turnOpen = claude.forwarder.isTurnOpen();
|
|
758
|
+
const injecting = claude.injectAbort !== undefined;
|
|
759
|
+
// Nothing to interrupt: idle pane, no turn, no in-flight injection. Do NOT
|
|
760
|
+
// send Escape — an Escape into idle claude submits an empty turn, which
|
|
761
|
+
// claude answers with a stray "No response requested." bubble.
|
|
762
|
+
if (!turnOpen && !injecting)
|
|
763
|
+
return false;
|
|
764
|
+
// Cancel an in-flight injection FIRST so its submit/verify loop can't re-send
|
|
765
|
+
// Enter after we clear the box, then clear any half-typed / re-populated draft.
|
|
766
|
+
claude.injectAbort?.abort();
|
|
767
|
+
claude.injector.clearInputLine();
|
|
768
|
+
// Only send Escape (+ close the turn UI-side) when a response is actually
|
|
769
|
+
// running — otherwise clearing the pending injection above is the whole job.
|
|
770
|
+
if (turnOpen) {
|
|
771
|
+
claude.injector.interrupt();
|
|
772
|
+
// An interrupt that cancels a response before it produced output (or before
|
|
773
|
+
// its Stop hook fires) would leave the spinner stuck until the 30s
|
|
774
|
+
// inactivity backstop — treat it like a Stop and close the turn now.
|
|
775
|
+
claude.forwarder.noteInterrupted();
|
|
776
|
+
}
|
|
777
|
+
return true;
|
|
778
|
+
}
|
|
779
|
+
const live = this.liveSessions.get(localThreadId);
|
|
780
|
+
if (!live)
|
|
781
|
+
return false;
|
|
782
|
+
// Only interrupt an OPEN turn — codex's app-server rejects a stale turnId, and
|
|
783
|
+
// omnigent no-ops (204) when no turn is active.
|
|
784
|
+
if (!live.forwarder.isTurnOpen())
|
|
785
|
+
return false;
|
|
786
|
+
const threadId = live.threadId ?? live.forwarder.threadId();
|
|
787
|
+
const turnId = live.forwarder.currentTurnId();
|
|
788
|
+
if (!threadId || !turnId)
|
|
789
|
+
return false;
|
|
790
|
+
try {
|
|
791
|
+
await live.injectClient.turnInterrupt({ threadId, turnId });
|
|
792
|
+
return true;
|
|
793
|
+
}
|
|
794
|
+
catch {
|
|
795
|
+
return false;
|
|
796
|
+
}
|
|
797
|
+
}
|
|
798
|
+
/** Stop + drop a session's live forwarder and its dedicated connection (session
|
|
799
|
+
* close / runner shutdown). The backend inject client is shared — left running. */
|
|
800
|
+
stopLiveCodexSession(localThreadId) {
|
|
801
|
+
const claude = this.liveClaudeSessions.get(localThreadId);
|
|
802
|
+
if (claude) {
|
|
803
|
+
this.liveClaudeSessions.delete(localThreadId);
|
|
804
|
+
claude.stopped = true;
|
|
805
|
+
claude.forwarder.stop();
|
|
806
|
+
// Remove the throwaway skills --plugin-dir temp so it doesn't leak per launch.
|
|
807
|
+
void claude.skillCleanup?.();
|
|
808
|
+
return;
|
|
809
|
+
}
|
|
810
|
+
const live = this.liveSessions.get(localThreadId);
|
|
811
|
+
if (!live)
|
|
812
|
+
return;
|
|
813
|
+
this.liveSessions.delete(localThreadId);
|
|
814
|
+
live.stopped = true;
|
|
815
|
+
live.releaseActive?.();
|
|
816
|
+
live.forwarder.stop();
|
|
817
|
+
void live.forwarderClient.stop().catch(() => undefined);
|
|
818
|
+
// Remove the session-scoped skills dir — the machine keeps zero task residue.
|
|
819
|
+
void live.skillsCleanup?.();
|
|
820
|
+
}
|
|
821
|
+
// --- claude-native live session (parallel to the codex methods above) -----
|
|
822
|
+
/** The interactive `claude` TUI spec for a claude-native live session: the real
|
|
823
|
+
* `claude` binary with rynx's hooks (`--settings`), resuming the bound session.
|
|
824
|
+
* Trust is pre-seeded so the daemon-spawned TUI never blocks on a first-run
|
|
825
|
+
* prompt. (PermissionRequest approvals land in Phase 6; until then the TUI
|
|
826
|
+
* surfaces its own permission prompt in the co-driven pane.) */
|
|
827
|
+
claudeTerminalSpec(localThreadId, record) {
|
|
828
|
+
const cwd = record?.cwd ?? process.cwd();
|
|
829
|
+
try {
|
|
830
|
+
ensureProjectTrusted(cwd);
|
|
831
|
+
}
|
|
832
|
+
catch {
|
|
833
|
+
// A malformed ~/.claude.json shouldn't block launch; claude re-prompts.
|
|
834
|
+
}
|
|
835
|
+
const settings = buildClaudeHookSettings({
|
|
836
|
+
bridgeDir: claudeBridgeDir(localThreadId),
|
|
837
|
+
permission: Boolean(process.env.RYNX_CONTROL_URL?.trim()),
|
|
838
|
+
messageDisplay: true,
|
|
839
|
+
statusLine: true,
|
|
840
|
+
});
|
|
841
|
+
// The live session (created by startLiveClaudeSession before the pane launches)
|
|
842
|
+
// carries the agent-spec launch config: model + `--plugin-dir` (skills; no persona).
|
|
843
|
+
const live = this.liveClaudeSessions.get(localThreadId);
|
|
844
|
+
const args = buildClaudeTuiArgs({
|
|
845
|
+
settingsJson: JSON.stringify(settings),
|
|
846
|
+
model: live?.launchModel ?? resolveRuntimeModel(this.config, "claude", record?.model),
|
|
847
|
+
...(record?.codexSessionId ? { resume: record.codexSessionId } : {}),
|
|
848
|
+
...(live?.launchExtraArgs?.length ? { extraArgs: live.launchExtraArgs } : {}),
|
|
849
|
+
});
|
|
850
|
+
return {
|
|
851
|
+
command: resolveRuntimeBinary("claude"),
|
|
852
|
+
args,
|
|
853
|
+
cwd,
|
|
854
|
+
// Same env the pane would inherit by default, plus the session scope for
|
|
855
|
+
// agent-run CLIs (rynx-emulator resolves device-less commands via it).
|
|
856
|
+
env: { ...process.env, RYNX_SESSION_ID: localThreadId },
|
|
857
|
+
};
|
|
858
|
+
}
|
|
859
|
+
/** Bring up a claude-native live forwarder: prepare the bridge dir (the TUI's
|
|
860
|
+
* hooks write into it), then tail bridge + transcript and drive a per-turn
|
|
861
|
+
* {@link SessionNormalizer} → `emit` (the SAME sink shape the codex path uses). */
|
|
862
|
+
/** Resolve an agent spec's launch config — model + the session skill env —
|
|
863
|
+
* for a LIVE native session, reusing the same core resolvers the non-live
|
|
864
|
+
* run path uses ({@link resolveAgentExecution} for the model,
|
|
865
|
+
* {@link resolveAgent} for skills). Instructions are intentionally NOT
|
|
866
|
+
* returned: the co-driven TUI runs on vendor defaults (see
|
|
867
|
+
* startLiveClaudeSession).
|
|
868
|
+
*
|
|
869
|
+
* Skills are spec-rooted: each declared ref is replayed into a
|
|
870
|
+
* SESSION-scoped temp dir (cache-accelerated); the owner's catalog plays no
|
|
871
|
+
* role, and no spec / no `skills` means ZERO skills. The env-build report
|
|
872
|
+
* (installs, failures, content drift) is logged — a declared skill that
|
|
873
|
+
* can't be provisioned is loudly absent, never silent; the session still
|
|
874
|
+
* starts. `skillsCleanup` removes the session dir (call on session stop). */
|
|
875
|
+
async resolveLiveAgentConfig(localThreadId, opts, runtime, cwd) {
|
|
876
|
+
// Inline spec needs an id only so a relative instructions-file path can
|
|
877
|
+
// resolve (mirrors runLocked); a preset agent loads by name.
|
|
878
|
+
const spec = opts?.agentSpec
|
|
879
|
+
? { ...opts.agentSpec, id: opts.agentName ?? "inline" }
|
|
880
|
+
: undefined;
|
|
881
|
+
const exec = await resolveAgentExecution({
|
|
882
|
+
config: this.config,
|
|
883
|
+
...(opts?.agentName ? { agentName: opts.agentName } : {}),
|
|
884
|
+
...(opts?.agentSpec ? { spec: opts.agentSpec } : {}),
|
|
885
|
+
runtime,
|
|
886
|
+
});
|
|
887
|
+
const resolved = await resolveAgent({
|
|
888
|
+
config: this.config,
|
|
889
|
+
cwd,
|
|
890
|
+
...(opts?.agentName ? { agentName: opts.agentName } : {}),
|
|
891
|
+
...(spec ? { spec } : {}),
|
|
892
|
+
});
|
|
893
|
+
const sessionSkillsDir = await mkdtemp(path.join(tmpdir(), "rynx-session-skills-"));
|
|
894
|
+
const skillEnv = await buildAgentSkillEnvironment(resolved.skills, sessionSkillsDir);
|
|
895
|
+
const agent = opts?.agentName ?? resolved.name ?? "-";
|
|
896
|
+
const { report } = skillEnv;
|
|
897
|
+
if (report.errors.length > 0 || report.missing.length > 0) {
|
|
898
|
+
console.error(`[skills] session=${localThreadId} agent=${agent} missing=[${report.missing.join(", ")}] ${report.errors.join("; ")}`);
|
|
899
|
+
}
|
|
900
|
+
if (report.installed.length > 0) {
|
|
901
|
+
console.error(`[skills] session=${localThreadId} agent=${agent} installed=[${report.installed.join(", ")}]`);
|
|
902
|
+
}
|
|
903
|
+
for (const note of report.drift) {
|
|
904
|
+
console.error(`[skills] session=${localThreadId} agent=${agent} drift: ${note}`);
|
|
905
|
+
}
|
|
906
|
+
return {
|
|
907
|
+
model: exec.model,
|
|
908
|
+
selectedSkills: skillEnv.resolved,
|
|
909
|
+
skillsCleanup: () => rm(sessionSkillsDir, { recursive: true, force: true }),
|
|
910
|
+
...(resolved.sandbox ? { sandbox: resolved.sandbox } : {}),
|
|
911
|
+
...(resolved.approvalPolicy ? { approvalPolicy: resolved.approvalPolicy } : {}),
|
|
912
|
+
...(resolved.permissionMode ? { permissionMode: resolved.permissionMode } : {}),
|
|
913
|
+
};
|
|
914
|
+
}
|
|
915
|
+
async startLiveClaudeSession(localThreadId, emit, record, opts) {
|
|
916
|
+
const retargetMirror = opts?.retargetMirror;
|
|
917
|
+
const bridgeDir = prepareClaudeBridgeDir(localThreadId, this.claudePermissionConfig(localThreadId));
|
|
918
|
+
const cwd = record?.cwd ?? process.cwd();
|
|
919
|
+
// Apply the agent spec (model / skills / instructions) to the live TUI launch —
|
|
920
|
+
// NOT just the config default. The stored record model wins if present (a
|
|
921
|
+
// resumed thread), else the agent-spec resolution. Spec-load errors are logged
|
|
922
|
+
// and fail the start loudly (mirrors the codex path).
|
|
923
|
+
let liveCfg;
|
|
924
|
+
try {
|
|
925
|
+
liveCfg = await this.resolveLiveAgentConfig(localThreadId, opts, "claude", cwd);
|
|
926
|
+
}
|
|
927
|
+
catch (err) {
|
|
928
|
+
console.error(`[agent-spec] session=${localThreadId} agent=${opts?.agentName ?? "-"} failed to resolve: ${err instanceof Error ? err.message : String(err)}`);
|
|
929
|
+
return false;
|
|
930
|
+
}
|
|
931
|
+
const model = resolveRuntimeModel(this.config, "claude", record?.model ?? liveCfg.model);
|
|
932
|
+
// Skills → throwaway `--plugin-dir`. Persona/instructions are deliberately NOT
|
|
933
|
+
// injected into the co-driven TUI: omnigent's claude-native and codex-native both
|
|
934
|
+
// leave the live thread on the vendor's built-in defaults (+ workspace AGENTS.md),
|
|
935
|
+
// so rynx matches (the non-live run path still injects persona for both runtimes).
|
|
936
|
+
// The plugin dir gets its own copy, so the session skills dir is disposable here.
|
|
937
|
+
const skillPlugin = await materializeSkillPlugin(liveCfg.selectedSkills);
|
|
938
|
+
void liveCfg.skillsCleanup();
|
|
939
|
+
// Agent-spec permission mode → claude's `--permission-mode` (claude-private
|
|
940
|
+
// approval axis). Unset sends no flag (claude's own default); any set value
|
|
941
|
+
// is passed through (`bypassPermissions` is how an agent opts out of the
|
|
942
|
+
// approval prompt). The shared `sandbox` axis is intentionally NOT applied
|
|
943
|
+
// here — the live co-drive TUI runs unsandboxed (matches omnigent's native).
|
|
944
|
+
const permissionMode = liveCfg.permissionMode;
|
|
945
|
+
const launchExtraArgs = [
|
|
946
|
+
...(skillPlugin?.pluginDir ? ["--plugin-dir", skillPlugin.pluginDir] : []),
|
|
947
|
+
...(permissionMode ? ["--permission-mode", permissionMode] : []),
|
|
948
|
+
];
|
|
949
|
+
// The CURRENT rynx session this forwarder mirrors into — starts as the launch
|
|
950
|
+
// session, re-pointed on a `/clear`·`/fork` rotation (see onSessionRotated).
|
|
951
|
+
let currentSessionId = localThreadId;
|
|
952
|
+
let normalizer = null;
|
|
953
|
+
let currentResponseId;
|
|
954
|
+
const startNormalizer = (turnId) => {
|
|
955
|
+
// turnId unknown → fixed literal (never random), aligning omnigent `_response_id`.
|
|
956
|
+
currentResponseId = turnId ? `resp_claude_${turnId}` : "resp_claude_native";
|
|
957
|
+
normalizer = new SessionNormalizer({
|
|
958
|
+
sessionId: currentSessionId,
|
|
959
|
+
responseId: currentResponseId,
|
|
960
|
+
model,
|
|
961
|
+
});
|
|
962
|
+
return normalizer;
|
|
963
|
+
};
|
|
964
|
+
let markReady;
|
|
965
|
+
const ready = new Promise((resolve) => {
|
|
966
|
+
markReady = resolve;
|
|
967
|
+
});
|
|
968
|
+
const live = {
|
|
969
|
+
forwarder: null,
|
|
970
|
+
cwd,
|
|
971
|
+
bridgeDir,
|
|
972
|
+
injectLock: Promise.resolve(),
|
|
973
|
+
ready,
|
|
974
|
+
markReady,
|
|
975
|
+
discoveredReady: false,
|
|
976
|
+
stopped: false,
|
|
977
|
+
launchModel: model,
|
|
978
|
+
launchExtraArgs,
|
|
979
|
+
...(skillPlugin?.cleanup ? { skillCleanup: skillPlugin.cleanup } : {}),
|
|
980
|
+
};
|
|
981
|
+
const sink = {
|
|
982
|
+
onTurnStart: (turnId) => startNormalizer(turnId),
|
|
983
|
+
onUserMessage: (text) => {
|
|
984
|
+
const n = normalizer ?? startNormalizer();
|
|
985
|
+
for (const se of n.userInput(text))
|
|
986
|
+
emit(se);
|
|
987
|
+
},
|
|
988
|
+
onTerminalCommand: (cmd) => {
|
|
989
|
+
const n = normalizer ?? startNormalizer();
|
|
990
|
+
for (const se of n.terminalCommand(cmd))
|
|
991
|
+
emit(se);
|
|
992
|
+
},
|
|
993
|
+
onTodos: (todos) => emit({ type: "session.todos", sessionId: currentSessionId, todos }),
|
|
994
|
+
onEvent: (event) => {
|
|
995
|
+
const n = normalizer ?? startNormalizer();
|
|
996
|
+
for (const se of n.next(event))
|
|
997
|
+
emit(se);
|
|
998
|
+
},
|
|
999
|
+
onTurnEnd: (usage) => {
|
|
1000
|
+
if (!normalizer)
|
|
1001
|
+
return;
|
|
1002
|
+
const rid = currentResponseId;
|
|
1003
|
+
// statusLine usage (context/cost) rides the turn's response.completed.
|
|
1004
|
+
if (usage)
|
|
1005
|
+
for (const se of normalizer.next({ type: "turn_completed", usage }))
|
|
1006
|
+
emit(se);
|
|
1007
|
+
for (const se of normalizer.next({ type: "done" }))
|
|
1008
|
+
emit(se);
|
|
1009
|
+
normalizer = null;
|
|
1010
|
+
// One-time context-window banner past CONTEXT_WARN_RATIO (a transient note,
|
|
1011
|
+
// cleared by the next turn's first item). statusLine pre-computes the %.
|
|
1012
|
+
const pct = usage && typeof usage.used_percentage === "number" ? usage.used_percentage : undefined;
|
|
1013
|
+
if (rid && pct !== undefined && !live.contextWarned && pct >= CONTEXT_WARN_RATIO * 100) {
|
|
1014
|
+
live.contextWarned = true;
|
|
1015
|
+
emit({
|
|
1016
|
+
type: "session.status",
|
|
1017
|
+
sessionId: currentSessionId,
|
|
1018
|
+
responseId: rid,
|
|
1019
|
+
status: "idle",
|
|
1020
|
+
note: `context ${Math.round(pct)}% full — consider /compact`,
|
|
1021
|
+
});
|
|
1022
|
+
}
|
|
1023
|
+
},
|
|
1024
|
+
onIdle: () => {
|
|
1025
|
+
// Surface idle on the current turn WITHOUT finalizing it (see the sink's
|
|
1026
|
+
// onIdle doc): a late assistant record still joins this response.
|
|
1027
|
+
if (currentResponseId) {
|
|
1028
|
+
emit({
|
|
1029
|
+
type: "session.status",
|
|
1030
|
+
sessionId: currentSessionId,
|
|
1031
|
+
responseId: currentResponseId,
|
|
1032
|
+
status: "idle",
|
|
1033
|
+
});
|
|
1034
|
+
}
|
|
1035
|
+
},
|
|
1036
|
+
onTurnError: (error) => {
|
|
1037
|
+
if (!normalizer)
|
|
1038
|
+
return;
|
|
1039
|
+
for (const se of normalizer.fail({ code: "claude_error", message: error.message, source: "execution" }))
|
|
1040
|
+
emit(se);
|
|
1041
|
+
normalizer = null;
|
|
1042
|
+
},
|
|
1043
|
+
onSessionDiscovered: (claudeSessionId) => this.onClaudeDiscovered(live, localThreadId, opts?.agentName ?? record?.agent, claudeSessionId),
|
|
1044
|
+
onSessionRotated: (kind, claudeSessionId) => {
|
|
1045
|
+
// The forwarder already closed the old turn (onTurnEnd) + re-pointed to the
|
|
1046
|
+
// new transcript. Rotate the rynx session: announce on the OLD session (the
|
|
1047
|
+
// mirror still targets it), mint a fresh one, persist it, and re-target the
|
|
1048
|
+
// mirror + terminal transfer so the new session is live AND injectable.
|
|
1049
|
+
const previousSessionId = currentSessionId;
|
|
1050
|
+
const newSessionId = makeSessionId();
|
|
1051
|
+
emit({ type: "session.rotated", sessionId: previousSessionId, newSessionId, kind });
|
|
1052
|
+
void this.sessionStore
|
|
1053
|
+
.set({
|
|
1054
|
+
localThreadId: newSessionId,
|
|
1055
|
+
codexSessionId: claudeSessionId,
|
|
1056
|
+
cwd: live.cwd,
|
|
1057
|
+
model,
|
|
1058
|
+
runtime: "claude",
|
|
1059
|
+
...(record?.agent ? { agent: record.agent } : {}),
|
|
1060
|
+
...(kind === "fork" ? { parentSessionId: previousSessionId } : {}),
|
|
1061
|
+
updatedAt: new Date().toISOString(),
|
|
1062
|
+
})
|
|
1063
|
+
.catch(() => undefined);
|
|
1064
|
+
currentSessionId = newSessionId;
|
|
1065
|
+
normalizer = null;
|
|
1066
|
+
live.contextWarned = false;
|
|
1067
|
+
// Route the new session to THIS live session (host map) + this runner (daemon).
|
|
1068
|
+
this.liveClaudeSessions.set(newSessionId, live);
|
|
1069
|
+
retargetMirror?.(newSessionId, {
|
|
1070
|
+
kind,
|
|
1071
|
+
...(record?.agent ? { agent: record.agent } : {}),
|
|
1072
|
+
model,
|
|
1073
|
+
cwd: live.cwd,
|
|
1074
|
+
parentSessionId: previousSessionId,
|
|
1075
|
+
});
|
|
1076
|
+
},
|
|
1077
|
+
};
|
|
1078
|
+
// Resume: a known claude session id → tail its transcript immediately.
|
|
1079
|
+
const transcriptPath = record?.codexSessionId
|
|
1080
|
+
? claudeTranscriptPath(cwd, record.codexSessionId)
|
|
1081
|
+
: undefined;
|
|
1082
|
+
live.forwarder = new ClaudeLiveSession({
|
|
1083
|
+
bridgeDir,
|
|
1084
|
+
sink,
|
|
1085
|
+
...(transcriptPath ? { transcriptPath } : {}),
|
|
1086
|
+
...(record?.codexSessionId ? { claudeSessionId: record.codexSessionId } : {}),
|
|
1087
|
+
});
|
|
1088
|
+
this.liveClaudeSessions.set(localThreadId, live);
|
|
1089
|
+
live.forwarder.start();
|
|
1090
|
+
if (record?.codexSessionId) {
|
|
1091
|
+
live.discoveredReady = true;
|
|
1092
|
+
markReady(); // resumed → injection can proceed without awaiting discovery
|
|
1093
|
+
}
|
|
1094
|
+
return true;
|
|
1095
|
+
}
|
|
1096
|
+
/** Persist claude's discovered session id (reusing the `codexSessionId` store
|
|
1097
|
+
* field, as the claude executor already does) and release the readiness gate. */
|
|
1098
|
+
onClaudeDiscovered(live, localThreadId, agent, claudeSessionId) {
|
|
1099
|
+
if (live.discoveredReady)
|
|
1100
|
+
return;
|
|
1101
|
+
live.discoveredReady = true;
|
|
1102
|
+
void this.sessionStore
|
|
1103
|
+
.set({
|
|
1104
|
+
localThreadId,
|
|
1105
|
+
codexSessionId: claudeSessionId,
|
|
1106
|
+
cwd: live.cwd,
|
|
1107
|
+
// Persist the agent-spec-resolved launch model (was the config default).
|
|
1108
|
+
model: live.launchModel,
|
|
1109
|
+
runtime: "claude",
|
|
1110
|
+
agent,
|
|
1111
|
+
updatedAt: new Date().toISOString(),
|
|
1112
|
+
})
|
|
1113
|
+
.catch(() => undefined);
|
|
1114
|
+
live.markReady();
|
|
1115
|
+
}
|
|
1116
|
+
/** Inject a web message into a claude-native pane via tmux (omnigent recipe),
|
|
1117
|
+
* serialized per session. Parks until the thread is ready AND the tmux injector
|
|
1118
|
+
* is (re)attached, then pastes; `injectViaTerminal` RAISES if the prompt never
|
|
1119
|
+
* appears (omnigent RAISE), so a not-ready pane is a hard error — NOT a
|
|
1120
|
+
* fall-through-to-run signal. Returns {@link InjectOutcome}. */
|
|
1121
|
+
injectClaude(live, localThreadId, text) {
|
|
1122
|
+
const run = live.injectLock.then(async () => {
|
|
1123
|
+
const ready = await this.waitLiveReady(localThreadId, 60_000);
|
|
1124
|
+
if (!ready)
|
|
1125
|
+
return "notReady";
|
|
1126
|
+
// Pane may have just relaunched — park until its injector re-attaches
|
|
1127
|
+
// (attachTerminalInjector resets it) instead of returning false → fallback.
|
|
1128
|
+
const injector = await this.waitInjector(live, 60_000);
|
|
1129
|
+
if (!injector)
|
|
1130
|
+
return "notReady";
|
|
1131
|
+
// Abortable: the web Stop button cancels an in-flight paste/submit (before
|
|
1132
|
+
// the message reaches claude) via interruptLive → injectAbort.abort().
|
|
1133
|
+
const abort = new AbortController();
|
|
1134
|
+
live.injectAbort = abort;
|
|
1135
|
+
try {
|
|
1136
|
+
const ok = await injectViaTerminal(injector, text, { signal: abort.signal });
|
|
1137
|
+
return ok ? "injected" : "failed";
|
|
1138
|
+
}
|
|
1139
|
+
catch {
|
|
1140
|
+
return "failed";
|
|
1141
|
+
}
|
|
1142
|
+
finally {
|
|
1143
|
+
if (live.injectAbort === abort)
|
|
1144
|
+
live.injectAbort = undefined;
|
|
1145
|
+
}
|
|
1146
|
+
});
|
|
1147
|
+
live.injectLock = run.then(() => undefined, () => undefined);
|
|
1148
|
+
return run;
|
|
1149
|
+
}
|
|
1150
|
+
/** Park until the claude session's tmux injector is (re)attached by the
|
|
1151
|
+
* runner-child, or the deadline passes. Pane relaunch re-attaches it via
|
|
1152
|
+
* {@link attachTerminalInjector}; without this a message during that window
|
|
1153
|
+
* would fail and (pre-omnigent) fall through to a second output path. */
|
|
1154
|
+
async waitInjector(live, timeoutMs) {
|
|
1155
|
+
const deadline = Date.now() + timeoutMs;
|
|
1156
|
+
while (!live.injector && Date.now() < deadline) {
|
|
1157
|
+
await new Promise((r) => setTimeout(r, 150));
|
|
1158
|
+
}
|
|
1159
|
+
return live.injector ?? null;
|
|
1160
|
+
}
|
|
1161
|
+
/** Attach a session's tmux pane injector (from the runner-child, which owns the
|
|
1162
|
+
* terminal registry — the host does not hold tmux). Ignored for codex sessions
|
|
1163
|
+
* (they inject via the app-server). Idempotent. */
|
|
1164
|
+
attachTerminalInjector(localThreadId, injector) {
|
|
1165
|
+
const live = this.liveClaudeSessions.get(localThreadId);
|
|
1166
|
+
if (live)
|
|
1167
|
+
live.injector = injector;
|
|
1168
|
+
}
|
|
1169
|
+
/** The PermissionRequest hook's server target from the runner-child env (the
|
|
1170
|
+
* daemon control URL + token), or undefined when unset — then no permission
|
|
1171
|
+
* hook is registered and claude surfaces its own TUI prompt in the pane. */
|
|
1172
|
+
claudePermissionConfig(localThreadId) {
|
|
1173
|
+
const serverUrl = process.env.RYNX_CONTROL_URL?.trim();
|
|
1174
|
+
if (!serverUrl)
|
|
1175
|
+
return undefined;
|
|
1176
|
+
return { sessionId: localThreadId, serverUrl };
|
|
1177
|
+
}
|
|
1178
|
+
/** List the models a runtime exposes (App Server for codex/traex; static for claude). */
|
|
1179
|
+
async listModels(runtime) {
|
|
1180
|
+
const resolved = runtime ?? this.defaultRuntime;
|
|
1181
|
+
if (resolved === "claude") {
|
|
1182
|
+
// The Agent SDK has no per-install model-list RPC; serve the curated list.
|
|
1183
|
+
return listClaudeModels();
|
|
1184
|
+
}
|
|
1185
|
+
const backend = this.getBackend(resolved);
|
|
1186
|
+
if (!backend.appServerClient) {
|
|
1187
|
+
return null;
|
|
1188
|
+
}
|
|
1189
|
+
return backend.appServerClient.modelList();
|
|
1190
|
+
}
|
|
1191
|
+
/**
|
|
1192
|
+
* Run a per-thread capability against a session's active thread on whichever
|
|
1193
|
+
* runtime it is bound to. Normalises the "no app-server backend" and "session
|
|
1194
|
+
* has no thread yet" cases into a `CapabilityResult`.
|
|
1195
|
+
*/
|
|
1196
|
+
async withCodexThread(localThreadId, fn) {
|
|
1197
|
+
const record = await this.sessionStore.get(localThreadId);
|
|
1198
|
+
if (!record?.codexSessionId) {
|
|
1199
|
+
return { ok: false, reason: "no_active_thread" };
|
|
1200
|
+
}
|
|
1201
|
+
const backend = this.getBackend(record.runtime ?? "codex");
|
|
1202
|
+
if (!backend.appServerClient) {
|
|
1203
|
+
return { ok: false, reason: "unsupported" };
|
|
1204
|
+
}
|
|
1205
|
+
try {
|
|
1206
|
+
return { ok: true, data: await fn(backend.appServerClient, record.codexSessionId) };
|
|
1207
|
+
}
|
|
1208
|
+
catch (error) {
|
|
1209
|
+
return {
|
|
1210
|
+
ok: false,
|
|
1211
|
+
reason: "error",
|
|
1212
|
+
message: error instanceof Error ? error.message : String(error),
|
|
1213
|
+
};
|
|
1214
|
+
}
|
|
1215
|
+
}
|
|
1216
|
+
async getGoal(localThreadId) {
|
|
1217
|
+
return this.withCodexThread(localThreadId, async (client, threadId) => {
|
|
1218
|
+
const response = await client.threadGoalGet({ threadId });
|
|
1219
|
+
return response.goal ?? null;
|
|
1220
|
+
});
|
|
1221
|
+
}
|
|
1222
|
+
async setGoal(localThreadId, objective) {
|
|
1223
|
+
return this.withCodexThread(localThreadId, async (client, threadId) => {
|
|
1224
|
+
await client.threadGoalSet({ threadId, objective });
|
|
1225
|
+
});
|
|
1226
|
+
}
|
|
1227
|
+
async clearGoal(localThreadId) {
|
|
1228
|
+
return this.withCodexThread(localThreadId, async (client, threadId) => {
|
|
1229
|
+
await client.threadGoalClear({ threadId });
|
|
1230
|
+
});
|
|
1231
|
+
}
|
|
1232
|
+
async forkSession(currentLocalThreadId, newLocalThreadId) {
|
|
1233
|
+
const record = await this.sessionStore.get(currentLocalThreadId);
|
|
1234
|
+
if (!record) {
|
|
1235
|
+
return { ok: false, reason: "no_active_thread" };
|
|
1236
|
+
}
|
|
1237
|
+
const runtime = record.runtime ?? "codex";
|
|
1238
|
+
const backend = this.getBackend(runtime);
|
|
1239
|
+
if (!backend.appServerClient) {
|
|
1240
|
+
return { ok: false, reason: "unsupported" };
|
|
1241
|
+
}
|
|
1242
|
+
try {
|
|
1243
|
+
const forked = await backend.appServerClient.threadFork({
|
|
1244
|
+
threadId: record.codexSessionId,
|
|
1245
|
+
model: record.model,
|
|
1246
|
+
cwd: record.cwd,
|
|
1247
|
+
});
|
|
1248
|
+
const forkedThreadId = forked.thread.id;
|
|
1249
|
+
await this.sessionStore.set({
|
|
1250
|
+
localThreadId: newLocalThreadId,
|
|
1251
|
+
codexSessionId: forkedThreadId,
|
|
1252
|
+
cwd: record.cwd,
|
|
1253
|
+
model: record.model,
|
|
1254
|
+
runtime,
|
|
1255
|
+
updatedAt: new Date().toISOString(),
|
|
1256
|
+
});
|
|
1257
|
+
return { ok: true, data: undefined };
|
|
1258
|
+
}
|
|
1259
|
+
catch (error) {
|
|
1260
|
+
return {
|
|
1261
|
+
ok: false,
|
|
1262
|
+
reason: "error",
|
|
1263
|
+
message: error instanceof Error ? error.message : String(error),
|
|
1264
|
+
};
|
|
1265
|
+
}
|
|
1266
|
+
}
|
|
1267
|
+
}
|
|
1268
|
+
export function parseCodexLoginStatus(exitCode, output) {
|
|
1269
|
+
const normalized = output.trim();
|
|
1270
|
+
const match = normalized.match(/Logged in using (.+)$/im);
|
|
1271
|
+
if (exitCode === 0 && match) {
|
|
1272
|
+
return {
|
|
1273
|
+
loggedIn: true,
|
|
1274
|
+
authMode: match[1].trim(),
|
|
1275
|
+
issues: [],
|
|
1276
|
+
};
|
|
1277
|
+
}
|
|
1278
|
+
if (/not logged in|logged out/i.test(normalized)) {
|
|
1279
|
+
return {
|
|
1280
|
+
loggedIn: false,
|
|
1281
|
+
authMode: null,
|
|
1282
|
+
issues: [],
|
|
1283
|
+
};
|
|
1284
|
+
}
|
|
1285
|
+
return {
|
|
1286
|
+
loggedIn: exitCode === 0,
|
|
1287
|
+
authMode: match?.[1]?.trim() ?? null,
|
|
1288
|
+
issues: normalized ? [normalized] : [],
|
|
1289
|
+
};
|
|
1290
|
+
}
|
|
1291
|
+
/** Race a live session's `ready` promise against a timeout → resolves boolean. */
|
|
1292
|
+
function raceReady(ready, timeoutMs) {
|
|
1293
|
+
let timer;
|
|
1294
|
+
const timeout = new Promise((resolve) => {
|
|
1295
|
+
timer = setTimeout(() => resolve(false), timeoutMs);
|
|
1296
|
+
});
|
|
1297
|
+
return Promise.race([ready.then(() => true), timeout]).finally(() => {
|
|
1298
|
+
if (timer)
|
|
1299
|
+
clearTimeout(timer);
|
|
1300
|
+
});
|
|
1301
|
+
}
|
|
1302
|
+
export function getCodexModel(config, override) {
|
|
1303
|
+
return override?.trim() || config.CODEX_MODEL;
|
|
1304
|
+
}
|
|
1305
|
+
export { createCodexChildEnv };
|
|
1306
|
+
export function isUnsupportedMethodError(error) {
|
|
1307
|
+
if (!error || typeof error !== "object") {
|
|
1308
|
+
return false;
|
|
1309
|
+
}
|
|
1310
|
+
const code = error.code;
|
|
1311
|
+
if (code === -32600 || code === -32601) {
|
|
1312
|
+
return true;
|
|
1313
|
+
}
|
|
1314
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
1315
|
+
return /unknown variant|method not found|unsupported method|not supported/i.test(message);
|
|
1316
|
+
}
|
|
1317
|
+
/** A `thread/resume` failure meaning the thread has no (or an empty) rollout yet
|
|
1318
|
+
* — a fresh TUI thread before its first turn. Retryable (park until active).
|
|
1319
|
+
* Mirrors omnigent's `_is_thread_not_ready_error`. */
|
|
1320
|
+
export function isThreadNotReadyError(error) {
|
|
1321
|
+
const message = (error instanceof Error ? error.message : String(error)).toLowerCase();
|
|
1322
|
+
return message.includes("no rollout found") || (message.includes("rollout") && message.includes("empty"));
|
|
1323
|
+
}
|