@rynx-ai/runtime 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (69) hide show
  1. package/dist/claude/executor.d.ts +17 -0
  2. package/dist/claude/executor.js +28 -0
  3. package/dist/claude/models.d.ts +10 -0
  4. package/dist/claude/models.js +33 -0
  5. package/dist/claude/native-bridge.d.ts +133 -0
  6. package/dist/claude/native-bridge.js +299 -0
  7. package/dist/claude/native-hook-main.d.ts +2 -0
  8. package/dist/claude/native-hook-main.js +74 -0
  9. package/dist/claude/native-hooks.d.ts +41 -0
  10. package/dist/claude/native-hooks.js +73 -0
  11. package/dist/claude/native-integration.d.ts +213 -0
  12. package/dist/claude/native-integration.js +665 -0
  13. package/dist/claude/native-message-display-main.d.ts +2 -0
  14. package/dist/claude/native-message-display-main.js +51 -0
  15. package/dist/claude/native-status-main.d.ts +2 -0
  16. package/dist/claude/native-status-main.js +105 -0
  17. package/dist/claude/status.d.ts +23 -0
  18. package/dist/claude/status.js +118 -0
  19. package/dist/claude/transcript.d.ts +79 -0
  20. package/dist/claude/transcript.js +272 -0
  21. package/dist/claude/trust.d.ts +6 -0
  22. package/dist/claude/trust.js +85 -0
  23. package/dist/codex/rollout-synth.d.ts +37 -0
  24. package/dist/codex/rollout-synth.js +212 -0
  25. package/dist/codex-app-server/client.d.ts +138 -0
  26. package/dist/codex-app-server/client.js +341 -0
  27. package/dist/codex-app-server/forwarder.d.ts +92 -0
  28. package/dist/codex-app-server/forwarder.js +188 -0
  29. package/dist/codex-app-server/mapping.d.ts +19 -0
  30. package/dist/codex-app-server/mapping.js +189 -0
  31. package/dist/codex-app-server/protocol.d.ts +472 -0
  32. package/dist/codex-app-server/protocol.js +12 -0
  33. package/dist/codex-app-server/transport.d.ts +139 -0
  34. package/dist/codex-app-server/transport.js +422 -0
  35. package/dist/codex-app-server/ws-channel.d.ts +72 -0
  36. package/dist/codex-app-server/ws-channel.js +233 -0
  37. package/dist/codex-child-env.d.ts +1 -0
  38. package/dist/codex-child-env.js +27 -0
  39. package/dist/codex-home.d.ts +47 -0
  40. package/dist/codex-home.js +135 -0
  41. package/dist/codex-session-store.d.ts +42 -0
  42. package/dist/codex-session-store.js +126 -0
  43. package/dist/host.d.ts +324 -0
  44. package/dist/host.js +1323 -0
  45. package/dist/index.d.ts +18 -0
  46. package/dist/index.js +17 -0
  47. package/dist/models-catalog.d.ts +18 -0
  48. package/dist/models-catalog.js +27 -0
  49. package/dist/runner/child.d.ts +58 -0
  50. package/dist/runner/child.js +268 -0
  51. package/dist/runner/manager.d.ts +175 -0
  52. package/dist/runner/manager.js +458 -0
  53. package/dist/runner/protocol.d.ts +195 -0
  54. package/dist/runner/protocol.js +41 -0
  55. package/dist/runner/transport.d.ts +36 -0
  56. package/dist/runner/transport.js +72 -0
  57. package/dist/runner-main.d.ts +2 -0
  58. package/dist/runner-main.js +61 -0
  59. package/dist/runtime-status.d.ts +16 -0
  60. package/dist/runtime-status.js +80 -0
  61. package/dist/terminal/claude-tui.d.ts +27 -0
  62. package/dist/terminal/claude-tui.js +13 -0
  63. package/dist/terminal/codex-tui.d.ts +54 -0
  64. package/dist/terminal/codex-tui.js +26 -0
  65. package/dist/terminal/registry.d.ts +42 -0
  66. package/dist/terminal/registry.js +70 -0
  67. package/dist/terminal/tmux.d.ts +150 -0
  68. package/dist/terminal/tmux.js +364 -0
  69. package/package.json +32 -0
@@ -0,0 +1,18 @@
1
+ /**
2
+ * @rynx-ai/runtime — runtime host that drives local coding-agent CLIs (codex / traex
3
+ * / claude) as live co-drive sessions behind the `@rynx-ai/core` contracts
4
+ * (`AgentCapabilities` + `LiveExecutor`). Depends only on `@rynx-ai/core`; knows
5
+ * nothing about any channel.
6
+ */
7
+ export { LocalAgentHost, CodexRuntimeError, SpawnCodexCommandRunner, FileCodexSessionStore, resolveCodexSessionStorePath, } from "./host.js";
8
+ export type { CodexCapabilities, CapabilityResult, CodexRuntimeStatus, } from "./host.js";
9
+ export type { CodexSessionStore, CodexSessionRecord } from "./codex-session-store.js";
10
+ export { ensureCodexResumeRollout } from "./codex/rollout-synth.js";
11
+ export { RunnerManager } from "./runner/manager.js";
12
+ export type { RunnerManagerOptions, OpenTerminalOptions, ParentTerminal, } from "./runner/manager.js";
13
+ export type { InjectOutcome, TerminalRole } from "./runner/protocol.js";
14
+ export { probeRuntimeStatus } from "./runtime-status.js";
15
+ export { listRuntimeModels } from "./models-catalog.js";
16
+ export { TmuxTerminal, isTmuxAvailable } from "./terminal/tmux.js";
17
+ export type { TerminalAttachment, TmuxTerminalOptions } from "./terminal/tmux.js";
18
+ export { TerminalRegistry } from "./terminal/registry.js";
package/dist/index.js ADDED
@@ -0,0 +1,17 @@
1
+ /**
2
+ * @rynx-ai/runtime — runtime host that drives local coding-agent CLIs (codex / traex
3
+ * / claude) as live co-drive sessions behind the `@rynx-ai/core` contracts
4
+ * (`AgentCapabilities` + `LiveExecutor`). Depends only on `@rynx-ai/core`; knows
5
+ * nothing about any channel.
6
+ */
7
+ export { LocalAgentHost, CodexRuntimeError, SpawnCodexCommandRunner, FileCodexSessionStore, resolveCodexSessionStorePath, } from "./host.js";
8
+ export { ensureCodexResumeRollout } from "./codex/rollout-synth.js";
9
+ // Runner subprocess layer: the parent-side manager (an `AgentExecutor` +
10
+ // `AgentCapabilities` that spawns per-session runner children) plus the wire
11
+ // types. The composition root uses `RunnerManager` in place of `LocalAgentHost`.
12
+ export { RunnerManager } from "./runner/manager.js";
13
+ export { probeRuntimeStatus } from "./runtime-status.js";
14
+ export { listRuntimeModels } from "./models-catalog.js";
15
+ // Live-terminal subsystem (Phase C): tmux-backed terminals + per-runner registry.
16
+ export { TmuxTerminal, isTmuxAvailable } from "./terminal/tmux.js";
17
+ export { TerminalRegistry } from "./terminal/registry.js";
@@ -0,0 +1,18 @@
1
+ /**
2
+ * Backend-free model listing for codex/traex.
3
+ *
4
+ * The parent control plane no longer holds an app-server, so `/models` can't be
5
+ * a live `model/list` RPC anymore. Following omnigent's static-catalog model, we
6
+ * serve a config-derived list: the runtime's configured default model (the one
7
+ * `resolveRuntimeModel` would pick), marked `isDefault`. This is intentionally
8
+ * minimal — a fuller curated catalogue can be added here later without touching
9
+ * any caller. claude already has its own static list ({@link listClaudeModels}).
10
+ */
11
+ import { type AgentRuntimeId, type AppConfig } from "@rynx-ai/core";
12
+ import type { ModelListResponse } from "./codex-app-server/protocol.js";
13
+ /**
14
+ * The model list for a runtime, without an execution backend.
15
+ * Returns `null` when the runtime exposes no resolvable model (e.g. traex with
16
+ * no `TRAEX_MODEL`), matching the prior "unsupported" semantics of `listModels`.
17
+ */
18
+ export declare function listRuntimeModels(config: AppConfig, runtime: AgentRuntimeId): ModelListResponse | null;
@@ -0,0 +1,27 @@
1
+ /**
2
+ * Backend-free model listing for codex/traex.
3
+ *
4
+ * The parent control plane no longer holds an app-server, so `/models` can't be
5
+ * a live `model/list` RPC anymore. Following omnigent's static-catalog model, we
6
+ * serve a config-derived list: the runtime's configured default model (the one
7
+ * `resolveRuntimeModel` would pick), marked `isDefault`. This is intentionally
8
+ * minimal — a fuller curated catalogue can be added here later without touching
9
+ * any caller. claude already has its own static list ({@link listClaudeModels}).
10
+ */
11
+ import { resolveRuntimeModel, } from "@rynx-ai/core";
12
+ import { listClaudeModels } from "./claude/models.js";
13
+ /**
14
+ * The model list for a runtime, without an execution backend.
15
+ * Returns `null` when the runtime exposes no resolvable model (e.g. traex with
16
+ * no `TRAEX_MODEL`), matching the prior "unsupported" semantics of `listModels`.
17
+ */
18
+ export function listRuntimeModels(config, runtime) {
19
+ if (runtime === "claude") {
20
+ return listClaudeModels();
21
+ }
22
+ const model = resolveRuntimeModel(config, runtime).trim();
23
+ if (!model) {
24
+ return null;
25
+ }
26
+ return { data: [{ id: model, model, isDefault: true }] };
27
+ }
@@ -0,0 +1,58 @@
1
+ /**
2
+ * Child-side runner session. One per runner process (i.e. per session); owns the
3
+ * single execution backend via the injected executor (a {@link LocalAgentHost}
4
+ * in production, which in a per-session process holds exactly one backend). It
5
+ * translates inbound {@link ToChild} control messages into the live co-drive
6
+ * surface (bring up the session's forwarder + TUI, inject / interrupt turns) and
7
+ * per-thread capabilities, answered against the same backend so the parent never
8
+ * needs an app-server of its own.
9
+ */
10
+ import { type AgentCapabilities } from "@rynx-ai/core";
11
+ import type { ChildTransport } from "./transport.js";
12
+ export interface RunnerSessionDeps {
13
+ transport: ChildTransport;
14
+ /** The single-backend capability + live co-drive surface (a `LocalAgentHost`). */
15
+ executor: AgentCapabilities;
16
+ /** Invoked on a `shutdown` message (default: close transport). */
17
+ onShutdown?: () => void;
18
+ }
19
+ export declare class RunnerSession {
20
+ private readonly transport;
21
+ private readonly executor;
22
+ private readonly onShutdown;
23
+ /** Live terminals hosted by this session, and per-attach client handles. */
24
+ private readonly terminals;
25
+ private readonly attachments;
26
+ /** Session ids with a live codex forwarder started here (stopped on shutdown). */
27
+ private readonly liveIds;
28
+ constructor({ transport, executor, onShutdown }: RunnerSessionDeps);
29
+ private handle;
30
+ private get liveProvider();
31
+ /** The mirror channel for a session: an `emit` that forwards every canonical
32
+ * event to the parent under the CURRENT session id (mutable), plus a `retarget`
33
+ * the host calls on a `/clear`·`/fork` rotation — it re-points the mirror to the
34
+ * new session AND tells the daemon to alias the runner (terminal transfer) so
35
+ * the new session stays injectable. */
36
+ private mirrorChannel;
37
+ /**
38
+ * Eagerly bring up a session's codex-native live view: start the persistent
39
+ * forwarder connection (which resume-subscribes to mirror every turn) and
40
+ * launch the detached `codex --remote` TUI, which CREATES the codex thread. The
41
+ * forwarder's connection sees the TUI's broadcast `thread/started`, binds, and
42
+ * subscribes — so the TUI is usable immediately (a fresh `--remote` needs no
43
+ * rollout) and its turns mirror to chat (omnigent's model).
44
+ */
45
+ private ensureLive;
46
+ private inject;
47
+ private interruptLive;
48
+ /** Launch (idempotently) the session's codex TUI pane from the executor's
49
+ * `codexTerminalSpec`. Shares the `${id}-main` terminal id with `term.open`,
50
+ * so the web attach reuses the same detached pane. */
51
+ private launchCodexPane;
52
+ private stopLive;
53
+ private openTerminal;
54
+ /** Attach an already-created terminal and forward its data/exit to the parent. */
55
+ private attachExisting;
56
+ private runCap;
57
+ private dispatchCap;
58
+ }
@@ -0,0 +1,268 @@
1
+ import { TerminalRegistry } from "../terminal/registry.js";
2
+ import { toWireError } from "./protocol.js";
3
+ export class RunnerSession {
4
+ transport;
5
+ executor;
6
+ onShutdown;
7
+ /** Live terminals hosted by this session, and per-attach client handles. */
8
+ terminals = new TerminalRegistry();
9
+ attachments = new Map();
10
+ /** Session ids with a live codex forwarder started here (stopped on shutdown). */
11
+ liveIds = new Set();
12
+ constructor({ transport, executor, onShutdown }) {
13
+ this.transport = transport;
14
+ this.executor = executor;
15
+ this.onShutdown =
16
+ onShutdown ??
17
+ (() => {
18
+ this.terminals.closeAll();
19
+ transport.close();
20
+ });
21
+ this.transport.onMessage((msg) => this.handle(msg));
22
+ this.transport.send({ t: "ready" });
23
+ }
24
+ handle(msg) {
25
+ switch (msg.t) {
26
+ case "cap":
27
+ void this.runCap(msg.capId, msg.name, msg.args);
28
+ return;
29
+ case "term.open":
30
+ void this.openTerminal(msg);
31
+ return;
32
+ case "term.input":
33
+ this.attachments.get(msg.attachId)?.write(Buffer.from(msg.dataB64, "base64").toString("utf8"));
34
+ return;
35
+ case "term.resize":
36
+ this.attachments.get(msg.attachId)?.resize(msg.cols, msg.rows);
37
+ return;
38
+ case "term.close": {
39
+ const attachment = this.attachments.get(msg.attachId);
40
+ this.attachments.delete(msg.attachId);
41
+ attachment?.kill();
42
+ return;
43
+ }
44
+ case "approval.resolve": {
45
+ const provider = this.executor;
46
+ void provider.resolveApproval?.(msg.localThreadId, msg.approvalId, msg.decision);
47
+ return;
48
+ }
49
+ case "live.ensure":
50
+ void this.ensureLive(msg);
51
+ return;
52
+ case "inject":
53
+ void this.inject(msg);
54
+ return;
55
+ case "live.interrupt":
56
+ void this.interruptLive(msg);
57
+ return;
58
+ case "shutdown":
59
+ this.stopLive();
60
+ this.terminals.closeAll();
61
+ this.onShutdown();
62
+ return;
63
+ }
64
+ }
65
+ get liveProvider() {
66
+ return this.executor;
67
+ }
68
+ /** The mirror channel for a session: an `emit` that forwards every canonical
69
+ * event to the parent under the CURRENT session id (mutable), plus a `retarget`
70
+ * the host calls on a `/clear`·`/fork` rotation — it re-points the mirror to the
71
+ * new session AND tells the daemon to alias the runner (terminal transfer) so
72
+ * the new session stays injectable. */
73
+ mirrorChannel(localThreadId) {
74
+ const target = { id: localThreadId };
75
+ const emit = (event) => this.transport.send({ t: "mirror", sessionId: target.id, event });
76
+ const retarget = (newId, meta) => {
77
+ target.id = newId;
78
+ this.transport.send({
79
+ t: "rotate",
80
+ from: localThreadId,
81
+ to: newId,
82
+ kind: meta.kind,
83
+ ...(meta.agent ? { agent: meta.agent } : {}),
84
+ ...(meta.model ? { model: meta.model } : {}),
85
+ ...(meta.cwd ? { cwd: meta.cwd } : {}),
86
+ ...(meta.parentSessionId ? { parentSessionId: meta.parentSessionId } : {}),
87
+ });
88
+ };
89
+ return { emit, retarget };
90
+ }
91
+ /**
92
+ * Eagerly bring up a session's codex-native live view: start the persistent
93
+ * forwarder connection (which resume-subscribes to mirror every turn) and
94
+ * launch the detached `codex --remote` TUI, which CREATES the codex thread. The
95
+ * forwarder's connection sees the TUI's broadcast `thread/started`, binds, and
96
+ * subscribes — so the TUI is usable immediately (a fresh `--remote` needs no
97
+ * rollout) and its turns mirror to chat (omnigent's model).
98
+ */
99
+ async ensureLive(msg) {
100
+ const provider = this.liveProvider;
101
+ try {
102
+ const { emit, retarget } = this.mirrorChannel(msg.localThreadId);
103
+ const started = await provider.ensureLiveCodexSession?.(msg.localThreadId, emit, {
104
+ ...(msg.runtime ? { runtime: msg.runtime } : {}),
105
+ ...(msg.agentName ? { agentName: msg.agentName } : {}),
106
+ ...(msg.agentSpec ? { agentSpec: msg.agentSpec } : {}),
107
+ retargetMirror: retarget,
108
+ });
109
+ if (!started) {
110
+ this.transport.send({ t: "live.ready", reqId: msg.reqId, localThreadId: msg.localThreadId, ok: false });
111
+ return;
112
+ }
113
+ this.liveIds.add(msg.localThreadId);
114
+ // Launch the TUI (fresh `--remote` → it creates the thread) so the forwarder
115
+ // captures the broadcast `thread/started` and binds. Re-launch when the pane
116
+ // is absent OR its process has died (`isAlive` probes `#{pane_dead}`) — so a
117
+ // reconnect after the TUI exited restarts it instead of skipping (a
118
+ // launched-once guard would leave a dead "Pane is dead" husk forever).
119
+ if (!this.terminals.get(`${msg.localThreadId}-main`)?.isAlive()) {
120
+ await this.launchCodexPane(msg.localThreadId, msg.cols, msg.rows);
121
+ }
122
+ const ready = provider.waitLiveReady
123
+ ? await provider.waitLiveReady(msg.localThreadId)
124
+ : true;
125
+ this.transport.send({ t: "live.ready", reqId: msg.reqId, localThreadId: msg.localThreadId, ok: ready });
126
+ }
127
+ catch (error) {
128
+ this.transport.send({
129
+ t: "live.ready",
130
+ reqId: msg.reqId,
131
+ localThreadId: msg.localThreadId,
132
+ ok: false,
133
+ error: error instanceof Error ? error.message : String(error),
134
+ });
135
+ }
136
+ }
137
+ async inject(msg) {
138
+ const provider = this.liveProvider;
139
+ try {
140
+ const outcome = (await provider.injectMessage?.(msg.localThreadId, msg.text)) ?? "notLive";
141
+ this.transport.send({ t: "injected", reqId: msg.reqId, localThreadId: msg.localThreadId, outcome });
142
+ }
143
+ catch (error) {
144
+ this.transport.send({
145
+ t: "injected",
146
+ reqId: msg.reqId,
147
+ localThreadId: msg.localThreadId,
148
+ outcome: "failed",
149
+ error: error instanceof Error ? error.message : String(error),
150
+ });
151
+ }
152
+ }
153
+ async interruptLive(msg) {
154
+ const provider = this.liveProvider;
155
+ let ok = false;
156
+ let error;
157
+ try {
158
+ ok = (await provider.interruptLive?.(msg.localThreadId)) ?? false;
159
+ }
160
+ catch (e) {
161
+ error = e instanceof Error ? e.message : String(e);
162
+ }
163
+ this.transport.send({
164
+ t: "interrupted",
165
+ reqId: msg.reqId,
166
+ localThreadId: msg.localThreadId,
167
+ ok,
168
+ ...(error ? { error } : {}),
169
+ });
170
+ }
171
+ /** Launch (idempotently) the session's codex TUI pane from the executor's
172
+ * `codexTerminalSpec`. Shares the `${id}-main` terminal id with `term.open`,
173
+ * so the web attach reuses the same detached pane. */
174
+ async launchCodexPane(localThreadId, cols, rows) {
175
+ const spec = await this.liveProvider.codexTerminalSpec?.(localThreadId);
176
+ if (!spec)
177
+ return;
178
+ const term = this.terminals.getOrCreate(`${localThreadId}-main`, {
179
+ cwd: spec.cwd,
180
+ command: spec.command,
181
+ args: spec.args,
182
+ cols: cols ?? 120,
183
+ rows: rows ?? 40,
184
+ ...(spec.env ? { env: spec.env } : {}),
185
+ });
186
+ this.liveProvider.attachTerminalInjector?.(localThreadId, term);
187
+ }
188
+ stopLive() {
189
+ for (const id of this.liveIds)
190
+ this.liveProvider.stopLiveCodexSession?.(id);
191
+ this.liveIds.clear();
192
+ }
193
+ async openTerminal(msg) {
194
+ try {
195
+ // codex/claude-native session (no explicit command): DUMB ATTACH — omnigent's
196
+ // reattach (codex_native.py:905-942, `app_server=None`). A tab switch ONLY
197
+ // attaches an already-live pane; it NEVER ensures the forwarder or relaunches a
198
+ // dead pane. Creation/relaunch happens on message-send (`live.ensure`) or an
199
+ // explicit restart. A missing/dead pane → `term.error`, so the web shows the
200
+ // dead-pane overlay + restart button instead of silently respawning the codex
201
+ // TUI (and re-ensuring the forwarder) on every tab switch.
202
+ if (!msg.command && msg.localThreadId) {
203
+ const existing = this.terminals.get(msg.terminalId);
204
+ if (!existing || !existing.isAlive()) {
205
+ this.transport.send({ t: "term.error", attachId: msg.attachId, message: "terminal not live" });
206
+ return;
207
+ }
208
+ await this.attachExisting(msg);
209
+ return;
210
+ }
211
+ // Login-shell terminal (explicit command): create-or-reuse as before.
212
+ this.terminals.getOrCreate(msg.terminalId, {
213
+ cwd: msg.cwd,
214
+ command: msg.command,
215
+ args: msg.args,
216
+ cols: msg.cols,
217
+ rows: msg.rows,
218
+ });
219
+ await this.attachExisting(msg);
220
+ }
221
+ catch (error) {
222
+ this.transport.send({
223
+ t: "term.error",
224
+ attachId: msg.attachId,
225
+ message: error instanceof Error ? error.message : String(error),
226
+ });
227
+ }
228
+ }
229
+ /** Attach an already-created terminal and forward its data/exit to the parent. */
230
+ async attachExisting(msg) {
231
+ const { attachment, role } = await this.terminals.attach(msg.terminalId, msg.role, {
232
+ cols: msg.cols,
233
+ rows: msg.rows,
234
+ });
235
+ this.attachments.set(msg.attachId, attachment);
236
+ attachment.onData((chunk) => this.transport.send({
237
+ t: "term.data",
238
+ attachId: msg.attachId,
239
+ dataB64: Buffer.from(chunk, "utf8").toString("base64"),
240
+ }));
241
+ attachment.onExit((info) => {
242
+ this.attachments.delete(msg.attachId);
243
+ this.transport.send({ t: "term.exit", attachId: msg.attachId, exitCode: info.exitCode });
244
+ });
245
+ this.transport.send({ t: "term.opened", attachId: msg.attachId, role });
246
+ }
247
+ async runCap(capId, name, args) {
248
+ try {
249
+ const data = await this.dispatchCap(name, args);
250
+ this.transport.send({ t: "capResult", capId, ok: true, data });
251
+ }
252
+ catch (error) {
253
+ this.transport.send({ t: "capResult", capId, ok: false, error: toWireError(error) });
254
+ }
255
+ }
256
+ dispatchCap(name, args) {
257
+ switch (name) {
258
+ case "getGoal":
259
+ return this.executor.getGoal(args[0]);
260
+ case "setGoal":
261
+ return this.executor.setGoal(args[0], args[1]);
262
+ case "clearGoal":
263
+ return this.executor.clearGoal(args[0]);
264
+ case "forkSession":
265
+ return this.executor.forkSession(args[0], args[1]);
266
+ }
267
+ }
268
+ }
@@ -0,0 +1,175 @@
1
+ /**
2
+ * Parent-side runner orchestrator. Replaces {@link LocalAgentHost} at the
3
+ * composition root as the live co-drive + capability surface, but holds **no
4
+ * execution backend** of its own: it spawns one runner child per session
5
+ * (`localThreadId`) that brings up the session's live forwarder + vendor TUI,
6
+ * forwards per-thread capabilities to a shared capability runner, and answers
7
+ * status / model-list locally without a backend.
8
+ *
9
+ * The process boundary carries no live objects: the parent drives a session over
10
+ * the runner transport (`live.ensure` / `inject` / `live.interrupt` + terminal
11
+ * attach), and the child mirrors that session's canonical events back as
12
+ * {@link SessionEvent}s.
13
+ *
14
+ * Today the only transport is local stdio (a spawned child); a remote tunnel
15
+ * transport drops in here later without touching `ConversationRuntime` or any
16
+ * channel.
17
+ */
18
+ import { spawn as nodeSpawn } from "node:child_process";
19
+ import { type AgentCapabilities, type AgentRuntimeId, type AgentSpec, type AppConfig, type CapabilityResult, type ModelListResponse, type SessionEvent, type ThreadGoal } from "@rynx-ai/core";
20
+ import { type CodexSessionStore } from "../host.js";
21
+ import { type InjectOutcome, type TerminalRole } from "./protocol.js";
22
+ export interface RunnerManagerOptions {
23
+ config: AppConfig;
24
+ /** Shared session store (used by the backend-free status probe). */
25
+ sessionStore: CodexSessionStore;
26
+ /** Path to the runner entry; defaults to the built `dist/runner-main.js`. */
27
+ runnerEntry?: string;
28
+ /** Idle TTL (ms) after which an unused runner is reaped. */
29
+ idleTtlMs?: number;
30
+ /** Background reap sweep interval (ms); `0` disables the timer (tests). */
31
+ reapIntervalMs?: number;
32
+ /** Injected for tests. */
33
+ spawn?: typeof nodeSpawn;
34
+ now?: () => number;
35
+ /** Extra env merged into every spawned runner child — e.g. the control-plane
36
+ * URL + token so a claude-native PermissionRequest hook can POST back. */
37
+ childEnv?: Record<string, string>;
38
+ }
39
+ /** A session rotation reported by a runner child (claude `/clear`·`/fork`): the
40
+ * new session `to` is now aliased to `from`'s runner; carries meta to carry over. */
41
+ export interface RotateInfo {
42
+ from: string;
43
+ to: string;
44
+ kind: "clear" | "fork";
45
+ agent?: string;
46
+ model?: string;
47
+ cwd?: string;
48
+ parentSessionId?: string;
49
+ }
50
+ /** Options for {@link RunnerManager.openTerminal}. */
51
+ export interface OpenTerminalOptions {
52
+ role: TerminalRole;
53
+ cwd: string;
54
+ cols: number;
55
+ rows: number;
56
+ /** Inner command for the pane (defaults child-side to the login shell). */
57
+ command?: string;
58
+ args?: string[];
59
+ }
60
+ /**
61
+ * Parent-side handle to a live terminal on a runner child. Bytes flow through
62
+ * {@link onData} / {@link write} (decoded from the base64 wire frames); resize
63
+ * and close are forwarded as control messages. {@link ready} resolves with the
64
+ * granted role once the child attaches (an `owner` request is downgraded to
65
+ * read-only when the owner slot is taken).
66
+ */
67
+ export interface ParentTerminal {
68
+ readonly attachId: string;
69
+ readonly ready: Promise<{
70
+ role: TerminalRole;
71
+ }>;
72
+ onData(listener: (chunk: string) => void): void;
73
+ onExit(listener: (info: {
74
+ exitCode: number;
75
+ }) => void): void;
76
+ write(data: string): void;
77
+ resize(cols: number, rows: number): void;
78
+ close(): void;
79
+ }
80
+ export declare class RunnerManager implements AgentCapabilities {
81
+ private readonly config;
82
+ private readonly sessionStore;
83
+ private readonly runnerEntry;
84
+ private readonly idleTtlMs;
85
+ private readonly spawn;
86
+ private readonly childEnv;
87
+ private readonly now;
88
+ private readonly defaultRuntime;
89
+ private readonly handles;
90
+ private readonly reapTimer;
91
+ /** Sink for mirrored {@link SessionEvent}s from every session's forwarder. */
92
+ private mirrorListener;
93
+ /** Sink for session rotations (claude `/clear`·`/fork`) — server records meta. */
94
+ private rotateListener;
95
+ /** Session keys with a live codex forwarder — never reaped while present. */
96
+ private readonly liveSessionKeys;
97
+ constructor(opts: RunnerManagerOptions);
98
+ /**
99
+ * Open a live terminal on the session's runner child (spawning it if needed).
100
+ * Phase C hosts one terminal ("main") per session; the returned handle is a
101
+ * single attach client — `owner` (read-write) or a downgraded `read-only`
102
+ * viewer per the child's ownership rule. Not part of `AgentExecutor`; the WS
103
+ * bridge calls it directly.
104
+ */
105
+ openTerminal(localThreadId: string, opts: OpenTerminalOptions): ParentTerminal;
106
+ /**
107
+ * Register the sink for mirrored {@link SessionEvent}s produced by every
108
+ * session's persistent codex forwarder (web- AND TUI-initiated turns). The
109
+ * server wires this to `persistSessionEvent` (bus + canonical log).
110
+ */
111
+ onMirror(listener: (sessionId: string, event: SessionEvent) => void): void;
112
+ /** Register the sink for session rotations (claude `/clear`·`/fork`): the server
113
+ * records the new session's meta (carry-over agent/model/title). */
114
+ onRotate(listener: (rotation: RotateInfo) => void): void;
115
+ /**
116
+ * Eagerly bring up a session's codex-native live view (persistent forwarder +
117
+ * detached `codex --remote` TUI) in its runner child, spawning the runner if
118
+ * needed. Idempotent. Resolves true once the codex thread is bound; false for a
119
+ * non-codex / non-live session (the caller then uses the normal run path).
120
+ */
121
+ ensureLiveSession(localThreadId: string, opts?: {
122
+ cwd?: string;
123
+ cols?: number;
124
+ rows?: number;
125
+ runtime?: AgentRuntimeId;
126
+ agentName?: string;
127
+ agentSpec?: AgentSpec;
128
+ }): Promise<boolean>;
129
+ /**
130
+ * Inject a user turn into a session's live codex thread — omnigent's
131
+ * single-writer web send (`turn/start` / `turn/steer`); the forwarder mirrors
132
+ * all output. Resolves true when the app-server accepted the turn, false when
133
+ * the session has no live forwarder (caller falls back to the run path).
134
+ */
135
+ injectMessage(localThreadId: string, text: string): Promise<InjectOutcome>;
136
+ /**
137
+ * Interrupt a session's active live turn — the web Stop button (codex
138
+ * `turn/interrupt`, claude Escape). Best-effort: resolves false with NO spawn
139
+ * when the session has no live runner (nothing to interrupt).
140
+ */
141
+ interruptLiveSession(localThreadId: string): Promise<boolean>;
142
+ /**
143
+ * Deliver a user's interactive-approval decision to the session's runner
144
+ * child (Phase D). Best-effort: no-op if the runner isn't live (the pending
145
+ * approval would have died with it). The child routes it to its codex client.
146
+ */
147
+ resolveApproval(localThreadId: string, approvalId: string, decision: "acceptForSession" | "accept" | "decline" | "cancel"): void;
148
+ listModels(runtime?: AgentRuntimeId): Promise<ModelListResponse | null>;
149
+ getGoal(localThreadId: string): Promise<CapabilityResult<ThreadGoal | null>>;
150
+ setGoal(localThreadId: string, objective: string): Promise<CapabilityResult>;
151
+ clearGoal(localThreadId: string): Promise<CapabilityResult>;
152
+ forkSession(currentLocalThreadId: string, newLocalThreadId: string): Promise<CapabilityResult>;
153
+ /**
154
+ * Backend-free runtime readiness (not part of `AgentCapabilities`; surfaced for
155
+ * the control console). Never spawns a runner.
156
+ */
157
+ getStatus(options?: {
158
+ force?: boolean;
159
+ runtime?: AgentRuntimeId;
160
+ }): Promise<import("../host.js").CodexRuntimeStatus>;
161
+ /** Stop the runner bound to one session (if any), rejecting its in-flight work. */
162
+ stopRunner(localThreadId: string): void;
163
+ /** Kill every runner. Call on server shutdown. */
164
+ stop(): Promise<void>;
165
+ /** Forward a capability to a runner child. Session-less caps (listModels/status)
166
+ * use the shared `CAP_KEY` child; per-thread caps pass the session's key so they
167
+ * run on that session's child (which owns its private CODEX_HOME). */
168
+ private forwardCap;
169
+ private getOrSpawn;
170
+ private spawnHandle;
171
+ private onChildMessage;
172
+ /** Mark a handle dead and reject every pending run/cap with the exit reason. */
173
+ private failHandle;
174
+ private reapIdle;
175
+ }