@rynx-ai/runtime 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/claude/executor.d.ts +17 -0
- package/dist/claude/executor.js +28 -0
- package/dist/claude/models.d.ts +10 -0
- package/dist/claude/models.js +33 -0
- package/dist/claude/native-bridge.d.ts +133 -0
- package/dist/claude/native-bridge.js +299 -0
- package/dist/claude/native-hook-main.d.ts +2 -0
- package/dist/claude/native-hook-main.js +74 -0
- package/dist/claude/native-hooks.d.ts +41 -0
- package/dist/claude/native-hooks.js +73 -0
- package/dist/claude/native-integration.d.ts +213 -0
- package/dist/claude/native-integration.js +665 -0
- package/dist/claude/native-message-display-main.d.ts +2 -0
- package/dist/claude/native-message-display-main.js +51 -0
- package/dist/claude/native-status-main.d.ts +2 -0
- package/dist/claude/native-status-main.js +105 -0
- package/dist/claude/status.d.ts +23 -0
- package/dist/claude/status.js +118 -0
- package/dist/claude/transcript.d.ts +79 -0
- package/dist/claude/transcript.js +272 -0
- package/dist/claude/trust.d.ts +6 -0
- package/dist/claude/trust.js +85 -0
- package/dist/codex/rollout-synth.d.ts +37 -0
- package/dist/codex/rollout-synth.js +212 -0
- package/dist/codex-app-server/client.d.ts +138 -0
- package/dist/codex-app-server/client.js +341 -0
- package/dist/codex-app-server/forwarder.d.ts +92 -0
- package/dist/codex-app-server/forwarder.js +188 -0
- package/dist/codex-app-server/mapping.d.ts +19 -0
- package/dist/codex-app-server/mapping.js +189 -0
- package/dist/codex-app-server/protocol.d.ts +472 -0
- package/dist/codex-app-server/protocol.js +12 -0
- package/dist/codex-app-server/transport.d.ts +139 -0
- package/dist/codex-app-server/transport.js +422 -0
- package/dist/codex-app-server/ws-channel.d.ts +72 -0
- package/dist/codex-app-server/ws-channel.js +233 -0
- package/dist/codex-child-env.d.ts +1 -0
- package/dist/codex-child-env.js +27 -0
- package/dist/codex-home.d.ts +47 -0
- package/dist/codex-home.js +135 -0
- package/dist/codex-session-store.d.ts +42 -0
- package/dist/codex-session-store.js +126 -0
- package/dist/host.d.ts +324 -0
- package/dist/host.js +1323 -0
- package/dist/index.d.ts +18 -0
- package/dist/index.js +17 -0
- package/dist/models-catalog.d.ts +18 -0
- package/dist/models-catalog.js +27 -0
- package/dist/runner/child.d.ts +58 -0
- package/dist/runner/child.js +268 -0
- package/dist/runner/manager.d.ts +175 -0
- package/dist/runner/manager.js +458 -0
- package/dist/runner/protocol.d.ts +195 -0
- package/dist/runner/protocol.js +41 -0
- package/dist/runner/transport.d.ts +36 -0
- package/dist/runner/transport.js +72 -0
- package/dist/runner-main.d.ts +2 -0
- package/dist/runner-main.js +61 -0
- package/dist/runtime-status.d.ts +16 -0
- package/dist/runtime-status.js +80 -0
- package/dist/terminal/claude-tui.d.ts +27 -0
- package/dist/terminal/claude-tui.js +13 -0
- package/dist/terminal/codex-tui.d.ts +54 -0
- package/dist/terminal/codex-tui.js +26 -0
- package/dist/terminal/registry.d.ts +42 -0
- package/dist/terminal/registry.js +70 -0
- package/dist/terminal/tmux.d.ts +150 -0
- package/dist/terminal/tmux.js +364 -0
- package/package.json +32 -0
|
@@ -0,0 +1,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,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 {};
|