@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,36 @@
1
+ import type { Readable, Writable } from "node:stream";
2
+ import { type FromChild, type ToChild } from "./protocol.js";
3
+ export interface RunnerTransport<TSend, TRecv> {
4
+ /** Send one message. Best-effort: a dead peer is surfaced via {@link onClose}. */
5
+ send(msg: TSend): void;
6
+ /** Register the single message handler. */
7
+ onMessage(cb: (msg: TRecv) => void): void;
8
+ /** Register a close/error handler (peer stream ended). */
9
+ onClose(cb: (error?: Error) => void): void;
10
+ /** Stop reading and release the readline interface. */
11
+ close(): void;
12
+ }
13
+ /** Parent-side transport: sends {@link ToChild}, receives {@link FromChild}. */
14
+ export type ParentTransport = RunnerTransport<ToChild, FromChild>;
15
+ /** Child-side transport: sends {@link FromChild}, receives {@link ToChild}. */
16
+ export type ChildTransport = RunnerTransport<FromChild, ToChild>;
17
+ /**
18
+ * NDJSON-over-streams transport. `input` is line-read for inbound messages;
19
+ * `output` receives one JSON line per {@link send}. Used on both ends:
20
+ * - parent: `input = child.stdout`, `output = child.stdin`
21
+ * - child: `input = process.stdin`, `output = process.stdout`
22
+ */
23
+ export declare class StdioRunnerTransport<TSend, TRecv> implements RunnerTransport<TSend, TRecv> {
24
+ private readonly input;
25
+ private readonly output;
26
+ private readonly rl;
27
+ private messageHandler;
28
+ private closeHandler;
29
+ private closed;
30
+ constructor(input: Readable, output: Writable);
31
+ send(msg: TSend): void;
32
+ onMessage(cb: (msg: TRecv) => void): void;
33
+ onClose(cb: (error?: Error) => void): void;
34
+ close(): void;
35
+ private handleClose;
36
+ }
@@ -0,0 +1,72 @@
1
+ /**
2
+ * Duplex message transport between the parent and a runner child. Abstracted so
3
+ * the {@link import("./manager.js").RunnerManager} and
4
+ * {@link import("./child.js").RunnerSession} depend only on this seam: today the
5
+ * only implementation is {@link StdioRunnerTransport} (NDJSON over a pair of
6
+ * streams — child stdin/stdout locally), and a remote tunnel transport can drop
7
+ * in later without touching either side.
8
+ *
9
+ * The NDJSON-over-stream framing (readline per line, write a JSON line) mirrors
10
+ * the codex app-server transport (`codex-app-server/transport.ts`).
11
+ */
12
+ import readline from "node:readline";
13
+ import { decodeMessage, encodeMessage } from "./protocol.js";
14
+ /**
15
+ * NDJSON-over-streams transport. `input` is line-read for inbound messages;
16
+ * `output` receives one JSON line per {@link send}. Used on both ends:
17
+ * - parent: `input = child.stdout`, `output = child.stdin`
18
+ * - child: `input = process.stdin`, `output = process.stdout`
19
+ */
20
+ export class StdioRunnerTransport {
21
+ input;
22
+ output;
23
+ rl;
24
+ messageHandler = null;
25
+ closeHandler = null;
26
+ closed = false;
27
+ constructor(input, output) {
28
+ this.input = input;
29
+ this.output = output;
30
+ // A broken pipe (peer died) must not throw on the writable — surface it as a
31
+ // close instead, so the manager/session can clean up the run.
32
+ this.output.on("error", (error) => {
33
+ if (error.code === "EPIPE") {
34
+ this.handleClose();
35
+ return;
36
+ }
37
+ this.handleClose(error);
38
+ });
39
+ this.rl = readline.createInterface({ input: this.input, crlfDelay: Infinity });
40
+ this.rl.on("line", (line) => {
41
+ const msg = decodeMessage(line);
42
+ if (msg && this.messageHandler) {
43
+ this.messageHandler(msg);
44
+ }
45
+ });
46
+ this.rl.on("close", () => this.handleClose());
47
+ this.input.on("error", (error) => this.handleClose(error));
48
+ }
49
+ send(msg) {
50
+ if (this.closed) {
51
+ return;
52
+ }
53
+ this.output.write(encodeMessage(msg));
54
+ }
55
+ onMessage(cb) {
56
+ this.messageHandler = cb;
57
+ }
58
+ onClose(cb) {
59
+ this.closeHandler = cb;
60
+ }
61
+ close() {
62
+ this.closed = true;
63
+ this.rl.close();
64
+ }
65
+ handleClose(error) {
66
+ if (this.closed) {
67
+ return;
68
+ }
69
+ this.closed = true;
70
+ this.closeHandler?.(error);
71
+ }
72
+ }
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env node
2
+ export {};
@@ -0,0 +1,61 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Runner child entry point. The parent {@link import("./runner/manager.js").RunnerManager}
4
+ * spawns one of these per session (`localThreadId`) and talks to it over
5
+ * stdin/stdout (NDJSON protocol). This process owns the session's single
6
+ * execution backend (codex app-server / claude PTY) via {@link LocalAgentHost}.
7
+ *
8
+ * stdout is reserved for the protocol — every log MUST go to stderr (which the
9
+ * parent captures for the crash exit-report). We route `console.log/info/debug`
10
+ * to stderr up front so a stray log can't corrupt the NDJSON stream.
11
+ *
12
+ * CodexSessionStore note: this child reads/writes the same on-disk store as the
13
+ * parent (decision A). Because there is one runner per session, the child is the
14
+ * sole writer of its own session record (codexSessionId / fork), so cross-process
15
+ * lost-update risk is limited to the parent's read-only access (mirror index /
16
+ * active-session display, which tolerate atomic-rename reads). A parent-of-record
17
+ * design (ship-in / emit-out) is the remote-ready follow-up.
18
+ */
19
+ import { loadConfig, } from "@rynx-ai/core";
20
+ import { FileCodexSessionStore, LocalAgentHost, resolveCodexSessionStorePath, } from "./host.js";
21
+ import { RunnerSession } from "./runner/child.js";
22
+ import { StdioRunnerTransport } from "./runner/transport.js";
23
+ // stdout is the protocol channel — keep all logging on stderr.
24
+ console.log = console.error.bind(console);
25
+ console.info = console.error.bind(console);
26
+ console.debug = console.error.bind(console);
27
+ // Under a parent pipe a broken stdout/stderr (parent gone) must not crash us
28
+ // with an unhandled stream 'error'; we exit on transport close instead.
29
+ for (const stream of [process.stdout, process.stderr]) {
30
+ stream.on("error", (err) => {
31
+ if (err.code === "EPIPE")
32
+ return;
33
+ throw err;
34
+ });
35
+ }
36
+ function main() {
37
+ const config = loadConfig();
38
+ const sessionStore = new FileCodexSessionStore(resolveCodexSessionStorePath(config));
39
+ const executor = new LocalAgentHost({
40
+ config,
41
+ sessionStore,
42
+ // The manager spawns one child per session and passes its key here so the
43
+ // host's private CODEX_HOME is scoped to this session (see manager.spawnHandle).
44
+ ...(process.env.RYNX_RUNNER_SESSION ? { sessionId: process.env.RYNX_RUNNER_SESSION } : {}),
45
+ });
46
+ const transport = new StdioRunnerTransport(process.stdin, process.stdout);
47
+ transport.onClose(() => process.exit(0));
48
+ // eslint-disable-next-line no-new -- the session wires itself to the transport.
49
+ new RunnerSession({
50
+ transport,
51
+ executor,
52
+ onShutdown: () => {
53
+ transport.close();
54
+ process.exit(0);
55
+ },
56
+ });
57
+ // A parent SIGTERM (idle reap / shutdown) ends the process; the OS tears down
58
+ // the child app-server / PTY with it.
59
+ process.on("SIGTERM", () => process.exit(0));
60
+ }
61
+ main();
@@ -0,0 +1,16 @@
1
+ import { type AgentRuntimeId, type AppConfig } from "@rynx-ai/core";
2
+ import { type CodexCommandRunner, type CodexRuntimeStatus } from "./host.js";
3
+ import { type CodexSessionStore } from "./codex-session-store.js";
4
+ export interface ProbeRuntimeStatusDeps {
5
+ /** Override the codex/traex command runner (tests inject a fake). */
6
+ commandRunner?: CodexCommandRunner;
7
+ env?: NodeJS.ProcessEnv;
8
+ }
9
+ /**
10
+ * Probe a runtime's readiness without instantiating an execution backend.
11
+ *
12
+ * @param runtime which local runtime to probe
13
+ * @param config app config (for binary resolution)
14
+ * @param sessionStore the shared session store (writability check)
15
+ */
16
+ export declare function probeRuntimeStatus(runtime: AgentRuntimeId, config: AppConfig, sessionStore: CodexSessionStore, deps?: ProbeRuntimeStatusDeps): Promise<CodexRuntimeStatus>;
@@ -0,0 +1,80 @@
1
+ /**
2
+ * Backend-free runtime readiness probe.
3
+ *
4
+ * The parent control plane no longer holds an execution backend (runners own
5
+ * the codex app-server / claude PTY), so it must answer "is this runtime usable
6
+ * on this machine?" without spinning one up. This mirrors omnigent's model,
7
+ * where readiness is `which(binary) + a usable credential` rather than a live
8
+ * app-server. For codex/traex that is a one-shot `<bin> login status` (no
9
+ * app-server); for claude it reuses {@link readClaudeStatus} (SDK + credentials).
10
+ *
11
+ * It returns the same {@link CodexRuntimeStatus} shape the host's status probe
12
+ * produces, so a future control console renders one status regardless of where
13
+ * execution happens.
14
+ */
15
+ import { access } from "node:fs/promises";
16
+ import { constants as fsConstants } from "node:fs";
17
+ import { getRuntimeProfile, resolveRuntimeBinary, resolveRuntimeHome, resolveRuntimeSessionsRoot, toErrorMessage, } from "@rynx-ai/core";
18
+ import { readClaudeStatus } from "./claude/status.js";
19
+ import { SpawnCodexCommandRunner, parseCodexLoginStatus, } from "./host.js";
20
+ /**
21
+ * Probe a runtime's readiness without instantiating an execution backend.
22
+ *
23
+ * @param runtime which local runtime to probe
24
+ * @param config app config (for binary resolution)
25
+ * @param sessionStore the shared session store (writability check)
26
+ */
27
+ export async function probeRuntimeStatus(runtime, config, sessionStore, deps = {}) {
28
+ if (runtime === "claude") {
29
+ return readClaudeStatus({ sessionStore, env: deps.env });
30
+ }
31
+ const profile = getRuntimeProfile(runtime);
32
+ const name = profile.displayName;
33
+ const issues = [];
34
+ let codexAvailable = true;
35
+ let loggedIn = false;
36
+ let authMode = null;
37
+ const commandRunner = deps.commandRunner ?? new SpawnCodexCommandRunner(resolveRuntimeBinary(runtime));
38
+ try {
39
+ const result = await commandRunner.capture({ args: ["login", "status"] });
40
+ const parsed = parseCodexLoginStatus(result.exitCode, `${result.stdout}\n${result.stderr}`.trim());
41
+ loggedIn = parsed.loggedIn;
42
+ authMode = parsed.authMode;
43
+ issues.push(...parsed.issues);
44
+ }
45
+ catch (error) {
46
+ codexAvailable = false;
47
+ issues.push(`${name} CLI is unavailable: ${toErrorMessage(error)}`);
48
+ }
49
+ const sessionStoreWritable = await sessionStore.isWritable();
50
+ if (!sessionStoreWritable) {
51
+ issues.push(`Session store is not writable: ${sessionStore.filePath}`);
52
+ }
53
+ const sessionsAccessible = await checkRuntimeSessionsAccessible(profile);
54
+ if (!sessionsAccessible) {
55
+ issues.push(`${name} session directory ${resolveRuntimeSessionsRoot(profile)} is not accessible to this process.`);
56
+ }
57
+ return {
58
+ codex_available: codexAvailable,
59
+ logged_in: loggedIn,
60
+ auth_mode: authMode,
61
+ session_store_writable: sessionStoreWritable,
62
+ codex_sessions_accessible: sessionsAccessible,
63
+ issues,
64
+ };
65
+ }
66
+ /** The runtime's rollout `sessions/` dir (or its home) must be read+write accessible. */
67
+ async function checkRuntimeSessionsAccessible(profile) {
68
+ const sessionsPath = resolveRuntimeSessionsRoot(profile);
69
+ const runtimeHome = resolveRuntimeHome(profile);
70
+ for (const target of [sessionsPath, runtimeHome]) {
71
+ try {
72
+ await access(target, fsConstants.R_OK | fsConstants.W_OK);
73
+ return true;
74
+ }
75
+ catch {
76
+ // Try the next candidate (sessions dir may not exist yet; home is enough).
77
+ }
78
+ }
79
+ return false;
80
+ }
@@ -0,0 +1,27 @@
1
+ /**
2
+ * claude-native interactive TUI launcher — the claude analogue of
3
+ * {@link ./codex-tui.ts}. This launches the real interactive `claude` TUI in the
4
+ * session's tmux pane so it can be co-driven: the web injects via tmux send-keys
5
+ * and its transcript mirrors back to chat.
6
+ *
7
+ * The `--settings` flag carries rynx's claude-native hooks (see
8
+ * {@link ../claude/native-hooks.ts}); approvals flow through the PermissionRequest
9
+ * hook, so NO `--dangerously-skip-permissions` is passed (unlike a blind headless
10
+ * run). Trust pre-seeding (`ensureProjectTrusted`) is done by the caller before
11
+ * launch so the daemon-spawned TUI never blocks on a first-run prompt.
12
+ */
13
+ export interface ClaudeTuiArgs {
14
+ /** The claude `--settings` JSON (hook registration). */
15
+ settingsJson: string;
16
+ /** Launch model (`--model`); omit to use claude's default. */
17
+ model?: string;
18
+ /** Resume a specific prior claude session (`--resume <id>`). */
19
+ resume?: string;
20
+ /** Extra claude args placed before the injected flags (rarely needed). */
21
+ extraArgs?: string[];
22
+ }
23
+ /**
24
+ * Build the `claude` argv for an interactive, co-drivable TUI:
25
+ * `[..extra] [--resume <id>] [--model <m>] --settings <json>`.
26
+ */
27
+ export declare function buildClaudeTuiArgs({ settingsJson, model, resume, extraArgs, }: ClaudeTuiArgs): string[];
@@ -0,0 +1,13 @@
1
+ /**
2
+ * Build the `claude` argv for an interactive, co-drivable TUI:
3
+ * `[..extra] [--resume <id>] [--model <m>] --settings <json>`.
4
+ */
5
+ export function buildClaudeTuiArgs({ settingsJson, model, resume, extraArgs = [], }) {
6
+ const args = [...extraArgs];
7
+ if (resume)
8
+ args.push("--resume", resume);
9
+ if (model)
10
+ args.push("--model", model);
11
+ args.push("--settings", settingsJson);
12
+ return args;
13
+ }
@@ -0,0 +1,54 @@
1
+ /**
2
+ * codex-native live-terminal launcher (Phase D). Encodes omnigent's verified
3
+ * co-drive recipe: a separate `codex` TUI process attaches to the session's
4
+ * already-running app-server over `--remote` and resumes the SAME thread, so the
5
+ * TUI, the structured data channel, and the web bridge all drive one thread.
6
+ *
7
+ * Two facts carried over from omnigent's working implementation
8
+ * (`codex_native_app_server.build_codex_remote_args`):
9
+ * - the app-server must listen on a multi-client transport (`ws://IP:PORT` or
10
+ * `unix://PATH`) — stdio only admits the one client that spawned it, so it
11
+ * cannot be co-driven;
12
+ * - the `--remote` TUI is a separate process that loads its OWN config and does
13
+ * NOT inherit the app-server's `-c` flags. Without the same provider overrides
14
+ * it falls back to the built-in OpenAI provider and renders the "Sign in with
15
+ * ChatGPT" onboarding, never starting a thread. So the provider `-c` overrides
16
+ * are passed through, and global `-c` flags must precede the `resume`
17
+ * subcommand. (Ambient `codex login` state is codex's own concern — a logged-in
18
+ * CLI just works; a logged-out one shows codex's own onboarding.)
19
+ */
20
+ import type { TerminalRegistry } from "./registry.js";
21
+ export interface CodexRemoteArgs {
22
+ /** App-server endpoint the TUI attaches to, e.g. `ws://127.0.0.1:9876` or
23
+ * `unix:///tmp/rynx/app-server.sock`. */
24
+ remoteUrl: string;
25
+ /** Codex thread id to resume; omit to start a fresh remote thread. */
26
+ threadId?: string;
27
+ /** Codex `-c key=value` provider/model overrides (same set the app-server was
28
+ * launched with) so the TUI resolves the same provider and skips onboarding. */
29
+ configOverrides?: string[];
30
+ /** Extra codex args that precede the attach flags (e.g. `["--model", "..."]`). */
31
+ codexArgs?: string[];
32
+ }
33
+ /**
34
+ * Build the `codex` argv tail for an app-server-backed TUI. Mirrors omnigent's
35
+ * `build_codex_remote_args` exactly: overrides → codexArgs → (resume) → --remote.
36
+ */
37
+ export declare function buildCodexRemoteArgs({ remoteUrl, threadId, configOverrides, codexArgs, }: CodexRemoteArgs): string[];
38
+ export interface LaunchCodexTuiOptions extends CodexRemoteArgs {
39
+ registry: TerminalRegistry;
40
+ /** Terminal id (one codex TUI per session — `terminal_codex_main`). */
41
+ terminalId: string;
42
+ cwd: string;
43
+ /** codex executable (runtime-resolved binary path). */
44
+ codexBin: string;
45
+ env?: Record<string, string>;
46
+ cols?: number;
47
+ rows?: number;
48
+ }
49
+ /**
50
+ * Create (idempotently) the codex TUI terminal in the registry, running the real
51
+ * `codex` CLI attached to the session's app-server. The Phase-C WS bridge then
52
+ * displays and co-drives it like any other terminal.
53
+ */
54
+ export declare function launchCodexTui(opts: LaunchCodexTuiOptions): void;
@@ -0,0 +1,26 @@
1
+ /**
2
+ * Build the `codex` argv tail for an app-server-backed TUI. Mirrors omnigent's
3
+ * `build_codex_remote_args` exactly: overrides → codexArgs → (resume) → --remote.
4
+ */
5
+ export function buildCodexRemoteArgs({ remoteUrl, threadId, configOverrides = [], codexArgs = [], }) {
6
+ const overrideArgs = configOverrides.flatMap((override) => ["-c", override]);
7
+ if (threadId === undefined) {
8
+ return [...overrideArgs, ...codexArgs, "--remote", remoteUrl];
9
+ }
10
+ return [...overrideArgs, ...codexArgs, "resume", "--remote", remoteUrl, threadId];
11
+ }
12
+ /**
13
+ * Create (idempotently) the codex TUI terminal in the registry, running the real
14
+ * `codex` CLI attached to the session's app-server. The Phase-C WS bridge then
15
+ * displays and co-drives it like any other terminal.
16
+ */
17
+ export function launchCodexTui(opts) {
18
+ opts.registry.getOrCreate(opts.terminalId, {
19
+ cwd: opts.cwd,
20
+ command: opts.codexBin,
21
+ args: buildCodexRemoteArgs(opts),
22
+ env: opts.env,
23
+ cols: opts.cols,
24
+ rows: opts.rows,
25
+ });
26
+ }
@@ -0,0 +1,42 @@
1
+ /**
2
+ * Per-runner registry of live {@link TmuxTerminal}s, keyed by terminal id.
3
+ *
4
+ * Owns terminal lifecycle. Read-only vs read-write is decided PURELY by the
5
+ * requested role (the caller derives it from the viewer's permission level),
6
+ * exactly like omnigent's terminal attach (`terminal_attach.py`: `read_only`
7
+ * from the URL → `-r`). There is deliberately NO stateful "first owner wins,
8
+ * later owners downgraded" slot: that rynx-only mechanism leaked its owner slot
9
+ * across a detach (the release ran on the attachment's `onExit`, which the
10
+ * runner child's own `onExit` clobbered), so a tab switch away then back
11
+ * re-attached read-only and silently dropped every keystroke. Multiple
12
+ * read-write owners coexisting is fine — tmux merges their input.
13
+ */
14
+ import { TmuxTerminal, type TerminalAttachment, type TmuxTerminalOptions } from "./tmux.js";
15
+ export type TerminalRole = "owner" | "read-only";
16
+ export interface AttachResult {
17
+ attachment: TerminalAttachment;
18
+ /** The granted role — always the requested role (no downgrade). */
19
+ role: TerminalRole;
20
+ }
21
+ export declare class TerminalRegistry {
22
+ private readonly terminals;
23
+ get(id: string): TmuxTerminal | undefined;
24
+ has(id: string): boolean;
25
+ /** Create + start a terminal under `id`, reusing a LIVE existing one. If the
26
+ * existing terminal's tmux session died (its TUI process exited), drop it and
27
+ * relaunch — so a reconnect after the pane died restarts the terminal instead
28
+ * of attaching to a dead session. `name` is derived from the id so the tmux
29
+ * socket is unique per terminal. */
30
+ getOrCreate(id: string, opts: Omit<TmuxTerminalOptions, "name">): TmuxTerminal;
31
+ /** Attach a client at the requested role (`owner` → read-write, `read-only`
32
+ * → `tmux attach -r`). Throws if the terminal id is unknown (create it
33
+ * first). */
34
+ attach(id: string, requestedRole: TerminalRole, dims?: {
35
+ cols?: number;
36
+ rows?: number;
37
+ }): Promise<AttachResult>;
38
+ /** Kill a terminal's tmux server and drop it. */
39
+ close(id: string): void;
40
+ /** Kill every terminal (runner shutdown). */
41
+ closeAll(): void;
42
+ }
@@ -0,0 +1,70 @@
1
+ /**
2
+ * Per-runner registry of live {@link TmuxTerminal}s, keyed by terminal id.
3
+ *
4
+ * Owns terminal lifecycle. Read-only vs read-write is decided PURELY by the
5
+ * requested role (the caller derives it from the viewer's permission level),
6
+ * exactly like omnigent's terminal attach (`terminal_attach.py`: `read_only`
7
+ * from the URL → `-r`). There is deliberately NO stateful "first owner wins,
8
+ * later owners downgraded" slot: that rynx-only mechanism leaked its owner slot
9
+ * across a detach (the release ran on the attachment's `onExit`, which the
10
+ * runner child's own `onExit` clobbered), so a tab switch away then back
11
+ * re-attached read-only and silently dropped every keystroke. Multiple
12
+ * read-write owners coexisting is fine — tmux merges their input.
13
+ */
14
+ import { TmuxTerminal } from "./tmux.js";
15
+ export class TerminalRegistry {
16
+ terminals = new Map();
17
+ get(id) {
18
+ return this.terminals.get(id);
19
+ }
20
+ has(id) {
21
+ return this.terminals.has(id);
22
+ }
23
+ /** Create + start a terminal under `id`, reusing a LIVE existing one. If the
24
+ * existing terminal's tmux session died (its TUI process exited), drop it and
25
+ * relaunch — so a reconnect after the pane died restarts the terminal instead
26
+ * of attaching to a dead session. `name` is derived from the id so the tmux
27
+ * socket is unique per terminal. */
28
+ getOrCreate(id, opts) {
29
+ const existing = this.terminals.get(id);
30
+ if (existing && existing.isAlive())
31
+ return existing;
32
+ if (existing) {
33
+ // Dead pane (its TUI exited) — kill the husk + drop it before relaunching.
34
+ try {
35
+ existing.kill();
36
+ }
37
+ catch {
38
+ // already gone — fine
39
+ }
40
+ this.terminals.delete(id);
41
+ }
42
+ const terminal = new TmuxTerminal({ ...opts, name: id });
43
+ terminal.start();
44
+ this.terminals.set(id, terminal);
45
+ return terminal;
46
+ }
47
+ /** Attach a client at the requested role (`owner` → read-write, `read-only`
48
+ * → `tmux attach -r`). Throws if the terminal id is unknown (create it
49
+ * first). */
50
+ async attach(id, requestedRole, dims) {
51
+ const terminal = this.terminals.get(id);
52
+ if (!terminal)
53
+ throw new Error(`terminal ${id} does not exist`);
54
+ const attachment = await terminal.attach(requestedRole, dims);
55
+ return { attachment, role: requestedRole };
56
+ }
57
+ /** Kill a terminal's tmux server and drop it. */
58
+ close(id) {
59
+ const terminal = this.terminals.get(id);
60
+ if (!terminal)
61
+ return;
62
+ terminal.kill();
63
+ this.terminals.delete(id);
64
+ }
65
+ /** Kill every terminal (runner shutdown). */
66
+ closeAll() {
67
+ for (const id of [...this.terminals.keys()])
68
+ this.close(id);
69
+ }
70
+ }
@@ -0,0 +1,150 @@
1
+ /** A live attach handle: the pane's bytes flow through `onData`; caller input
2
+ * goes through `write`; `resize` reflows the pane; `kill` ends this client
3
+ * (not the server). Shape mirrors the node-pty surface the WS bridge needs. */
4
+ export interface TerminalAttachment {
5
+ onData(listener: (chunk: string) => void): void;
6
+ onExit(listener: (info: {
7
+ exitCode: number;
8
+ }) => void): void;
9
+ write(data: string): void;
10
+ resize(cols: number, rows: number): void;
11
+ /** Detach this client. The tmux server + pane keep running. */
12
+ kill(): void;
13
+ }
14
+ /** Minimal node-pty surface (kept local so this module has no type dep on it). */
15
+ interface PtyProcess {
16
+ onData(cb: (data: string) => void): void;
17
+ onExit(cb: (e: {
18
+ exitCode: number;
19
+ }) => void): void;
20
+ write(data: string): void;
21
+ resize(cols: number, rows: number): void;
22
+ kill(signal?: string): void;
23
+ }
24
+ type PtySpawn = (file: string, args: string[], opts: {
25
+ name: string;
26
+ cols: number;
27
+ rows: number;
28
+ cwd: string;
29
+ env: Record<string, string>;
30
+ }) => PtyProcess;
31
+ export interface TmuxTerminalOptions {
32
+ /** Unique name for the tmux session + socket, e.g. `rynx-<sessionId>-<termId>`. */
33
+ name: string;
34
+ cwd: string;
35
+ /** Inner command to run in the pane. Defaults to the login shell. */
36
+ command?: string;
37
+ args?: string[];
38
+ env?: Record<string, string>;
39
+ cols?: number;
40
+ rows?: number;
41
+ /** Injectable for tests; defaults to the real node-pty spawn. */
42
+ ptySpawn?: PtySpawn;
43
+ /** Injectable for tests; defaults to the real `tmux` binary path. */
44
+ tmuxBin?: string;
45
+ }
46
+ /** Is a usable `tmux` on PATH? Cheap probe for the capability gate. */
47
+ export declare function isTmuxAvailable(tmuxBin?: string): boolean;
48
+ export declare class TmuxTerminal {
49
+ readonly name: string;
50
+ readonly socketPath: string;
51
+ private readonly cwd;
52
+ private readonly command;
53
+ private readonly args;
54
+ private readonly env;
55
+ private readonly cols;
56
+ private readonly rows;
57
+ private readonly tmuxBin;
58
+ private readonly injectedSpawn?;
59
+ private started;
60
+ constructor(opts: TmuxTerminalOptions);
61
+ /** tmux argv prefix targeting this terminal's private server. */
62
+ private base;
63
+ /** Create the private tmux server + detached session running the inner
64
+ * command. Idempotent: a second call is a no-op once the session exists. */
65
+ start(): void;
66
+ /**
67
+ * Apply omnigent's tmux option suite to the private server (inner/terminal.py).
68
+ * These are NOT cosmetic — several fix real co-drive behavior that the tmux
69
+ * defaults break:
70
+ * - `mouse on`: the web terminal's wheel scrolls the pane's scrollback (and
71
+ * mouse events reach a TUI that requests them). Without it, no scrolling.
72
+ * - `extended-keys on` + `csi-u`: tmux forwards Kitty Keyboard Protocol / CSI-u
73
+ * keys (Ctrl+C, Shift+Enter, modified keys) that codex/claude TUIs request —
74
+ * without it tmux downgrades them and the vendor TUI mis-reads modifiers.
75
+ * - `escape-time 0`: kills tmux's default 500 ms wait after ESC, which otherwise
76
+ * makes arrow keys / Alt-combos / pasted CSI feel laggy or mis-parse.
77
+ * - `prefix None` + `prefix2 None` + unbind the prefix table: the user's
78
+ * keystrokes (notably C-b) go to the pane, never tmux — a co-drive terminal
79
+ * must not intercept a prefix.
80
+ * - `focus-events on`, `allow-passthrough on`, `history-limit`: focus reporting,
81
+ * passthrough sequences, scrollback depth.
82
+ * - `remain-on-exit on` + `exit-empty off`: keep the dead pane + server after the
83
+ * inner CLI exits so its last output stays capturable and `#{pane_dead}` reads
84
+ * the exit (liveness probe), instead of the server vanishing.
85
+ * - `MouseDown3*` unbinds: no right-click menu to spawn extra panes/windows.
86
+ * - `status off`: hide tmux chrome. omnigent keeps the status line only to show
87
+ * a conversation link; rynx has none, so the whole line (and its
88
+ * `[main] 0:node*` window list) is hidden.
89
+ * `-q`/`-gq`/`-sq` keep an older tmux that lacks an option from failing launch.
90
+ * Batched into one invocation with `;` command separators (one spawn).
91
+ */
92
+ private configureSession;
93
+ /** Whether the terminal's INNER PROCESS is still running. Probes the pane's
94
+ * `#{pane_dead}` flag rather than mere session existence: with
95
+ * `remain-on-exit on` the session/server deliberately outlive the inner CLI's
96
+ * exit (a dead pane shows tmux's "Pane is dead"), so `has-session` succeeding
97
+ * no longer implies a live process. Alive only when the session exists AND its
98
+ * pane process has not exited. Mirrors omnigent's `_terminal.is_alive`. */
99
+ isAlive(): boolean;
100
+ /** Async pane-liveness probe — MUST NOT block the event loop. The attach
101
+ * pane-death watcher polls this on an interval; a synchronous `execFileSync`
102
+ * there stalls the runner child's event loop (freezing the PTY stream → the
103
+ * terminal appears "stuck"). omnigent's `_tmux_session_alive` uses an async
104
+ * subprocess + timeout for exactly this reason. */
105
+ private isAliveAsync;
106
+ /** Type literal text into the pane (agent injection / co-drive from a
107
+ * non-PTY caller). `-l` sends the text literally rather than as key names. */
108
+ sendKeys(text: string): void;
109
+ /**
110
+ * The visible pane text (synchronous). Used by the claude-native injection
111
+ * path as a ready-gate (poll for the `❯` prompt glyph before typing) and to
112
+ * verify a pasted draft landed / left the input box. Returns "" on failure so
113
+ * a dead pane just reads as empty rather than throwing mid-poll.
114
+ */
115
+ capturePane(): string;
116
+ /** Send a submit Enter as a KEY NAME (no `-l`), committing the input line.
117
+ * Kept separate from the paste so a multi-line paste isn't folded into a
118
+ * single submit (claude coalesces a rapid stdin burst into a paste). */
119
+ sendEnter(): void;
120
+ /** Interrupt the pane's running TUI turn with an Escape key — codex/claude both
121
+ * cancel an in-flight response on a single Esc ("esc to interrupt"). A key NAME
122
+ * (no `-l`) so tmux interprets it. Mirrors omnigent's `inject_interrupt`. */
123
+ interrupt(): void;
124
+ /** Clear the current input line before an injection so leftover keystrokes
125
+ * can't prepend to the pasted draft. `C-a` (Home) + `C-k` (kill-to-end) is
126
+ * the safe pair — `C-u` only clears backwards from the cursor (omnigent). */
127
+ clearInputLine(): void;
128
+ /** Send one or more tmux key NAMES (e.g. `Enter`, `C-a`) to the pane. */
129
+ private sendKeyNames;
130
+ /**
131
+ * Deliver `text` into the pane as a bracketed paste — `load-buffer` (from
132
+ * stdin, no temp file) then `paste-buffer -p` (bracketed markers keep newlines
133
+ * as data, so claude treats it as one pasted draft, not N submits) and `-d`
134
+ * (drop the buffer after). This is the injection body; the caller sends a
135
+ * separate {@link sendEnter} to submit. Handles multi-line + large messages
136
+ * that would overflow a `send-keys` argv.
137
+ */
138
+ paste(text: string, bufferName?: string): void;
139
+ /**
140
+ * Attach a client. `role: "read-only"` passes tmux `-r` so the viewer cannot
141
+ * type (defense-in-depth on top of the WS bridge dropping input frames).
142
+ */
143
+ attach(role: "owner" | "read-only", dims?: {
144
+ cols?: number;
145
+ rows?: number;
146
+ }): Promise<TerminalAttachment>;
147
+ /** Kill the tmux server (ends the session and all attaches). */
148
+ kill(): void;
149
+ }
150
+ export {};