@rynx-ai/runtime 0.1.0 → 0.1.9

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 (53) hide show
  1. package/dist/claude/executor.d.ts +3 -5
  2. package/dist/claude/executor.js +3 -5
  3. package/dist/claude/native-bridge.d.ts +74 -17
  4. package/dist/claude/native-bridge.js +225 -30
  5. package/dist/claude/native-hook-main.js +291 -39
  6. package/dist/claude/native-hooks.d.ts +3 -2
  7. package/dist/claude/native-hooks.js +15 -6
  8. package/dist/claude/native-integration.d.ts +78 -5
  9. package/dist/claude/native-integration.js +417 -26
  10. package/dist/claude/settings.d.ts +8 -0
  11. package/dist/claude/settings.js +50 -0
  12. package/dist/claude/transcript.d.ts +2 -2
  13. package/dist/claude/transcript.js +3 -3
  14. package/dist/codex/rollout-synth.js +1 -1
  15. package/dist/codex-app-server/client.d.ts +26 -40
  16. package/dist/codex-app-server/client.js +1128 -99
  17. package/dist/codex-app-server/forwarder.d.ts +7 -7
  18. package/dist/codex-app-server/forwarder.js +11 -5
  19. package/dist/codex-app-server/mapping.d.ts +1 -1
  20. package/dist/codex-app-server/mapping.js +27 -2
  21. package/dist/codex-app-server/protocol.d.ts +238 -4
  22. package/dist/codex-app-server/transport.d.ts +20 -5
  23. package/dist/codex-app-server/transport.js +93 -40
  24. package/dist/codex-app-server/ws-channel.d.ts +3 -3
  25. package/dist/codex-app-server/ws-channel.js +23 -7
  26. package/dist/codex-child-env.js +33 -0
  27. package/dist/codex-home.d.ts +6 -6
  28. package/dist/codex-home.js +8 -9
  29. package/dist/codex-session-store.d.ts +2 -1
  30. package/dist/host.d.ts +34 -33
  31. package/dist/host.js +531 -91
  32. package/dist/index.d.ts +4 -3
  33. package/dist/index.js +1 -1
  34. package/dist/interactions.d.ts +61 -0
  35. package/dist/interactions.js +236 -0
  36. package/dist/models-catalog.d.ts +1 -1
  37. package/dist/models-catalog.js +1 -1
  38. package/dist/runner/child.d.ts +9 -1
  39. package/dist/runner/child.js +93 -15
  40. package/dist/runner/manager.d.ts +59 -10
  41. package/dist/runner/manager.js +385 -41
  42. package/dist/runner/protocol.d.ts +18 -7
  43. package/dist/runner-main.js +9 -6
  44. package/dist/runtime-status.js +1 -1
  45. package/dist/terminal/claude-tui.d.ts +8 -3
  46. package/dist/terminal/claude-tui.js +6 -2
  47. package/dist/terminal/codex-tui.d.ts +3 -3
  48. package/dist/terminal/codex-tui.js +1 -1
  49. package/dist/terminal/registry.d.ts +1 -1
  50. package/dist/terminal/registry.js +1 -1
  51. package/dist/terminal/tmux.d.ts +6 -6
  52. package/dist/terminal/tmux.js +10 -10
  53. package/package.json +3 -3
package/dist/index.d.ts CHANGED
@@ -8,9 +8,10 @@ export { LocalAgentHost, CodexRuntimeError, SpawnCodexCommandRunner, FileCodexSe
8
8
  export type { CodexCapabilities, CapabilityResult, CodexRuntimeStatus, } from "./host.js";
9
9
  export type { CodexSessionStore, CodexSessionRecord } from "./codex-session-store.js";
10
10
  export { ensureCodexResumeRollout } from "./codex/rollout-synth.js";
11
- export { RunnerManager } from "./runner/manager.js";
12
- export type { RunnerManagerOptions, OpenTerminalOptions, ParentTerminal, } from "./runner/manager.js";
13
- export type { InjectOutcome, TerminalRole } from "./runner/protocol.js";
11
+ export { RunnerManager, TerminalOpenError } from "./runner/manager.js";
12
+ export type { RunnerManagerOptions, RunnerSessionContext, RunnerSessionContextProvider, OpenTerminalOptions, ParentTerminal, } from "./runner/manager.js";
13
+ export type { InjectOutcome, TerminalOpenErrorCode, TerminalRole } from "./runner/protocol.js";
14
+ export type { ResolveInteractionResult, RuntimeInteractionEvent, RuntimeInteractionListener, } from "./interactions.js";
14
15
  export { probeRuntimeStatus } from "./runtime-status.js";
15
16
  export { listRuntimeModels } from "./models-catalog.js";
16
17
  export { TmuxTerminal, isTmuxAvailable } from "./terminal/tmux.js";
package/dist/index.js CHANGED
@@ -9,7 +9,7 @@ export { ensureCodexResumeRollout } from "./codex/rollout-synth.js";
9
9
  // Runner subprocess layer: the parent-side manager (an `AgentExecutor` +
10
10
  // `AgentCapabilities` that spawns per-session runner children) plus the wire
11
11
  // types. The composition root uses `RunnerManager` in place of `LocalAgentHost`.
12
- export { RunnerManager } from "./runner/manager.js";
12
+ export { RunnerManager, TerminalOpenError } from "./runner/manager.js";
13
13
  export { probeRuntimeStatus } from "./runtime-status.js";
14
14
  export { listRuntimeModels } from "./models-catalog.js";
15
15
  // Live-terminal subsystem (Phase C): tmux-backed terminals + per-runner registry.
@@ -0,0 +1,61 @@
1
+ import { type SessionInteractionRequest, type SessionInteractionResolution } from "@rynx-ai/core";
2
+ /** Provider-neutral lifecycle emitted by a native runtime adapter. */
3
+ export type RuntimeInteractionEvent = {
4
+ type: "requested";
5
+ request: SessionInteractionRequest;
6
+ /** Native Turn correlation, when the provider exposes it. */
7
+ turnId?: string;
8
+ } | {
9
+ type: "resolved";
10
+ interactionId: string;
11
+ resolution: SessionInteractionResolution;
12
+ turnId?: string;
13
+ } | {
14
+ type: "cancelled";
15
+ interactionId: string;
16
+ reason?: string;
17
+ turnId?: string;
18
+ };
19
+ export type RuntimeInteractionListener = (event: RuntimeInteractionEvent) => void;
20
+ /** Result of attempting to answer a pending native interaction. */
21
+ export type ResolveInteractionResult = {
22
+ disposition: "applied";
23
+ } | {
24
+ disposition: "already_resolved";
25
+ } | {
26
+ disposition: "not_found";
27
+ } | {
28
+ disposition: "invalid";
29
+ message: string;
30
+ };
31
+ export type InteractionResolution = SessionInteractionResolution;
32
+ export declare const INTERACTION_LIMITS: {
33
+ readonly idBytes: 256;
34
+ readonly titleBytes: 1024;
35
+ readonly labelBytes: 1024;
36
+ readonly descriptionBytes: number;
37
+ readonly contextBytes: number;
38
+ readonly requestBytes: number;
39
+ readonly fields: 64;
40
+ readonly optionsPerField: 128;
41
+ readonly actions: 16;
42
+ readonly routingSkeletonBytes: number;
43
+ };
44
+ export type BoundInteractionRequestResult = {
45
+ ok: true;
46
+ request: SessionInteractionRequest;
47
+ } | {
48
+ ok: false;
49
+ reason: string;
50
+ };
51
+ /** Bound an untrusted provider request before it enters the canonical event
52
+ * stream. Lists that affect native answer routing are rejected when oversized;
53
+ * option lists may be truncated to a still-answerable subset. */
54
+ export declare function boundInteractionRequest(request: SessionInteractionRequest): BoundInteractionRequestResult;
55
+ /** Validate a provider-neutral resolution against the exact advertised form.
56
+ * Unknown keys and wrong answer shapes are rejected even for deny/cancel; the
57
+ * selected action's `requiresAnswers` controls only whether required fields may
58
+ * be omitted. */
59
+ export declare function validateInteractionResolution(request: SessionInteractionRequest, resolution: SessionInteractionResolution): string | undefined;
60
+ /** Secret values reach the native runtime but never enter canonical history. */
61
+ export declare function redactInteractionResolution(request: SessionInteractionRequest, resolution: SessionInteractionResolution): SessionInteractionResolution;
@@ -0,0 +1,236 @@
1
+ import { boundSessionInteractionRouting, sessionInteractionRoutingSkeletonBytes, SESSION_INTERACTION_STRUCTURAL_LIMITS, } from "@rynx-ai/core";
2
+ export const INTERACTION_LIMITS = {
3
+ ...SESSION_INTERACTION_STRUCTURAL_LIMITS,
4
+ idBytes: 256,
5
+ titleBytes: 1024,
6
+ labelBytes: 1024,
7
+ descriptionBytes: 8 * 1024,
8
+ contextBytes: 64 * 1024,
9
+ // SessionNormalizer carries the request twice (event + durable item). 240 KiB
10
+ // leaves 32 KiB for both wrappers under the daemon's 512 KiB event limit.
11
+ requestBytes: 240 * 1024,
12
+ };
13
+ /** UTF-8 byte-safe truncation: iteration is by Unicode code point, so neither a
14
+ * surrogate pair nor a multi-byte sequence is split. */
15
+ function truncateUtf8(value, maxBytes) {
16
+ if (Buffer.byteLength(value, "utf8") <= maxBytes)
17
+ return value;
18
+ const suffix = "…";
19
+ const budget = Math.max(0, maxBytes - Buffer.byteLength(suffix, "utf8"));
20
+ let result = "";
21
+ let bytes = 0;
22
+ for (const char of value) {
23
+ const width = Buffer.byteLength(char, "utf8");
24
+ if (bytes + width > budget)
25
+ break;
26
+ result += char;
27
+ bytes += width;
28
+ }
29
+ return `${result}${suffix}`;
30
+ }
31
+ function validId(id) {
32
+ return id.length > 0 && Buffer.byteLength(id, "utf8") <= INTERACTION_LIMITS.idBytes;
33
+ }
34
+ /** Bound an untrusted provider request before it enters the canonical event
35
+ * stream. Lists that affect native answer routing are rejected when oversized;
36
+ * option lists may be truncated to a still-answerable subset. */
37
+ export function boundInteractionRequest(request) {
38
+ if (!validId(request.interactionId)) {
39
+ return { ok: false, reason: "interaction id exceeds the canonical limit" };
40
+ }
41
+ if (request.fields.length > INTERACTION_LIMITS.fields) {
42
+ return { ok: false, reason: "interaction has too many fields" };
43
+ }
44
+ if (request.actions.length === 0 || request.actions.length > INTERACTION_LIMITS.actions) {
45
+ return { ok: false, reason: "interaction has an invalid number of actions" };
46
+ }
47
+ const seenFields = new Set();
48
+ const fields = [];
49
+ for (const field of request.fields) {
50
+ if (!validId(field.id) || seenFields.has(field.id)) {
51
+ return { ok: false, reason: "interaction has an invalid or duplicate field id" };
52
+ }
53
+ seenFields.add(field.id);
54
+ const label = truncateUtf8(field.label, INTERACTION_LIMITS.labelBytes);
55
+ const description = field.description !== undefined
56
+ ? truncateUtf8(field.description, INTERACTION_LIMITS.descriptionBytes)
57
+ : undefined;
58
+ if (field.type === "text") {
59
+ fields.push({
60
+ id: field.id,
61
+ type: "text",
62
+ label,
63
+ ...(description !== undefined ? { description } : {}),
64
+ ...(field.required !== undefined ? { required: field.required } : {}),
65
+ ...(field.secret !== undefined ? { secret: field.secret } : {}),
66
+ ...(field.multiline !== undefined ? { multiline: field.multiline } : {}),
67
+ ...(field.placeholder !== undefined
68
+ ? { placeholder: truncateUtf8(field.placeholder, INTERACTION_LIMITS.labelBytes) }
69
+ : {}),
70
+ });
71
+ continue;
72
+ }
73
+ const options = [];
74
+ const seenOptions = new Set();
75
+ for (const option of field.options.slice(0, INTERACTION_LIMITS.optionsPerField)) {
76
+ if (!validId(option.value) || seenOptions.has(option.value)) {
77
+ return { ok: false, reason: `interaction field has an invalid option id: ${field.id}` };
78
+ }
79
+ seenOptions.add(option.value);
80
+ options.push({
81
+ ...option,
82
+ label: truncateUtf8(option.label, INTERACTION_LIMITS.labelBytes),
83
+ ...(option.description !== undefined
84
+ ? { description: truncateUtf8(option.description, INTERACTION_LIMITS.descriptionBytes) }
85
+ : {}),
86
+ });
87
+ }
88
+ fields.push({
89
+ id: field.id,
90
+ type: "select",
91
+ label,
92
+ ...(description !== undefined ? { description } : {}),
93
+ ...(field.required !== undefined ? { required: field.required } : {}),
94
+ ...(field.multiple !== undefined ? { multiple: field.multiple } : {}),
95
+ ...(field.allowOther !== undefined ? { allowOther: field.allowOther } : {}),
96
+ options,
97
+ });
98
+ }
99
+ const seenActions = new Set();
100
+ const actions = [];
101
+ for (const action of request.actions) {
102
+ if (!validId(action.id) || seenActions.has(action.id)) {
103
+ return { ok: false, reason: "interaction has an invalid or duplicate action id" };
104
+ }
105
+ seenActions.add(action.id);
106
+ actions.push({
107
+ ...action,
108
+ label: truncateUtf8(action.label, INTERACTION_LIMITS.labelBytes),
109
+ ...(action.description !== undefined
110
+ ? { description: truncateUtf8(action.description, INTERACTION_LIMITS.descriptionBytes) }
111
+ : {}),
112
+ });
113
+ }
114
+ const context = request.context
115
+ ? {
116
+ ...(request.context.summary !== undefined
117
+ ? { summary: truncateUtf8(request.context.summary, INTERACTION_LIMITS.descriptionBytes) }
118
+ : {}),
119
+ ...(request.context.toolName !== undefined
120
+ ? { toolName: truncateUtf8(request.context.toolName, INTERACTION_LIMITS.labelBytes) }
121
+ : {}),
122
+ ...(request.context.command !== undefined
123
+ ? { command: truncateUtf8(request.context.command, INTERACTION_LIMITS.contextBytes) }
124
+ : {}),
125
+ ...(request.context.cwd !== undefined
126
+ ? { cwd: truncateUtf8(request.context.cwd, INTERACTION_LIMITS.contextBytes) }
127
+ : {}),
128
+ ...(request.context.diff !== undefined
129
+ ? { diff: truncateUtf8(request.context.diff, INTERACTION_LIMITS.contextBytes) }
130
+ : {}),
131
+ }
132
+ : undefined;
133
+ const boundedPresentation = {
134
+ ...request,
135
+ title: truncateUtf8(request.title, INTERACTION_LIMITS.titleBytes),
136
+ ...(request.description !== undefined
137
+ ? { description: truncateUtf8(request.description, INTERACTION_LIMITS.descriptionBytes) }
138
+ : {}),
139
+ fields,
140
+ actions,
141
+ ...(context ? { context } : {}),
142
+ };
143
+ const bounded = boundSessionInteractionRouting(boundedPresentation);
144
+ const lostOptionCoverage = boundedPresentation.fields.some((source, index) => source.type === "select" &&
145
+ source.options.length > 0 &&
146
+ bounded.fields[index]?.type === "select" &&
147
+ bounded.fields[index].options.length === 0);
148
+ if (lostOptionCoverage) {
149
+ return { ok: false, reason: "interaction routing minimum coverage exceeds the canonical budget" };
150
+ }
151
+ if (sessionInteractionRoutingSkeletonBytes(bounded) > INTERACTION_LIMITS.routingSkeletonBytes) {
152
+ return { ok: false, reason: "interaction routing structure exceeds the canonical budget" };
153
+ }
154
+ if (Buffer.byteLength(JSON.stringify(bounded), "utf8") > INTERACTION_LIMITS.requestBytes) {
155
+ return { ok: false, reason: "interaction exceeds the canonical request budget" };
156
+ }
157
+ return { ok: true, request: bounded };
158
+ }
159
+ function isRecord(value) {
160
+ return typeof value === "object" && value !== null && !Array.isArray(value);
161
+ }
162
+ /** Validate a provider-neutral resolution against the exact advertised form.
163
+ * Unknown keys and wrong answer shapes are rejected even for deny/cancel; the
164
+ * selected action's `requiresAnswers` controls only whether required fields may
165
+ * be omitted. */
166
+ export function validateInteractionResolution(request, resolution) {
167
+ const action = request.actions.find((candidate) => candidate.id === resolution.actionId);
168
+ if (!action)
169
+ return `unknown interaction action: ${resolution.actionId}`;
170
+ const rawAnswers = resolution.answers;
171
+ if (rawAnswers !== undefined && !isRecord(rawAnswers)) {
172
+ return "interaction answers must be an object";
173
+ }
174
+ const answers = rawAnswers ?? {};
175
+ const fields = new Map(request.fields.map((field) => [field.id, field]));
176
+ for (const [id, answer] of Object.entries(answers)) {
177
+ const field = fields.get(id);
178
+ if (!field)
179
+ return `unknown interaction answer: ${id}`;
180
+ if (field.type === "text") {
181
+ if (typeof answer !== "string")
182
+ return `answer must be a string: ${id}`;
183
+ continue;
184
+ }
185
+ const values = field.multiple
186
+ ? Array.isArray(answer) && answer.every((value) => typeof value === "string")
187
+ ? answer
188
+ : null
189
+ : typeof answer === "string"
190
+ ? [answer]
191
+ : null;
192
+ if (!values) {
193
+ return field.multiple
194
+ ? `answer must be a string array: ${id}`
195
+ : `answer must be a string: ${id}`;
196
+ }
197
+ const allowed = new Set(field.options.map((option) => option.value));
198
+ for (const value of values) {
199
+ if (allowed.has(value))
200
+ continue;
201
+ if (field.allowOther && value.trim().length > 0)
202
+ continue;
203
+ return `answer is not an allowed option: ${id}`;
204
+ }
205
+ }
206
+ if (!action.requiresAnswers)
207
+ return undefined;
208
+ for (const field of request.fields) {
209
+ if (!field.required)
210
+ continue;
211
+ const answer = answers[field.id];
212
+ const empty = answer === undefined ||
213
+ (typeof answer === "string" && answer.trim().length === 0) ||
214
+ (Array.isArray(answer) &&
215
+ (answer.length === 0 || answer.some((value) => value.trim().length === 0)));
216
+ if (empty)
217
+ return `answer is required: ${field.id}`;
218
+ }
219
+ return undefined;
220
+ }
221
+ /** Secret values reach the native runtime but never enter canonical history. */
222
+ export function redactInteractionResolution(request, resolution) {
223
+ if (!resolution.answers)
224
+ return resolution;
225
+ const secretIds = new Set(request.fields
226
+ .filter((field) => field.type === "text" && field.secret)
227
+ .map((field) => field.id));
228
+ if (secretIds.size === 0)
229
+ return resolution;
230
+ const answers = { ...resolution.answers };
231
+ for (const id of secretIds) {
232
+ if (answers[id] !== undefined)
233
+ answers[id] = "[redacted]";
234
+ }
235
+ return { ...resolution, answers };
236
+ }
@@ -2,7 +2,7 @@
2
2
  * Backend-free model listing for codex/traex.
3
3
  *
4
4
  * The parent control plane no longer holds an app-server, so `/models` can't be
5
- * a live `model/list` RPC anymore. Following omnigent's static-catalog model, we
5
+ * a live `model/list` RPC anymore. Following reference implementation's static-catalog model, we
6
6
  * serve a config-derived list: the runtime's configured default model (the one
7
7
  * `resolveRuntimeModel` would pick), marked `isDefault`. This is intentionally
8
8
  * minimal — a fuller curated catalogue can be added here later without touching
@@ -2,7 +2,7 @@
2
2
  * Backend-free model listing for codex/traex.
3
3
  *
4
4
  * The parent control plane no longer holds an app-server, so `/models` can't be
5
- * a live `model/list` RPC anymore. Following omnigent's static-catalog model, we
5
+ * a live `model/list` RPC anymore. Following reference implementation's static-catalog model, we
6
6
  * serve a config-derived list: the runtime's configured default model (the one
7
7
  * `resolveRuntimeModel` would pick), marked `isDefault`. This is intentionally
8
8
  * minimal — a fuller curated catalogue can be added here later without touching
@@ -23,11 +23,16 @@ export declare class RunnerSession {
23
23
  /** Live terminals hosted by this session, and per-attach client handles. */
24
24
  private readonly terminals;
25
25
  private readonly attachments;
26
+ /** Opens are async; a close received before attach resolves tombstones the id. */
27
+ private readonly pendingTerminalOpens;
28
+ private readonly cancelledTerminalOpens;
26
29
  /** Session ids with a live codex forwarder started here (stopped on shutdown). */
27
30
  private readonly liveIds;
31
+ private shuttingDown;
28
32
  constructor({ transport, executor, onShutdown }: RunnerSessionDeps);
29
33
  private handle;
30
34
  private get liveProvider();
35
+ private resolveInteraction;
31
36
  /** The mirror channel for a session: an `emit` that forwards every canonical
32
37
  * event to the parent under the CURRENT session id (mutable), plus a `retarget`
33
38
  * the host calls on a `/clear`·`/fork` rotation — it re-points the mirror to the
@@ -40,7 +45,7 @@ export declare class RunnerSession {
40
45
  * launch the detached `codex --remote` TUI, which CREATES the codex thread. The
41
46
  * forwarder's connection sees the TUI's broadcast `thread/started`, binds, and
42
47
  * subscribes — so the TUI is usable immediately (a fresh `--remote` needs no
43
- * rollout) and its turns mirror to chat (omnigent's model).
48
+ * rollout) and its turns mirror to chat (reference implementation's model).
44
49
  */
45
50
  private ensureLive;
46
51
  private inject;
@@ -50,6 +55,9 @@ export declare class RunnerSession {
50
55
  * so the web attach reuses the same detached pane. */
51
56
  private launchCodexPane;
52
57
  private stopLive;
58
+ /** Stop event forwarding, kill native terminals/hooks, then synchronously
59
+ * scrub provider handoff files before the child process is allowed to exit. */
60
+ shutdown(): void;
53
61
  private openTerminal;
54
62
  /** Attach an already-created terminal and forward its data/exit to the parent. */
55
63
  private attachExisting;
@@ -7,15 +7,18 @@ export class RunnerSession {
7
7
  /** Live terminals hosted by this session, and per-attach client handles. */
8
8
  terminals = new TerminalRegistry();
9
9
  attachments = new Map();
10
+ /** Opens are async; a close received before attach resolves tombstones the id. */
11
+ pendingTerminalOpens = new Set();
12
+ cancelledTerminalOpens = new Set();
10
13
  /** Session ids with a live codex forwarder started here (stopped on shutdown). */
11
14
  liveIds = new Set();
15
+ shuttingDown = false;
12
16
  constructor({ transport, executor, onShutdown }) {
13
17
  this.transport = transport;
14
18
  this.executor = executor;
15
19
  this.onShutdown =
16
20
  onShutdown ??
17
21
  (() => {
18
- this.terminals.closeAll();
19
22
  transport.close();
20
23
  });
21
24
  this.transport.onMessage((msg) => this.handle(msg));
@@ -27,7 +30,20 @@ export class RunnerSession {
27
30
  void this.runCap(msg.capId, msg.name, msg.args);
28
31
  return;
29
32
  case "term.open":
30
- void this.openTerminal(msg);
33
+ if (this.pendingTerminalOpens.has(msg.attachId) || this.attachments.has(msg.attachId)) {
34
+ this.transport.send({
35
+ t: "term.error",
36
+ attachId: msg.attachId,
37
+ code: "terminal_open_failed",
38
+ message: "terminal attachment id is already active",
39
+ });
40
+ return;
41
+ }
42
+ this.pendingTerminalOpens.add(msg.attachId);
43
+ void this.openTerminal(msg).finally(() => {
44
+ this.pendingTerminalOpens.delete(msg.attachId);
45
+ this.cancelledTerminalOpens.delete(msg.attachId);
46
+ });
31
47
  return;
32
48
  case "term.input":
33
49
  this.attachments.get(msg.attachId)?.write(Buffer.from(msg.dataB64, "base64").toString("utf8"));
@@ -36,14 +52,16 @@ export class RunnerSession {
36
52
  this.attachments.get(msg.attachId)?.resize(msg.cols, msg.rows);
37
53
  return;
38
54
  case "term.close": {
55
+ if (this.pendingTerminalOpens.has(msg.attachId)) {
56
+ this.cancelledTerminalOpens.add(msg.attachId);
57
+ }
39
58
  const attachment = this.attachments.get(msg.attachId);
40
59
  this.attachments.delete(msg.attachId);
41
60
  attachment?.kill();
42
61
  return;
43
62
  }
44
- case "approval.resolve": {
45
- const provider = this.executor;
46
- void provider.resolveApproval?.(msg.localThreadId, msg.approvalId, msg.decision);
63
+ case "interaction.resolve": {
64
+ void this.resolveInteraction(msg);
47
65
  return;
48
66
  }
49
67
  case "live.ensure":
@@ -56,15 +74,31 @@ export class RunnerSession {
56
74
  void this.interruptLive(msg);
57
75
  return;
58
76
  case "shutdown":
59
- this.stopLive();
60
- this.terminals.closeAll();
61
- this.onShutdown();
77
+ this.shutdown();
62
78
  return;
63
79
  }
64
80
  }
65
81
  get liveProvider() {
66
82
  return this.executor;
67
83
  }
84
+ async resolveInteraction(msg) {
85
+ let result;
86
+ try {
87
+ result = await this.liveProvider.resolveInteraction?.(msg.localThreadId, msg.interactionId, msg.resolution) ?? { disposition: "not_found" };
88
+ }
89
+ catch (error) {
90
+ result = {
91
+ disposition: "invalid",
92
+ message: error instanceof Error ? error.message : String(error),
93
+ };
94
+ }
95
+ this.transport.send({
96
+ t: "interaction.resolved",
97
+ reqId: msg.reqId,
98
+ localThreadId: msg.localThreadId,
99
+ result,
100
+ });
101
+ }
68
102
  /** The mirror channel for a session: an `emit` that forwards every canonical
69
103
  * event to the parent under the CURRENT session id (mutable), plus a `retarget`
70
104
  * the host calls on a `/clear`·`/fork` rotation — it re-points the mirror to the
@@ -94,20 +128,28 @@ export class RunnerSession {
94
128
  * launch the detached `codex --remote` TUI, which CREATES the codex thread. The
95
129
  * forwarder's connection sees the TUI's broadcast `thread/started`, binds, and
96
130
  * subscribes — so the TUI is usable immediately (a fresh `--remote` needs no
97
- * rollout) and its turns mirror to chat (omnigent's model).
131
+ * rollout) and its turns mirror to chat (reference implementation's model).
98
132
  */
99
133
  async ensureLive(msg) {
100
134
  const provider = this.liveProvider;
101
135
  try {
102
136
  const { emit, retarget } = this.mirrorChannel(msg.localThreadId);
103
137
  const started = await provider.ensureLiveCodexSession?.(msg.localThreadId, emit, {
138
+ ...(msg.cwd ? { cwd: msg.cwd } : {}),
104
139
  ...(msg.runtime ? { runtime: msg.runtime } : {}),
140
+ ...(msg.reasoningEffort ? { reasoningEffort: msg.reasoningEffort } : {}),
105
141
  ...(msg.agentName ? { agentName: msg.agentName } : {}),
106
142
  ...(msg.agentSpec ? { agentSpec: msg.agentSpec } : {}),
107
143
  retargetMirror: retarget,
108
144
  });
109
145
  if (!started) {
110
- this.transport.send({ t: "live.ready", reqId: msg.reqId, localThreadId: msg.localThreadId, ok: false });
146
+ this.transport.send({
147
+ t: "live.ready",
148
+ reqId: msg.reqId,
149
+ localThreadId: msg.localThreadId,
150
+ ok: false,
151
+ error: "live provider did not start",
152
+ });
111
153
  return;
112
154
  }
113
155
  this.liveIds.add(msg.localThreadId);
@@ -122,7 +164,13 @@ export class RunnerSession {
122
164
  const ready = provider.waitLiveReady
123
165
  ? await provider.waitLiveReady(msg.localThreadId)
124
166
  : true;
125
- this.transport.send({ t: "live.ready", reqId: msg.reqId, localThreadId: msg.localThreadId, ok: ready });
167
+ this.transport.send({
168
+ t: "live.ready",
169
+ reqId: msg.reqId,
170
+ localThreadId: msg.localThreadId,
171
+ ok: ready,
172
+ ...(ready ? {} : { error: "live session was not ready before timeout" }),
173
+ });
126
174
  }
127
175
  catch (error) {
128
176
  this.transport.send({
@@ -186,13 +234,27 @@ export class RunnerSession {
186
234
  this.liveProvider.attachTerminalInjector?.(localThreadId, term);
187
235
  }
188
236
  stopLive() {
189
- for (const id of this.liveIds)
190
- this.liveProvider.stopLiveCodexSession?.(id);
237
+ for (const id of this.liveIds) {
238
+ this.liveProvider.stopLiveCodexSession?.(id, {
239
+ deferClaudeInteractionCleanup: true,
240
+ });
241
+ }
191
242
  this.liveIds.clear();
192
243
  }
244
+ /** Stop event forwarding, kill native terminals/hooks, then synchronously
245
+ * scrub provider handoff files before the child process is allowed to exit. */
246
+ shutdown() {
247
+ if (this.shuttingDown)
248
+ return;
249
+ this.shuttingDown = true;
250
+ this.stopLive();
251
+ this.terminals.closeAll();
252
+ this.liveProvider.finalizeStoppedLiveSessions?.();
253
+ this.onShutdown();
254
+ }
193
255
  async openTerminal(msg) {
194
256
  try {
195
- // codex/claude-native session (no explicit command): DUMB ATTACH — omnigent's
257
+ // codex/claude-native session (no explicit command): DUMB ATTACH — reference implementation's
196
258
  // reattach (codex_native.py:905-942, `app_server=None`). A tab switch ONLY
197
259
  // attaches an already-live pane; it NEVER ensures the forwarder or relaunches a
198
260
  // dead pane. Creation/relaunch happens on message-send (`live.ensure`) or an
@@ -202,7 +264,12 @@ export class RunnerSession {
202
264
  if (!msg.command && msg.localThreadId) {
203
265
  const existing = this.terminals.get(msg.terminalId);
204
266
  if (!existing || !existing.isAlive()) {
205
- this.transport.send({ t: "term.error", attachId: msg.attachId, message: "terminal not live" });
267
+ this.transport.send({
268
+ t: "term.error",
269
+ attachId: msg.attachId,
270
+ code: "terminal_not_live",
271
+ message: "terminal not live",
272
+ });
206
273
  return;
207
274
  }
208
275
  await this.attachExisting(msg);
@@ -222,6 +289,7 @@ export class RunnerSession {
222
289
  this.transport.send({
223
290
  t: "term.error",
224
291
  attachId: msg.attachId,
292
+ code: "terminal_open_failed",
225
293
  message: error instanceof Error ? error.message : String(error),
226
294
  });
227
295
  }
@@ -232,6 +300,16 @@ export class RunnerSession {
232
300
  cols: msg.cols,
233
301
  rows: msg.rows,
234
302
  });
303
+ if (this.cancelledTerminalOpens.has(msg.attachId)) {
304
+ attachment.kill();
305
+ this.transport.send({
306
+ t: "term.error",
307
+ attachId: msg.attachId,
308
+ code: "terminal_open_failed",
309
+ message: "terminal attachment was cancelled while opening",
310
+ });
311
+ return;
312
+ }
235
313
  this.attachments.set(msg.attachId, attachment);
236
314
  attachment.onData((chunk) => this.transport.send({
237
315
  t: "term.data",