@rynx-ai/runtime 0.1.11-beta.1 → 0.1.11-beta.3

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 (38) hide show
  1. package/dist/claude/native-bridge.js +3 -8
  2. package/dist/claude/native-integration.d.ts +12 -1
  3. package/dist/claude/native-integration.js +16 -2
  4. package/dist/claude/transcript.d.ts +0 -7
  5. package/dist/claude/transcript.js +6 -20
  6. package/dist/codex-app-server/client.d.ts +2 -1
  7. package/dist/codex-app-server/forwarder.d.ts +4 -1
  8. package/dist/codex-app-server/forwarder.js +19 -1
  9. package/dist/codex-app-server/protocol.d.ts +45 -1
  10. package/dist/codex-home.d.ts +9 -26
  11. package/dist/codex-home.js +37 -65
  12. package/dist/codex-session-store.d.ts +22 -10
  13. package/dist/codex-session-store.js +277 -12
  14. package/dist/host.d.ts +47 -47
  15. package/dist/host.js +799 -351
  16. package/dist/index.d.ts +2 -3
  17. package/dist/index.js +1 -2
  18. package/dist/models-catalog.d.ts +1 -0
  19. package/dist/models-catalog.js +43 -1
  20. package/dist/provider-workspace.d.ts +56 -0
  21. package/dist/provider-workspace.js +83 -0
  22. package/dist/runner/child.d.ts +54 -6
  23. package/dist/runner/child.js +42 -17
  24. package/dist/runner/manager.d.ts +92 -19
  25. package/dist/runner/manager.js +838 -83
  26. package/dist/runner/protocol.d.ts +7 -18
  27. package/dist/runner-main.js +12 -4
  28. package/dist/runtime-state-paths.d.ts +10 -0
  29. package/dist/runtime-state-paths.js +53 -0
  30. package/dist/terminal/claude-tui.d.ts +8 -1
  31. package/dist/terminal/claude-tui.js +7 -1
  32. package/dist/terminal/codex-tui.d.ts +5 -1
  33. package/dist/terminal/codex-tui.js +12 -3
  34. package/dist/terminal/tmux.d.ts +8 -0
  35. package/dist/terminal/tmux.js +36 -3
  36. package/package.json +2 -2
  37. package/dist/codex/rollout-synth.d.ts +0 -42
  38. package/dist/codex/rollout-synth.js +0 -245
package/dist/index.d.ts CHANGED
@@ -6,14 +6,13 @@
6
6
  */
7
7
  export { LocalAgentHost, CodexRuntimeError, SpawnCodexCommandRunner, FileCodexSessionStore, resolveCodexSessionStorePath, } from "./host.js";
8
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";
9
+ export type { ClaudeForkIntent, CodexSessionStore, CodexSessionRecord, } from "./codex-session-store.js";
11
10
  export { RunnerManager, TerminalOpenError } from "./runner/manager.js";
12
11
  export type { RunnerManagerOptions, RunnerSessionContext, RunnerSessionContextProvider, OpenTerminalOptions, ParentTerminal, } from "./runner/manager.js";
13
12
  export type { InjectOutcome, TerminalOpenErrorCode, TerminalRole } from "./runner/protocol.js";
14
13
  export type { ResolveInteractionResult, RuntimeInteractionEvent, RuntimeInteractionListener, } from "./interactions.js";
15
14
  export { probeRuntimeStatus } from "./runtime-status.js";
16
15
  export { listRuntimeModels } from "./models-catalog.js";
17
- export { TmuxTerminal, isTmuxAvailable } from "./terminal/tmux.js";
16
+ export { TmuxTerminal, isTmuxAvailable, terminateTmuxServer, tmuxSocketPath, } from "./terminal/tmux.js";
18
17
  export type { TerminalAttachment, TmuxTerminalOptions } from "./terminal/tmux.js";
19
18
  export { TerminalRegistry } from "./terminal/registry.js";
package/dist/index.js CHANGED
@@ -5,7 +5,6 @@
5
5
  * nothing about any channel.
6
6
  */
7
7
  export { LocalAgentHost, CodexRuntimeError, SpawnCodexCommandRunner, FileCodexSessionStore, resolveCodexSessionStorePath, } from "./host.js";
8
- export { ensureCodexResumeRollout } from "./codex/rollout-synth.js";
9
8
  // Runner subprocess layer: the parent-side manager (an `AgentExecutor` +
10
9
  // `AgentCapabilities` that spawns per-session runner children) plus the wire
11
10
  // types. The composition root uses `RunnerManager` in place of `LocalAgentHost`.
@@ -13,5 +12,5 @@ export { RunnerManager, TerminalOpenError } from "./runner/manager.js";
13
12
  export { probeRuntimeStatus } from "./runtime-status.js";
14
13
  export { listRuntimeModels } from "./models-catalog.js";
15
14
  // Live-terminal subsystem (Phase C): tmux-backed terminals + per-runner registry.
16
- export { TmuxTerminal, isTmuxAvailable } from "./terminal/tmux.js";
15
+ export { TmuxTerminal, isTmuxAvailable, terminateTmuxServer, tmuxSocketPath, } from "./terminal/tmux.js";
17
16
  export { TerminalRegistry } from "./terminal/registry.js";
@@ -2,6 +2,7 @@ import { type AgentRuntimeId, type AppConfig } from "@rynx-ai/core";
2
2
  import type { ModelListResponse } from "./codex-app-server/protocol.js";
3
3
  export interface RuntimeModelCatalogDeps {
4
4
  readTraexModels?: () => Promise<unknown>;
5
+ readTraexDebugModels?: () => Promise<unknown>;
5
6
  }
6
7
  /**
7
8
  * The model list for a runtime, without an execution backend.
@@ -24,7 +24,17 @@ export async function listRuntimeModels(config, runtime, deps = {}) {
24
24
  if (runtime === "traex") {
25
25
  try {
26
26
  const value = await (deps.readTraexModels ?? readTraexModels)();
27
- const models = normalizeTraexModels(value, resolveRuntimeModel(config, runtime).trim());
27
+ const configuredDefault = resolveRuntimeModel(config, runtime).trim();
28
+ const defaultModel = configuredDefault || resolveTraexNativeDefault(await (deps.readTraexDebugModels ?? readTraexDebugModels)());
29
+ if (!configuredDefault &&
30
+ (!Array.isArray(value) ||
31
+ !value.some((item) => item &&
32
+ typeof item === "object" &&
33
+ !Array.isArray(item) &&
34
+ item.name === defaultModel))) {
35
+ throw new Error(`Traex native default is missing from the public catalog: ${defaultModel}`);
36
+ }
37
+ const models = normalizeTraexModels(value, defaultModel);
28
38
  if (models.length > 0)
29
39
  return { data: models };
30
40
  }
@@ -46,6 +56,38 @@ async function readTraexModels() {
46
56
  });
47
57
  return JSON.parse(stdout);
48
58
  }
59
+ async function readTraexDebugModels() {
60
+ const { stdout } = await execFileAsync(resolveRuntimeBinary("traex"), ["debug", "models"], {
61
+ encoding: "utf8",
62
+ timeout: TRAEX_MODELS_TIMEOUT_MS,
63
+ maxBuffer: TRAEX_MODELS_MAX_BYTES,
64
+ });
65
+ return JSON.parse(stdout);
66
+ }
67
+ function resolveTraexNativeDefault(value) {
68
+ if (!value || typeof value !== "object" || Array.isArray(value)) {
69
+ throw new Error("Traex debug model catalog is invalid");
70
+ }
71
+ const models = value.models;
72
+ if (!Array.isArray(models)) {
73
+ throw new Error("Traex debug model catalog is invalid");
74
+ }
75
+ const defaults = models.filter((item) => item &&
76
+ typeof item === "object" &&
77
+ !Array.isArray(item) &&
78
+ item.business_metadata &&
79
+ typeof item.business_metadata === "object" &&
80
+ item.business_metadata
81
+ .backend_is_default === true);
82
+ if (defaults.length !== 1) {
83
+ throw new Error(`Traex debug model catalog has ${defaults.length} native defaults`);
84
+ }
85
+ const name = defaults[0].config_name;
86
+ if (typeof name !== "string" || !name.trim()) {
87
+ throw new Error("Traex native default has no config_name");
88
+ }
89
+ return name.trim();
90
+ }
49
91
  function normalizeTraexModels(value, configuredDefault) {
50
92
  if (!Array.isArray(value))
51
93
  return [];
@@ -0,0 +1,56 @@
1
+ import type { AgentRuntimeId, SandboxMode } from "@rynx-ai/core";
2
+ import type { PermissionProfileSelectionParams, SandboxPolicy } from "./codex-app-server/protocol.js";
3
+ /** Structural view of the immutable Session workspace snapshot. The canonical
4
+ * type lives in core; keeping the adapter input structural avoids inventing a
5
+ * second domain model in the Provider layer. */
6
+ export interface ProviderWorkspace {
7
+ readonly cwd: string;
8
+ readonly allowedDirs: readonly string[];
9
+ }
10
+ /** Canonical roots projected without mutating the Session snapshot, with the
11
+ * primary working directory first for every Provider. */
12
+ export declare function providerWorkspaceRoots(workspace: ProviderWorkspace): string[];
13
+ export declare function providerAdditionalDirs(workspace: ProviderWorkspace): string[];
14
+ export declare function sandboxPolicyForWorkspace(mode: SandboxMode, workspace: ProviderWorkspace): SandboxPolicy;
15
+ export declare function codexThreadWorkspaceParams(workspace: ProviderWorkspace): {
16
+ cwd: string;
17
+ runtimeWorkspaceRoots: string[];
18
+ };
19
+ export declare function codexTurnWorkspaceParams(workspace: ProviderWorkspace, mode: SandboxMode): {
20
+ cwd: string;
21
+ runtimeWorkspaceRoots: string[];
22
+ sandboxPolicy: SandboxPolicy;
23
+ };
24
+ /** Traex has no runtimeWorkspaceRoots field. For a multi-root writable
25
+ * workspace, use the bounded named-profile modification supported by Traex
26
+ * 0.200; never combine it with the legacy sandbox field. */
27
+ export declare function traexThreadWorkspaceParams(workspace: ProviderWorkspace, mode: SandboxMode): {
28
+ cwd: string;
29
+ permissions: PermissionProfileSelectionParams;
30
+ } | {
31
+ cwd: string;
32
+ sandbox: SandboxMode;
33
+ };
34
+ /** Turn equivalent of {@link traexThreadWorkspaceParams}; `permissions` and
35
+ * `sandboxPolicy` are intentionally mutually exclusive. */
36
+ export declare function traexTurnWorkspaceParams(workspace: ProviderWorkspace, mode: SandboxMode): {
37
+ cwd: string;
38
+ permissions: PermissionProfileSelectionParams;
39
+ } | {
40
+ cwd: string;
41
+ sandboxPolicy: SandboxPolicy;
42
+ };
43
+ export declare function threadWorkspaceParams(runtime: Exclude<AgentRuntimeId, "claude">, workspace: ProviderWorkspace, mode: SandboxMode): {
44
+ cwd: string;
45
+ permissions: PermissionProfileSelectionParams;
46
+ } | {
47
+ cwd: string;
48
+ sandbox: SandboxMode;
49
+ };
50
+ export declare function turnWorkspaceParams(runtime: Exclude<AgentRuntimeId, "claude">, workspace: ProviderWorkspace, mode: SandboxMode): {
51
+ cwd: string;
52
+ permissions: PermissionProfileSelectionParams;
53
+ } | {
54
+ cwd: string;
55
+ sandboxPolicy: SandboxPolicy;
56
+ };
@@ -0,0 +1,83 @@
1
+ /** Canonical roots projected without mutating the Session snapshot, with the
2
+ * primary working directory first for every Provider. */
3
+ export function providerWorkspaceRoots(workspace) {
4
+ return [...new Set([workspace.cwd, ...workspace.allowedDirs])];
5
+ }
6
+ export function providerAdditionalDirs(workspace) {
7
+ return providerWorkspaceRoots(workspace).filter((dir) => dir !== workspace.cwd);
8
+ }
9
+ export function sandboxPolicyForWorkspace(mode, workspace) {
10
+ switch (mode) {
11
+ case "read-only":
12
+ return { type: "readOnly", networkAccess: false };
13
+ case "workspace-write":
14
+ return {
15
+ type: "workspaceWrite",
16
+ writableRoots: providerWorkspaceRoots(workspace),
17
+ networkAccess: true,
18
+ excludeTmpdirEnvVar: false,
19
+ excludeSlashTmp: false,
20
+ };
21
+ case "danger-full-access":
22
+ return { type: "dangerFullAccess" };
23
+ }
24
+ }
25
+ export function codexThreadWorkspaceParams(workspace) {
26
+ return {
27
+ cwd: workspace.cwd,
28
+ runtimeWorkspaceRoots: providerWorkspaceRoots(workspace),
29
+ };
30
+ }
31
+ export function codexTurnWorkspaceParams(workspace, mode) {
32
+ return {
33
+ ...codexThreadWorkspaceParams(workspace),
34
+ sandboxPolicy: sandboxPolicyForWorkspace(mode, workspace),
35
+ };
36
+ }
37
+ function traexWorkspacePermissions(workspace) {
38
+ const additionalDirs = providerAdditionalDirs(workspace);
39
+ if (additionalDirs.length === 0)
40
+ return undefined;
41
+ return {
42
+ type: "profile",
43
+ id: ":workspace",
44
+ modifications: additionalDirs.map((path) => ({
45
+ type: "additionalWritableRoot",
46
+ path,
47
+ })),
48
+ };
49
+ }
50
+ /** Traex has no runtimeWorkspaceRoots field. For a multi-root writable
51
+ * workspace, use the bounded named-profile modification supported by Traex
52
+ * 0.200; never combine it with the legacy sandbox field. */
53
+ export function traexThreadWorkspaceParams(workspace, mode) {
54
+ const permissions = mode === "workspace-write"
55
+ ? traexWorkspacePermissions(workspace)
56
+ : undefined;
57
+ return permissions
58
+ ? { cwd: workspace.cwd, permissions }
59
+ : { cwd: workspace.cwd, sandbox: mode };
60
+ }
61
+ /** Turn equivalent of {@link traexThreadWorkspaceParams}; `permissions` and
62
+ * `sandboxPolicy` are intentionally mutually exclusive. */
63
+ export function traexTurnWorkspaceParams(workspace, mode) {
64
+ const permissions = mode === "workspace-write"
65
+ ? traexWorkspacePermissions(workspace)
66
+ : undefined;
67
+ return permissions
68
+ ? { cwd: workspace.cwd, permissions }
69
+ : {
70
+ cwd: workspace.cwd,
71
+ sandboxPolicy: sandboxPolicyForWorkspace(mode, workspace),
72
+ };
73
+ }
74
+ export function threadWorkspaceParams(runtime, workspace, mode) {
75
+ return runtime === "traex"
76
+ ? traexThreadWorkspaceParams(workspace, mode)
77
+ : { ...codexThreadWorkspaceParams(workspace), sandbox: mode };
78
+ }
79
+ export function turnWorkspaceParams(runtime, workspace, mode) {
80
+ return runtime === "traex"
81
+ ? traexTurnWorkspaceParams(workspace, mode)
82
+ : codexTurnWorkspaceParams(workspace, mode);
83
+ }
@@ -7,12 +7,60 @@
7
7
  * per-thread capabilities, answered against the same backend so the parent never
8
8
  * needs an app-server of its own.
9
9
  */
10
- import { type AgentCapabilities } from "@rynx-ai/core";
10
+ import { type AgentCapabilities, type ResolvedExecutionSnapshot, type RuntimeUserInput, type SessionInteractionResolution, type SessionWorkspaceSnapshot } from "@rynx-ai/core";
11
+ import type { SessionEvent } from "@rynx-ai/core";
12
+ import type { TerminalInjector } from "../claude/native-integration.js";
13
+ import type { ResolveInteractionResult } from "../interactions.js";
14
+ import { type InjectOutcome } from "./protocol.js";
11
15
  import type { ChildTransport } from "./transport.js";
16
+ /** Re-target the mirror to a freshly minted rynx session (claude `/clear`·`/fork`)
17
+ * + record the terminal transfer with the daemon. The child owns the transport,
18
+ * so it supplies this to the host. */
19
+ type RetargetMirror = (newSessionId: string, meta: {
20
+ kind: "clear" | "fork";
21
+ workspace: SessionWorkspaceSnapshot;
22
+ execution: ResolvedExecutionSnapshot;
23
+ parentSessionId?: string;
24
+ }) => void;
25
+ /** Codex-native live-session methods the executor (a `LocalAgentHost`) exposes
26
+ * beyond the `AgentExecutor`/`AgentCapabilities` contract. Duck-typed so the
27
+ * runner works with any executor (a claude-only host simply lacks them). */
28
+ interface LiveCodexProvider {
29
+ codexTerminalSpec?(localThreadId: string): Promise<{
30
+ command: string;
31
+ args: string[];
32
+ cwd: string;
33
+ env?: Record<string, string>;
34
+ } | null>;
35
+ ensureLiveCodexSession?(localThreadId: string, emit: (event: SessionEvent) => void, opts: {
36
+ workspace: SessionWorkspaceSnapshot;
37
+ execution: ResolvedExecutionSnapshot;
38
+ retargetMirror?: RetargetMirror;
39
+ }): Promise<boolean>;
40
+ waitLiveReady?(localThreadId: string, timeoutMs?: number): Promise<boolean>;
41
+ waitTerminalReady?(localThreadId: string, timeoutMs?: number): Promise<boolean>;
42
+ liveSessionError?(localThreadId: string): string | undefined;
43
+ injectMessage?(localThreadId: string, input: RuntimeUserInput | string): Promise<InjectOutcome>;
44
+ interruptLive?(localThreadId: string): Promise<boolean>;
45
+ stopLiveCodexSession?(localThreadId: string, opts?: {
46
+ deferClaudeInteractionCleanup?: boolean;
47
+ }): void;
48
+ finalizeStoppedLiveSessions?(): void;
49
+ resolveInteraction?(localThreadId: string, interactionId: string, resolution: SessionInteractionResolution): Promise<ResolveInteractionResult>;
50
+ /** claude-native: hand the session's tmux pane injector to the host (which
51
+ * doesn't own tmux). No-op for codex sessions (they inject via app-server). */
52
+ attachTerminalInjector?(localThreadId: string, injector: TerminalInjector): void;
53
+ /** Internal Provider operation used only by MachineSessionService through
54
+ * RunnerManager. It is deliberately absent from public AgentCapabilities. */
55
+ forkSession?(currentLocalThreadId: string, newLocalThreadId: string, options: {
56
+ workspace: SessionWorkspaceSnapshot;
57
+ execution: ResolvedExecutionSnapshot;
58
+ }): Promise<import("@rynx-ai/core").CapabilityResult>;
59
+ }
12
60
  export interface RunnerSessionDeps {
13
61
  transport: ChildTransport;
14
62
  /** The single-backend capability + live co-drive surface (a `LocalAgentHost`). */
15
- executor: AgentCapabilities;
63
+ executor: AgentCapabilities & LiveCodexProvider;
16
64
  /** Invoked on a `shutdown` message (default: close transport). */
17
65
  onShutdown?: () => void;
18
66
  }
@@ -42,10 +90,9 @@ export declare class RunnerSession {
42
90
  /**
43
91
  * Eagerly bring up a session's codex-native live view: start the persistent
44
92
  * forwarder connection (which resume-subscribes to mirror every turn) and
45
- * launch the detached `codex --remote` TUI, which CREATES the codex thread. The
46
- * forwarder's connection sees the TUI's broadcast `thread/started`, binds, and
47
- * subscribes — so the TUI is usable immediately (a fresh `--remote` needs no
48
- * rollout) and its turns mirror to chat (reference implementation's model).
93
+ * launch the detached `codex --remote resume` TUI against the thread the
94
+ * structured runtime created. The forwarder subscribes to that same thread,
95
+ * so the TUI is usable immediately and its turns mirror to chat.
49
96
  */
50
97
  private ensureLive;
51
98
  private inject;
@@ -64,3 +111,4 @@ export declare class RunnerSession {
64
111
  private runCap;
65
112
  private dispatchCap;
66
113
  }
114
+ export {};
@@ -114,9 +114,8 @@ export class RunnerSession {
114
114
  from: localThreadId,
115
115
  to: newId,
116
116
  kind: meta.kind,
117
- ...(meta.agent ? { agent: meta.agent } : {}),
118
- ...(meta.model ? { model: meta.model } : {}),
119
- ...(meta.cwd ? { cwd: meta.cwd } : {}),
117
+ workspace: meta.workspace,
118
+ execution: meta.execution,
120
119
  ...(meta.parentSessionId ? { parentSessionId: meta.parentSessionId } : {}),
121
120
  });
122
121
  };
@@ -125,21 +124,17 @@ export class RunnerSession {
125
124
  /**
126
125
  * Eagerly bring up a session's codex-native live view: start the persistent
127
126
  * forwarder connection (which resume-subscribes to mirror every turn) and
128
- * launch the detached `codex --remote` TUI, which CREATES the codex thread. The
129
- * forwarder's connection sees the TUI's broadcast `thread/started`, binds, and
130
- * subscribes — so the TUI is usable immediately (a fresh `--remote` needs no
131
- * rollout) and its turns mirror to chat (reference implementation's model).
127
+ * launch the detached `codex --remote resume` TUI against the thread the
128
+ * structured runtime created. The forwarder subscribes to that same thread,
129
+ * so the TUI is usable immediately and its turns mirror to chat.
132
130
  */
133
131
  async ensureLive(msg) {
134
132
  const provider = this.liveProvider;
135
133
  try {
136
134
  const { emit, retarget } = this.mirrorChannel(msg.localThreadId);
137
135
  const started = await provider.ensureLiveCodexSession?.(msg.localThreadId, emit, {
138
- ...(msg.cwd ? { cwd: msg.cwd } : {}),
139
- ...(msg.runtime ? { runtime: msg.runtime } : {}),
140
- ...(msg.reasoningEffort ? { reasoningEffort: msg.reasoningEffort } : {}),
141
- ...(msg.agentName ? { agentName: msg.agentName } : {}),
142
- ...(msg.agentSpec ? { agentSpec: msg.agentSpec } : {}),
136
+ workspace: msg.workspace,
137
+ execution: msg.execution,
143
138
  retargetMirror: retarget,
144
139
  });
145
140
  if (!started) {
@@ -153,12 +148,24 @@ export class RunnerSession {
153
148
  return;
154
149
  }
155
150
  this.liveIds.add(msg.localThreadId);
156
- // Launch the TUI (fresh `--remote` → it creates the thread) so the forwarder
157
- // captures the broadcast `thread/started` and binds. Re-launch when the pane
158
- // is absent OR its process has died (`isAlive` probes `#{pane_dead}`) — so a
151
+ // Launch the TUI attached to the already-bound thread. Re-launch when the
152
+ // pane is absent OR its process has died (`isAlive` probes `#{pane_dead}`) — so a
159
153
  // reconnect after the TUI exited restarts it instead of skipping (a
160
154
  // launched-once guard would leave a dead "Pane is dead" husk forever).
161
155
  if (!this.terminals.get(`${msg.localThreadId}-main`)?.isAlive()) {
156
+ const terminalReady = provider.waitTerminalReady
157
+ ? await provider.waitTerminalReady(msg.localThreadId)
158
+ : true;
159
+ if (!terminalReady) {
160
+ this.transport.send({
161
+ t: "live.ready",
162
+ reqId: msg.reqId,
163
+ localThreadId: msg.localThreadId,
164
+ ok: false,
165
+ error: "Provider thread was not ready for Terminal resume",
166
+ });
167
+ return;
168
+ }
162
169
  await this.launchCodexPane(msg.localThreadId, msg.cols, msg.rows);
163
170
  }
164
171
  const ready = msg.waitForReady === false
@@ -166,12 +173,27 @@ export class RunnerSession {
166
173
  : provider.waitLiveReady
167
174
  ? await provider.waitLiveReady(msg.localThreadId)
168
175
  : true;
176
+ const terminal = this.terminals.get(`${msg.localThreadId}-main`);
177
+ const paneFailure = !ready && terminal && !terminal.isAlive()
178
+ ? terminal
179
+ .capturePane()
180
+ .split("\n")
181
+ .map((line) => line.trim())
182
+ .filter(Boolean)
183
+ .slice(-6)
184
+ .join(" ")
185
+ .slice(-1_000)
186
+ : "";
187
+ const readinessError = provider.liveSessionError?.(msg.localThreadId)
188
+ ?? (paneFailure
189
+ ? `Provider terminal exited before session discovery: ${paneFailure}`
190
+ : "live session was not ready before timeout");
169
191
  this.transport.send({
170
192
  t: "live.ready",
171
193
  reqId: msg.reqId,
172
194
  localThreadId: msg.localThreadId,
173
195
  ok: ready,
174
- ...(ready ? {} : { error: "live session was not ready before timeout" }),
196
+ ...(ready ? {} : { error: readinessError }),
175
197
  });
176
198
  }
177
199
  catch (error) {
@@ -343,7 +365,10 @@ export class RunnerSession {
343
365
  case "clearGoal":
344
366
  return this.executor.clearGoal(args[0]);
345
367
  case "forkSession":
346
- return this.executor.forkSession(args[0], args[1]);
368
+ if (!this.executor.forkSession) {
369
+ throw new Error("Provider fork is unavailable");
370
+ }
371
+ return this.executor.forkSession(args[0], args[1], args[2]);
347
372
  }
348
373
  }
349
374
  }
@@ -15,8 +15,8 @@
15
15
  * transport drops in here later without touching `ConversationRuntime` or any
16
16
  * channel.
17
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 ReasoningEffort, type RuntimeUserInput, type SessionInteractionResolution, type SessionEvent, type ThreadGoal } from "@rynx-ai/core";
18
+ import { spawn as nodeSpawn, type ChildProcess } from "node:child_process";
19
+ import { type AdmissionReservation, type AgentCapabilities, type AgentRuntimeId, type AppConfig, type CapabilityResult, type ModelListResponse, type ResolvedExecutionSnapshot, type RuntimeUserInput, type SessionInteractionResolution, type SessionEvent, type SessionWorkspaceSnapshot, type ThreadGoal } from "@rynx-ai/core";
20
20
  import { type CodexSessionStore } from "../host.js";
21
21
  import type { ResolveInteractionResult } from "../interactions.js";
22
22
  import { type InjectOutcome, type TerminalOpenErrorCode, type TerminalRole } from "./protocol.js";
@@ -53,8 +53,22 @@ export interface RunnerManagerOptions {
53
53
  liveStartTimeoutMs?: number;
54
54
  /** Max wait for a Provider-native thread to become ready. */
55
55
  liveReadyTimeoutMs?: number;
56
+ /** Max wait for a native interrupt acknowledgement before reporting it
57
+ * unproven. Callers may then use the explicit force-stop path. */
58
+ liveInterruptTimeoutMs?: number;
59
+ /** Max wait for an accepted owner TUI submission to become observable as a
60
+ * mirrored response or a published native rotation. */
61
+ terminalInputHandoffTimeoutMs?: number;
62
+ /** Initial exponential backoff for retrying unproven terminal cleanup. */
63
+ terminalInputCleanupRetryMs?: number;
56
64
  /** Injected for tests. */
57
65
  spawn?: typeof nodeSpawn;
66
+ /** Injected for tests. Defaults to signaling the whole POSIX process group
67
+ * created for the runner child, with direct-child fallback. */
68
+ signalChild?: (child: ChildProcess, signal: NodeJS.Signals, processGroup: boolean) => void;
69
+ /** Injected for tests. Kills and verifies the deterministic private tmux
70
+ * server owned by a Session runner. */
71
+ terminateTerminalServer?: (terminalName: string) => boolean | Promise<boolean>;
58
72
  now?: () => number;
59
73
  /** Extra env merged into every spawned runner child — e.g. the control-plane
60
74
  * URL + token so a claude-native PermissionRequest hook can POST back. */
@@ -62,6 +76,13 @@ export interface RunnerManagerOptions {
62
76
  /** Optional lifecycle context for Session runners. The shared `__cap__`
63
77
  * runner never opens one. */
64
78
  sessionContextProvider?: RunnerSessionContextProvider;
79
+ /** Dynamic process-wide admission fence. Existing cancellation and read-only
80
+ * inspection remain available; every path that may spawn or attach execution
81
+ * checks this immediately before admission. */
82
+ admissionOpen?: () => boolean;
83
+ /** Atomically reserves one work-producing admission while it crosses the
84
+ * asynchronous boundary into an observable live runner/turn. */
85
+ admissionReserve?: () => AdmissionReservation | undefined;
65
86
  }
66
87
  /** A session rotation reported by a runner child (claude `/clear`·`/fork`): the
67
88
  * new session `to` is now aliased to `from`'s runner; carries meta to carry over. */
@@ -69,9 +90,8 @@ export interface RotateInfo {
69
90
  from: string;
70
91
  to: string;
71
92
  kind: "clear" | "fork";
72
- agent?: string;
73
- model?: string;
74
- cwd?: string;
93
+ workspace: SessionWorkspaceSnapshot;
94
+ execution: ResolvedExecutionSnapshot;
75
95
  parentSessionId?: string;
76
96
  }
77
97
  /** Options for {@link RunnerManager.openTerminal}. */
@@ -119,6 +139,8 @@ export declare class RunnerManager implements AgentCapabilities {
119
139
  private readonly spawn;
120
140
  private readonly childEnv;
121
141
  private readonly sessionContextProvider;
142
+ private readonly admissionOpen;
143
+ private readonly admissionReserve;
122
144
  private readonly now;
123
145
  private readonly defaultRuntime;
124
146
  private readonly handles;
@@ -128,8 +150,13 @@ export declare class RunnerManager implements AgentCapabilities {
128
150
  private readonly reapTimer;
129
151
  private readonly shutdownGraceMs;
130
152
  private readonly shutdownKillGraceMs;
153
+ private readonly signalChild;
154
+ private readonly terminateTerminalServer;
131
155
  private readonly liveStartTimeoutMs;
132
156
  private readonly liveReadyTimeoutMs;
157
+ private readonly liveInterruptTimeoutMs;
158
+ private readonly terminalInputHandoffTimeoutMs;
159
+ private readonly terminalInputCleanupRetryMs;
133
160
  private stopping;
134
161
  private stopPromise;
135
162
  /** Sink for mirrored {@link SessionEvent}s from every session's forwarder. */
@@ -140,6 +167,24 @@ export declare class RunnerManager implements AgentCapabilities {
140
167
  private readonly liveSessionKeys;
141
168
  /** Last live-start error per local session, surfaced by the control API. */
142
169
  private readonly liveErrors;
170
+ /** Last immutable launch snapshots seen for a Session. Used only to restore a
171
+ * target request that crossed a fork reservation boundary. */
172
+ private readonly liveOptions;
173
+ /** A fork reserves its target before the first asynchronous store read. This
174
+ * prevents another entry point from starting the target against an
175
+ * uncommitted Provider binding. */
176
+ private readonly forkReservations;
177
+ /** A native fork temporarily makes the source read-only so its canonical
178
+ * snapshot and Provider context are captured at the same boundary. */
179
+ private readonly sourceForkReservations;
180
+ /** Manager-wide fork de-duplication. LocalAgentHost only sees one source
181
+ * runner, so the fence must live here to cover concurrent source runners. */
182
+ private readonly forkOperations;
183
+ private readonly forkBufferedMessages;
184
+ private readonly forkBufferedTerminalInputs;
185
+ /** Owner TUI submissions accepted by the parent but not yet represented by a
186
+ * mirrored response or a durably published native rotation. */
187
+ private readonly terminalInputHandoffs;
143
188
  constructor(opts: RunnerManagerOptions);
144
189
  /**
145
190
  * Open a live terminal on the session's runner child (spawning it if needed).
@@ -166,33 +211,39 @@ export declare class RunnerManager implements AgentCapabilities {
166
211
  onMirror(listener: (sessionId: string, event: SessionEvent) => void): void;
167
212
  /** Register the sink for session rotations (claude `/clear`·`/fork`): the server
168
213
  * records the new session's meta (carry-over agent/model/title). */
169
- onRotate(listener: (rotation: RotateInfo) => void): void;
214
+ onRotate(listener: (rotation: RotateInfo) => void | Promise<void>): void;
215
+ /** Accepted TUI submissions that have not crossed into an observable runtime
216
+ * state. The daemon folds this into runningTurns after closing admission. */
217
+ pendingTerminalInputCount(): number;
218
+ private beginTerminalInputHandoffs;
219
+ private settleTerminalInputHandoff;
220
+ private settleTerminalRotationHandoff;
221
+ private currentTerminalSessionId;
222
+ private finishTerminalInputHandoff;
223
+ private finishAllTerminalInputHandoffs;
224
+ private preserveTerminalInputHandoffsAsActivity;
225
+ private scheduleTerminalInputCleanupRetry;
226
+ private expireTerminalInputHandoffs;
170
227
  /**
171
228
  * Eagerly bring up a session's codex-native live view (persistent forwarder +
172
229
  * detached `codex --remote` TUI) in its runner child, spawning the runner if
173
230
  * needed. Idempotent. Resolves true once the codex thread is bound; false for a
174
231
  * non-codex / non-live session (the caller then uses the normal run path).
175
232
  */
176
- ensureLiveSession(localThreadId: string, opts?: {
177
- cwd?: string;
233
+ ensureLiveSession(localThreadId: string, opts: {
234
+ workspace: SessionWorkspaceSnapshot;
235
+ execution: ResolvedExecutionSnapshot;
178
236
  cols?: number;
179
237
  rows?: number;
180
- runtime?: AgentRuntimeId;
181
- reasoningEffort?: ReasoningEffort;
182
- agentName?: string;
183
- agentSpec?: AgentSpec;
184
238
  }): Promise<boolean>;
185
239
  /** Start the Provider pane without waiting for login/onboarding to create a
186
240
  * native thread. This is the setup-terminal gate; callers may attach as soon
187
241
  * as it resolves, while normal message delivery still uses ensureLiveSession. */
188
- startLiveSession(localThreadId: string, opts?: {
189
- cwd?: string;
242
+ startLiveSession(localThreadId: string, opts: {
243
+ workspace: SessionWorkspaceSnapshot;
244
+ execution: ResolvedExecutionSnapshot;
190
245
  cols?: number;
191
246
  rows?: number;
192
- runtime?: AgentRuntimeId;
193
- reasoningEffort?: ReasoningEffort;
194
- agentName?: string;
195
- agentSpec?: AgentSpec;
196
247
  }): Promise<boolean>;
197
248
  private requestLiveSession;
198
249
  lastLiveSessionError(localThreadId: string): string | undefined;
@@ -203,6 +254,7 @@ export declare class RunnerManager implements AgentCapabilities {
203
254
  * the session has no live forwarder (caller falls back to the run path).
204
255
  */
205
256
  injectMessage(localThreadId: string, input: RuntimeUserInput | string): Promise<InjectOutcome>;
257
+ private injectMessageAdmitted;
206
258
  /**
207
259
  * Interrupt a session's active live turn — the web Stop button (codex
208
260
  * `turn/interrupt`, claude Escape). Best-effort: resolves false with NO spawn
@@ -215,7 +267,16 @@ export declare class RunnerManager implements AgentCapabilities {
215
267
  getGoal(localThreadId: string): Promise<CapabilityResult<ThreadGoal | null>>;
216
268
  setGoal(localThreadId: string, objective: string): Promise<CapabilityResult>;
217
269
  clearGoal(localThreadId: string): Promise<CapabilityResult>;
218
- forkSession(currentLocalThreadId: string, newLocalThreadId: string): Promise<CapabilityResult>;
270
+ forkSession(currentLocalThreadId: string, newLocalThreadId: string, options: {
271
+ workspace: SessionWorkspaceSnapshot;
272
+ execution: ResolvedExecutionSnapshot;
273
+ beforeProviderFork: () => Promise<void>;
274
+ }): Promise<{
275
+ ok: true;
276
+ } | {
277
+ ok: false;
278
+ message: string;
279
+ }>;
219
280
  /**
220
281
  * Backend-free runtime readiness (not part of `AgentCapabilities`; surfaced for
221
282
  * the control console). Never spawns a runner.
@@ -226,13 +287,25 @@ export declare class RunnerManager implements AgentCapabilities {
226
287
  }): Promise<import("../host.js").CodexRuntimeStatus>;
227
288
  /** Stop the runner bound to one session (if any), rejecting its in-flight work. */
228
289
  stopRunner(localThreadId: string): void;
290
+ /** Force-stop and join the runner bound to one Session. Unlike stopRunner,
291
+ * completion proves that the child process has exited. */
292
+ terminateLiveSession(localThreadId: string): Promise<boolean>;
229
293
  /** Stop every runner and join all child exits. Idempotent across concurrent calls. */
230
294
  stop(): Promise<void>;
295
+ private handleForCleanup;
231
296
  /** Forward a capability to a runner child. Session-less caps (listModels/status)
232
297
  * use the shared `CAP_KEY` child; per-thread caps pass the session's key so they
233
298
  * run on that session's child (which owns its private CODEX_HOME). */
234
299
  private forwardCap;
235
300
  private getOrSpawn;
301
+ private assertAdmissionOpen;
302
+ private reserveAdmission;
303
+ private performManagedFork;
304
+ private performClaudeManagedFork;
305
+ private bufferForkTargetMessage;
306
+ private reservedForkTarget;
307
+ private deliverForkBufferedMessage;
308
+ private deliverRotateMessage;
236
309
  private spawnHandle;
237
310
  private onChildMessage;
238
311
  /** Mark a handle dead and reject every pending run/cap with the exit reason. */