@rynx-ai/runtime 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (69) hide show
  1. package/dist/claude/executor.d.ts +17 -0
  2. package/dist/claude/executor.js +28 -0
  3. package/dist/claude/models.d.ts +10 -0
  4. package/dist/claude/models.js +33 -0
  5. package/dist/claude/native-bridge.d.ts +133 -0
  6. package/dist/claude/native-bridge.js +299 -0
  7. package/dist/claude/native-hook-main.d.ts +2 -0
  8. package/dist/claude/native-hook-main.js +74 -0
  9. package/dist/claude/native-hooks.d.ts +41 -0
  10. package/dist/claude/native-hooks.js +73 -0
  11. package/dist/claude/native-integration.d.ts +213 -0
  12. package/dist/claude/native-integration.js +665 -0
  13. package/dist/claude/native-message-display-main.d.ts +2 -0
  14. package/dist/claude/native-message-display-main.js +51 -0
  15. package/dist/claude/native-status-main.d.ts +2 -0
  16. package/dist/claude/native-status-main.js +105 -0
  17. package/dist/claude/status.d.ts +23 -0
  18. package/dist/claude/status.js +118 -0
  19. package/dist/claude/transcript.d.ts +79 -0
  20. package/dist/claude/transcript.js +272 -0
  21. package/dist/claude/trust.d.ts +6 -0
  22. package/dist/claude/trust.js +85 -0
  23. package/dist/codex/rollout-synth.d.ts +37 -0
  24. package/dist/codex/rollout-synth.js +212 -0
  25. package/dist/codex-app-server/client.d.ts +138 -0
  26. package/dist/codex-app-server/client.js +341 -0
  27. package/dist/codex-app-server/forwarder.d.ts +92 -0
  28. package/dist/codex-app-server/forwarder.js +188 -0
  29. package/dist/codex-app-server/mapping.d.ts +19 -0
  30. package/dist/codex-app-server/mapping.js +189 -0
  31. package/dist/codex-app-server/protocol.d.ts +472 -0
  32. package/dist/codex-app-server/protocol.js +12 -0
  33. package/dist/codex-app-server/transport.d.ts +139 -0
  34. package/dist/codex-app-server/transport.js +422 -0
  35. package/dist/codex-app-server/ws-channel.d.ts +72 -0
  36. package/dist/codex-app-server/ws-channel.js +233 -0
  37. package/dist/codex-child-env.d.ts +1 -0
  38. package/dist/codex-child-env.js +27 -0
  39. package/dist/codex-home.d.ts +47 -0
  40. package/dist/codex-home.js +135 -0
  41. package/dist/codex-session-store.d.ts +42 -0
  42. package/dist/codex-session-store.js +126 -0
  43. package/dist/host.d.ts +324 -0
  44. package/dist/host.js +1323 -0
  45. package/dist/index.d.ts +18 -0
  46. package/dist/index.js +17 -0
  47. package/dist/models-catalog.d.ts +18 -0
  48. package/dist/models-catalog.js +27 -0
  49. package/dist/runner/child.d.ts +58 -0
  50. package/dist/runner/child.js +268 -0
  51. package/dist/runner/manager.d.ts +175 -0
  52. package/dist/runner/manager.js +458 -0
  53. package/dist/runner/protocol.d.ts +195 -0
  54. package/dist/runner/protocol.js +41 -0
  55. package/dist/runner/transport.d.ts +36 -0
  56. package/dist/runner/transport.js +72 -0
  57. package/dist/runner-main.d.ts +2 -0
  58. package/dist/runner-main.js +61 -0
  59. package/dist/runtime-status.d.ts +16 -0
  60. package/dist/runtime-status.js +80 -0
  61. package/dist/terminal/claude-tui.d.ts +27 -0
  62. package/dist/terminal/claude-tui.js +13 -0
  63. package/dist/terminal/codex-tui.d.ts +54 -0
  64. package/dist/terminal/codex-tui.js +26 -0
  65. package/dist/terminal/registry.d.ts +42 -0
  66. package/dist/terminal/registry.js +70 -0
  67. package/dist/terminal/tmux.d.ts +150 -0
  68. package/dist/terminal/tmux.js +364 -0
  69. package/package.json +32 -0
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
+ }