@rynx-ai/runtime 0.1.11-beta.3 → 0.1.11-beta.30
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 +19 -5
- package/dist/claude/executor.js +56 -12
- package/dist/claude/models.d.ts +0 -5
- package/dist/claude/models.js +1 -7
- package/dist/claude/native-bridge.d.ts +2 -0
- package/dist/claude/native-bridge.js +23 -0
- package/dist/claude/native-hook-main.js +62 -0
- package/dist/claude/native-integration.d.ts +50 -10
- package/dist/claude/native-integration.js +262 -37
- package/dist/claude/session-status.d.ts +39 -0
- package/dist/claude/session-status.js +163 -0
- package/dist/claude/transcript.js +27 -17
- package/dist/codex-app-server/client.d.ts +10 -6
- package/dist/codex-app-server/client.js +67 -15
- package/dist/codex-app-server/forwarder.d.ts +92 -3
- package/dist/codex-app-server/forwarder.js +509 -56
- package/dist/codex-app-server/mapping.d.ts +3 -6
- package/dist/codex-app-server/mapping.js +174 -28
- package/dist/codex-app-server/mcp-startup.d.ts +13 -0
- package/dist/codex-app-server/mcp-startup.js +63 -0
- package/dist/codex-app-server/protocol.d.ts +64 -7
- package/dist/codex-app-server/ws-channel.js +19 -19
- package/dist/codex-home.js +2 -4
- package/dist/host.d.ts +64 -21
- package/dist/host.js +1330 -441
- package/dist/index.d.ts +1 -1
- package/dist/input-resources.d.ts +4 -0
- package/dist/input-resources.js +21 -5
- package/dist/models-catalog.d.ts +2 -1
- package/dist/models-catalog.js +94 -6
- package/dist/runner/child.d.ts +48 -21
- package/dist/runner/child.js +550 -48
- package/dist/runner/manager.d.ts +54 -13
- package/dist/runner/manager.js +479 -114
- package/dist/runner/protocol.d.ts +62 -19
- package/dist/runner/protocol.js +5 -0
- package/dist/runner/startup-policy.d.ts +7 -0
- package/dist/runner/startup-policy.js +10 -0
- package/dist/terminal/claude-tui.d.ts +3 -1
- package/dist/terminal/claude-tui.js +3 -1
- package/dist/terminal/registry.js +3 -2
- package/dist/terminal/tmux.d.ts +50 -7
- package/dist/terminal/tmux.js +168 -47
- package/package.json +4 -3
package/dist/runner/child.js
CHANGED
|
@@ -1,5 +1,112 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Child-side runner session. One per runner process (i.e. per session); owns the
|
|
3
|
+
* single execution backend via the injected executor (a {@link LocalAgentHost}
|
|
4
|
+
* in production, which in a per-session process holds exactly one backend). It
|
|
5
|
+
* translates inbound {@link ToChild} control messages into the live co-drive
|
|
6
|
+
* surface (bring up the session's forwarder + TUI, inject / interrupt turns) and
|
|
7
|
+
* per-thread capabilities, answered against the same backend so the parent never
|
|
8
|
+
* needs an app-server of its own.
|
|
9
|
+
*/
|
|
10
|
+
import { randomUUID } from "node:crypto";
|
|
11
|
+
import { AgentRuntimeError, } from "@rynx-ai/core";
|
|
1
12
|
import { TerminalRegistry } from "../terminal/registry.js";
|
|
2
|
-
import { toWireError } from "./protocol.js";
|
|
13
|
+
import { RUNNER_IMAGE_CHUNK_CHARS, RUNNER_IMAGE_MAX_RESULT_CHARS, toWireError, } from "./protocol.js";
|
|
14
|
+
import { isCodexLineageProvider } from "./startup-policy.js";
|
|
15
|
+
/** Maximum time for a fresh Codex-lineage TUI to publish its native thread. */
|
|
16
|
+
const CODEX_THREAD_START_TIMEOUT_MS = 30_000;
|
|
17
|
+
function providerDisplayName(runtime) {
|
|
18
|
+
return runtime === "traex" ? "Traex" : runtime === "claude" ? "Claude" : "Codex";
|
|
19
|
+
}
|
|
20
|
+
function providerFailure(provider, localThreadId, fallback) {
|
|
21
|
+
return provider.liveSessionFailure?.(localThreadId) ?? {
|
|
22
|
+
message: provider.liveSessionError?.(localThreadId) ?? fallback.message,
|
|
23
|
+
code: fallback.code,
|
|
24
|
+
statusCode: 503,
|
|
25
|
+
};
|
|
26
|
+
}
|
|
27
|
+
function nativePhaseError(runtime, code, phase, cause) {
|
|
28
|
+
const detail = cause === undefined
|
|
29
|
+
? ""
|
|
30
|
+
: `: ${cause instanceof Error ? cause.message : String(cause)}`;
|
|
31
|
+
return new AgentRuntimeError(`${providerDisplayName(runtime)} ${phase}${detail}`, 503, code);
|
|
32
|
+
}
|
|
33
|
+
function terminalFailureDetail(pane) {
|
|
34
|
+
// A captured pane is a screen snapshot, not stderr. Do not present ordinary
|
|
35
|
+
// TUI chrome ("Working", shortcuts, model/status bars) as the process's exit
|
|
36
|
+
// cause. Retain only lines that plausibly carry an actual startup/runtime
|
|
37
|
+
// failure; the structured error still reports the proven pane exit itself.
|
|
38
|
+
return pane
|
|
39
|
+
.split("\n")
|
|
40
|
+
.map((line) => line.trim())
|
|
41
|
+
.filter(Boolean)
|
|
42
|
+
.filter((line) => /\b(?:error|fatal|failed|failure|panic|refused|denied|unauthorized|invalid)\b/i.test(line))
|
|
43
|
+
.slice(-6)
|
|
44
|
+
.join(" ")
|
|
45
|
+
.slice(-1_000);
|
|
46
|
+
}
|
|
47
|
+
const TRAEX_STARTUP_WATCH_MS = 20_000;
|
|
48
|
+
const TRAEX_STARTUP_POLL_MS = 100;
|
|
49
|
+
const TRAEX_AUTHORIZATION_POLL_MS = 500;
|
|
50
|
+
const TRAEX_AUTHORIZATION_WAIT_MS = 15 * 60_000;
|
|
51
|
+
const TRAEX_PROMPT_RETRY_MS = 500;
|
|
52
|
+
const MIRROR_IMAGE_ACK_TIMEOUT_MS = 30_000;
|
|
53
|
+
function normalizeTraexPane(pane) {
|
|
54
|
+
return pane.toLowerCase().replace(/\s+/g, " ").trim();
|
|
55
|
+
}
|
|
56
|
+
function isTraexAuthorizationPending(pane) {
|
|
57
|
+
return pane.includes("waiting for authorization") &&
|
|
58
|
+
pane.includes("open this link in your browser") &&
|
|
59
|
+
pane.includes("press esc to cancel");
|
|
60
|
+
}
|
|
61
|
+
function isTerminalProtocolResponse(input) {
|
|
62
|
+
// xterm answers terminal queries through the same onData channel as real
|
|
63
|
+
// keystrokes. CSI carries device/focus/position reports; OSC carries color
|
|
64
|
+
// query replies such as `OSC 10;rgb:... ST` and `OSC 11;rgb:... ST`.
|
|
65
|
+
// Neither is evidence that the user has taken over startup prompt handling.
|
|
66
|
+
return /^(?:\x1b\[(?:(?:\?|>)[0-9;]*c|[0-9;]*(?:n|R|t)|[IO])|\x1b\][0-9]+;[^\x07\x1b]*(?:\x07|\x1b\\))+$/.test(input);
|
|
67
|
+
}
|
|
68
|
+
function detachGeneratedImageResult(event) {
|
|
69
|
+
if (event.type !== "response.output_item.done" ||
|
|
70
|
+
event.item.type !== "function_call_output") {
|
|
71
|
+
return undefined;
|
|
72
|
+
}
|
|
73
|
+
const data = event.item.data;
|
|
74
|
+
const pending = data.__rynxGeneratedImage;
|
|
75
|
+
if (!pending?.result)
|
|
76
|
+
return undefined;
|
|
77
|
+
const { result, ...withoutResult } = pending;
|
|
78
|
+
return {
|
|
79
|
+
result,
|
|
80
|
+
event: {
|
|
81
|
+
...event,
|
|
82
|
+
item: {
|
|
83
|
+
...event.item,
|
|
84
|
+
data: {
|
|
85
|
+
...data,
|
|
86
|
+
__rynxGeneratedImage: withoutResult,
|
|
87
|
+
},
|
|
88
|
+
},
|
|
89
|
+
},
|
|
90
|
+
};
|
|
91
|
+
}
|
|
92
|
+
function generatedImagePreviewUnavailable(event) {
|
|
93
|
+
if (event.type !== "response.output_item.done" ||
|
|
94
|
+
event.item.type !== "function_call_output") {
|
|
95
|
+
return event;
|
|
96
|
+
}
|
|
97
|
+
const data = event.item.data;
|
|
98
|
+
const { __rynxGeneratedImage: _omitted, ...publicData } = data;
|
|
99
|
+
return {
|
|
100
|
+
...event,
|
|
101
|
+
item: {
|
|
102
|
+
...event.item,
|
|
103
|
+
data: {
|
|
104
|
+
...publicData,
|
|
105
|
+
output: `${event.item.data.output}\nImage preview unavailable.`.trim(),
|
|
106
|
+
},
|
|
107
|
+
},
|
|
108
|
+
};
|
|
109
|
+
}
|
|
3
110
|
export class RunnerSession {
|
|
4
111
|
transport;
|
|
5
112
|
executor;
|
|
@@ -7,11 +114,18 @@ export class RunnerSession {
|
|
|
7
114
|
/** Live terminals hosted by this session, and per-attach client handles. */
|
|
8
115
|
terminals = new TerminalRegistry();
|
|
9
116
|
attachments = new Map();
|
|
117
|
+
attachmentThreadIds = new Map();
|
|
118
|
+
traexStartupWatchers = new Map();
|
|
119
|
+
terminalWatchers = new Map();
|
|
10
120
|
/** Opens are async; a close received before attach resolves tombstones the id. */
|
|
11
121
|
pendingTerminalOpens = new Set();
|
|
12
122
|
cancelledTerminalOpens = new Set();
|
|
13
123
|
/** Session ids with a live codex forwarder started here (stopped on shutdown). */
|
|
14
124
|
liveIds = new Set();
|
|
125
|
+
mirrorQueue = Promise.resolve();
|
|
126
|
+
mirrorImageAcks = new Map();
|
|
127
|
+
/** Provider name retained for asynchronous Terminal-exit diagnostics. */
|
|
128
|
+
liveRuntimes = new Map();
|
|
15
129
|
shuttingDown = false;
|
|
16
130
|
constructor({ transport, executor, onShutdown }) {
|
|
17
131
|
this.transport = transport;
|
|
@@ -22,10 +136,36 @@ export class RunnerSession {
|
|
|
22
136
|
transport.close();
|
|
23
137
|
});
|
|
24
138
|
this.transport.onMessage((msg) => this.handle(msg));
|
|
139
|
+
this.transport.onClose((error) => {
|
|
140
|
+
this.rejectMirrorImageAcks(error ?? new Error("runner transport closed"));
|
|
141
|
+
});
|
|
25
142
|
this.transport.send({ t: "ready" });
|
|
26
143
|
}
|
|
27
144
|
handle(msg) {
|
|
28
145
|
switch (msg.t) {
|
|
146
|
+
case "mirror.image.ack": {
|
|
147
|
+
const pending = this.mirrorImageAcks.get(msg.transferId);
|
|
148
|
+
if (!pending)
|
|
149
|
+
return;
|
|
150
|
+
if (pending.seq !== msg.seq) {
|
|
151
|
+
pending.reject(new Error("generated image acknowledgement sequence changed"));
|
|
152
|
+
return;
|
|
153
|
+
}
|
|
154
|
+
if (pending.timer)
|
|
155
|
+
clearTimeout(pending.timer);
|
|
156
|
+
this.mirrorImageAcks.delete(msg.transferId);
|
|
157
|
+
pending.resolve();
|
|
158
|
+
return;
|
|
159
|
+
}
|
|
160
|
+
case "mirror.image.hold": {
|
|
161
|
+
const pending = this.mirrorImageAcks.get(msg.transferId);
|
|
162
|
+
if (!pending || pending.seq !== msg.seq)
|
|
163
|
+
return;
|
|
164
|
+
if (pending.timer)
|
|
165
|
+
clearTimeout(pending.timer);
|
|
166
|
+
pending.timer = undefined;
|
|
167
|
+
return;
|
|
168
|
+
}
|
|
29
169
|
case "cap":
|
|
30
170
|
void this.runCap(msg.capId, msg.name, msg.args);
|
|
31
171
|
return;
|
|
@@ -46,7 +186,17 @@ export class RunnerSession {
|
|
|
46
186
|
});
|
|
47
187
|
return;
|
|
48
188
|
case "term.input":
|
|
49
|
-
|
|
189
|
+
{
|
|
190
|
+
const localThreadId = this.attachmentThreadIds.get(msg.attachId);
|
|
191
|
+
const input = Buffer.from(msg.dataB64, "base64").toString("utf8");
|
|
192
|
+
// xterm sends device/focus reports through the same onData channel as
|
|
193
|
+
// keystrokes. They must reach the TUI without pretending the user has
|
|
194
|
+
// taken over startup prompt handling.
|
|
195
|
+
if (!isTerminalProtocolResponse(input)) {
|
|
196
|
+
this.cancelTraexStartupWatcher(localThreadId);
|
|
197
|
+
}
|
|
198
|
+
this.attachments.get(msg.attachId)?.write(input);
|
|
199
|
+
}
|
|
50
200
|
return;
|
|
51
201
|
case "term.resize":
|
|
52
202
|
this.attachments.get(msg.attachId)?.resize(msg.cols, msg.rows);
|
|
@@ -57,6 +207,7 @@ export class RunnerSession {
|
|
|
57
207
|
}
|
|
58
208
|
const attachment = this.attachments.get(msg.attachId);
|
|
59
209
|
this.attachments.delete(msg.attachId);
|
|
210
|
+
this.attachmentThreadIds.delete(msg.attachId);
|
|
60
211
|
attachment?.kill();
|
|
61
212
|
return;
|
|
62
213
|
}
|
|
@@ -70,6 +221,9 @@ export class RunnerSession {
|
|
|
70
221
|
case "inject":
|
|
71
222
|
void this.inject(msg);
|
|
72
223
|
return;
|
|
224
|
+
case "collaboration.update":
|
|
225
|
+
void this.updateCollaborationMode(msg);
|
|
226
|
+
return;
|
|
73
227
|
case "live.interrupt":
|
|
74
228
|
void this.interruptLive(msg);
|
|
75
229
|
return;
|
|
@@ -106,27 +260,97 @@ export class RunnerSession {
|
|
|
106
260
|
* the new session stays injectable. */
|
|
107
261
|
mirrorChannel(localThreadId) {
|
|
108
262
|
const target = { id: localThreadId };
|
|
109
|
-
const emit = (event) =>
|
|
263
|
+
const emit = (event) => {
|
|
264
|
+
const sessionId = target.id;
|
|
265
|
+
this.enqueueMirror(() => this.sendMirroredEvent(sessionId, event));
|
|
266
|
+
};
|
|
110
267
|
const retarget = (newId, meta) => {
|
|
111
268
|
target.id = newId;
|
|
112
|
-
this.
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
269
|
+
this.enqueueMirror(() => {
|
|
270
|
+
this.transport.send({
|
|
271
|
+
t: "rotate",
|
|
272
|
+
from: localThreadId,
|
|
273
|
+
to: newId,
|
|
274
|
+
kind: meta.kind,
|
|
275
|
+
workspace: meta.workspace,
|
|
276
|
+
execution: meta.execution,
|
|
277
|
+
...(meta.parentSessionId ? { parentSessionId: meta.parentSessionId } : {}),
|
|
278
|
+
});
|
|
120
279
|
});
|
|
121
280
|
};
|
|
122
281
|
return { emit, retarget };
|
|
123
282
|
}
|
|
283
|
+
enqueueMirror(operation) {
|
|
284
|
+
this.mirrorQueue = this.mirrorQueue.then(operation).catch((error) => {
|
|
285
|
+
if (!this.shuttingDown) {
|
|
286
|
+
this.rejectMirrorImageAcks(error instanceof Error ? error : new Error(String(error)));
|
|
287
|
+
this.shutdown();
|
|
288
|
+
}
|
|
289
|
+
});
|
|
290
|
+
}
|
|
291
|
+
async sendMirroredEvent(sessionId, event) {
|
|
292
|
+
const detached = detachGeneratedImageResult(event);
|
|
293
|
+
if (!detached) {
|
|
294
|
+
this.transport.send({ t: "mirror", sessionId, event });
|
|
295
|
+
return;
|
|
296
|
+
}
|
|
297
|
+
if (detached.result.length > RUNNER_IMAGE_MAX_RESULT_CHARS) {
|
|
298
|
+
this.transport.send({
|
|
299
|
+
t: "mirror",
|
|
300
|
+
sessionId,
|
|
301
|
+
event: generatedImagePreviewUnavailable(detached.event),
|
|
302
|
+
});
|
|
303
|
+
return;
|
|
304
|
+
}
|
|
305
|
+
const transferId = `img_${randomUUID()}`;
|
|
306
|
+
let seq = 0;
|
|
307
|
+
await this.sendMirrorImageFrame(transferId, seq, {
|
|
308
|
+
t: "mirror.image.begin",
|
|
309
|
+
transferId,
|
|
310
|
+
sessionId,
|
|
311
|
+
event: detached.event,
|
|
312
|
+
totalChars: detached.result.length,
|
|
313
|
+
});
|
|
314
|
+
for (let offset = 0; offset < detached.result.length; offset += RUNNER_IMAGE_CHUNK_CHARS) {
|
|
315
|
+
seq += 1;
|
|
316
|
+
await this.sendMirrorImageFrame(transferId, seq, {
|
|
317
|
+
t: "mirror.image.chunk",
|
|
318
|
+
transferId,
|
|
319
|
+
seq,
|
|
320
|
+
data: detached.result.slice(offset, offset + RUNNER_IMAGE_CHUNK_CHARS),
|
|
321
|
+
});
|
|
322
|
+
}
|
|
323
|
+
seq += 1;
|
|
324
|
+
await this.sendMirrorImageFrame(transferId, seq, {
|
|
325
|
+
t: "mirror.image.commit",
|
|
326
|
+
transferId,
|
|
327
|
+
seq,
|
|
328
|
+
});
|
|
329
|
+
}
|
|
330
|
+
sendMirrorImageFrame(transferId, seq, frame) {
|
|
331
|
+
return new Promise((resolve, reject) => {
|
|
332
|
+
const timer = setTimeout(() => {
|
|
333
|
+
this.mirrorImageAcks.delete(transferId);
|
|
334
|
+
reject(new Error("generated image transfer acknowledgement timed out"));
|
|
335
|
+
}, MIRROR_IMAGE_ACK_TIMEOUT_MS);
|
|
336
|
+
timer.unref?.();
|
|
337
|
+
this.mirrorImageAcks.set(transferId, { seq, resolve, reject, timer });
|
|
338
|
+
this.transport.send(frame);
|
|
339
|
+
});
|
|
340
|
+
}
|
|
341
|
+
rejectMirrorImageAcks(error) {
|
|
342
|
+
for (const pending of this.mirrorImageAcks.values()) {
|
|
343
|
+
if (pending.timer)
|
|
344
|
+
clearTimeout(pending.timer);
|
|
345
|
+
pending.reject(error);
|
|
346
|
+
}
|
|
347
|
+
this.mirrorImageAcks.clear();
|
|
348
|
+
}
|
|
124
349
|
/**
|
|
125
|
-
* Eagerly bring up a session's codex-native live view
|
|
126
|
-
*
|
|
127
|
-
*
|
|
128
|
-
*
|
|
129
|
-
* so the TUI is usable immediately and its turns mirror to chat.
|
|
350
|
+
* Eagerly bring up a session's codex-native live view. Fresh sessions connect
|
|
351
|
+
* the discovery listener before launching the detached TUI; known-thread
|
|
352
|
+
* resumes preload first, launch the replacement TUI, then start their
|
|
353
|
+
* persistent observer in the background.
|
|
130
354
|
*/
|
|
131
355
|
async ensureLive(msg) {
|
|
132
356
|
const provider = this.liveProvider;
|
|
@@ -138,43 +362,114 @@ export class RunnerSession {
|
|
|
138
362
|
retargetMirror: retarget,
|
|
139
363
|
});
|
|
140
364
|
if (!started) {
|
|
365
|
+
const runtime = msg.execution?.provider;
|
|
141
366
|
this.transport.send({
|
|
142
367
|
t: "live.ready",
|
|
143
368
|
reqId: msg.reqId,
|
|
144
369
|
localThreadId: msg.localThreadId,
|
|
145
370
|
ok: false,
|
|
146
|
-
error:
|
|
371
|
+
error: providerFailure(provider, msg.localThreadId, {
|
|
372
|
+
runtime,
|
|
373
|
+
code: "native_start_failed",
|
|
374
|
+
message: `${providerDisplayName(runtime)} native session did not start`,
|
|
375
|
+
}),
|
|
147
376
|
});
|
|
148
377
|
return;
|
|
149
378
|
}
|
|
150
379
|
this.liveIds.add(msg.localThreadId);
|
|
380
|
+
const runtime = msg.execution?.provider;
|
|
381
|
+
if (runtime)
|
|
382
|
+
this.liveRuntimes.set(msg.localThreadId, runtime);
|
|
151
383
|
// Launch the TUI attached to the already-bound thread. Re-launch when the
|
|
152
384
|
// pane is absent OR its process has died (`isAlive` probes `#{pane_dead}`) — so a
|
|
153
385
|
// reconnect after the TUI exited restarts it instead of skipping (a
|
|
154
386
|
// launched-once guard would leave a dead "Pane is dead" husk forever).
|
|
155
387
|
if (!this.terminals.get(`${msg.localThreadId}-main`)?.isAlive()) {
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
388
|
+
// Codex/Traex `ensureLiveCodexSession` already performs the backend
|
|
389
|
+
// preload for an existing thread. Match the native lifecycle: launch
|
|
390
|
+
// the TUI immediately after that preload and let the observer subscribe
|
|
391
|
+
// in the background. Fresh sessions likewise launch before discovery.
|
|
392
|
+
const terminalReady = isCodexLineageProvider(runtime)
|
|
393
|
+
? true
|
|
394
|
+
: provider.waitTerminalReady
|
|
395
|
+
? await provider.waitTerminalReady(msg.localThreadId)
|
|
396
|
+
: true;
|
|
159
397
|
if (!terminalReady) {
|
|
398
|
+
const providerName = runtime === "traex" ? "Traex" : runtime === "codex" ? "Codex" : "Provider";
|
|
160
399
|
this.transport.send({
|
|
161
400
|
t: "live.ready",
|
|
162
401
|
reqId: msg.reqId,
|
|
163
402
|
localThreadId: msg.localThreadId,
|
|
164
403
|
ok: false,
|
|
165
|
-
error:
|
|
404
|
+
error: providerFailure(provider, msg.localThreadId, {
|
|
405
|
+
runtime,
|
|
406
|
+
code: "native_resume_failed",
|
|
407
|
+
message: runtime === "codex" || runtime === "traex"
|
|
408
|
+
? `${providerName} app-server was ready, but its existing session could not be resumed for the Terminal`
|
|
409
|
+
: "Provider thread was not ready for Terminal resume",
|
|
410
|
+
}),
|
|
166
411
|
});
|
|
167
412
|
return;
|
|
168
413
|
}
|
|
169
|
-
|
|
414
|
+
try {
|
|
415
|
+
await this.launchCodexPane(msg.localThreadId, msg.cols, msg.rows);
|
|
416
|
+
}
|
|
417
|
+
catch (error) {
|
|
418
|
+
throw error instanceof AgentRuntimeError
|
|
419
|
+
? error
|
|
420
|
+
: nativePhaseError(runtime, "native_terminal_launch_failed", "Terminal failed to launch", error);
|
|
421
|
+
}
|
|
422
|
+
}
|
|
423
|
+
// reference implementation's known-thread path launches `codex --remote resume` first,
|
|
424
|
+
// then creates `_codex_forward_known_thread` as background work. Keep the
|
|
425
|
+
// ordering explicit here: observer connection failure cannot delay or
|
|
426
|
+
// reject cold-resume admission.
|
|
427
|
+
if (isCodexLineageProvider(runtime)) {
|
|
428
|
+
provider.startLiveCodexObserver?.(msg.localThreadId);
|
|
170
429
|
}
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
430
|
+
let ready = true;
|
|
431
|
+
if (runtime === "codex" || runtime === "traex") {
|
|
432
|
+
// Discover thread/started in the background for 30s while injection
|
|
433
|
+
// waits up to 60s for either bridge readiness or that startup error.
|
|
434
|
+
// Do not serialize the two waits here.
|
|
435
|
+
this.monitorCodexThreadStartup(msg.localThreadId, runtime, provider);
|
|
436
|
+
}
|
|
437
|
+
else if (msg.waitForReady !== false && provider.waitLiveReady) {
|
|
438
|
+
ready = await provider.waitLiveReady(msg.localThreadId);
|
|
439
|
+
}
|
|
440
|
+
const readinessError = ready
|
|
441
|
+
? undefined
|
|
442
|
+
: providerFailure(provider, msg.localThreadId, {
|
|
443
|
+
runtime,
|
|
444
|
+
code: "native_readiness_failed",
|
|
445
|
+
message: `${providerDisplayName(runtime)} native session was not ready`,
|
|
446
|
+
});
|
|
447
|
+
this.transport.send({
|
|
448
|
+
t: "live.ready",
|
|
449
|
+
reqId: msg.reqId,
|
|
450
|
+
localThreadId: msg.localThreadId,
|
|
451
|
+
ok: ready,
|
|
452
|
+
...(ready ? {} : { error: readinessError }),
|
|
453
|
+
});
|
|
454
|
+
}
|
|
455
|
+
catch (error) {
|
|
456
|
+
this.transport.send({
|
|
457
|
+
t: "live.ready",
|
|
458
|
+
reqId: msg.reqId,
|
|
459
|
+
localThreadId: msg.localThreadId,
|
|
460
|
+
ok: false,
|
|
461
|
+
error: toWireError(error),
|
|
462
|
+
});
|
|
463
|
+
}
|
|
464
|
+
}
|
|
465
|
+
monitorCodexThreadStartup(localThreadId, runtime, provider) {
|
|
466
|
+
if (!provider.waitLiveReady)
|
|
467
|
+
return;
|
|
468
|
+
void provider.waitLiveReady(localThreadId, CODEX_THREAD_START_TIMEOUT_MS).then((ready) => {
|
|
469
|
+
if (ready)
|
|
470
|
+
return;
|
|
471
|
+
const terminal = this.terminals.get(`${localThreadId}-main`);
|
|
472
|
+
const paneFailure = terminal && !terminal.isAlive()
|
|
178
473
|
? terminal
|
|
179
474
|
.capturePane()
|
|
180
475
|
.split("\n")
|
|
@@ -184,42 +479,106 @@ export class RunnerSession {
|
|
|
184
479
|
.join(" ")
|
|
185
480
|
.slice(-1_000)
|
|
186
481
|
: "";
|
|
187
|
-
const
|
|
482
|
+
const detail = provider.liveSessionError?.(localThreadId)
|
|
188
483
|
?? (paneFailure
|
|
189
|
-
?
|
|
190
|
-
: "
|
|
484
|
+
? `${providerDisplayName(runtime)} Terminal exited before native session discovery: ${paneFailure}`
|
|
485
|
+
: `${runtime === "traex" ? "Traex" : "Codex"} TUI did not publish thread/started within ${CODEX_THREAD_START_TIMEOUT_MS / 1_000}s after app-server and observer readiness`);
|
|
486
|
+
this.failCodexThreadStartup(localThreadId, provider, new AgentRuntimeError(detail, 503, paneFailure
|
|
487
|
+
? "native_terminal_exited_before_session"
|
|
488
|
+
: "native_thread_discovery_timeout"));
|
|
489
|
+
}).catch((error) => {
|
|
490
|
+
this.failCodexThreadStartup(localThreadId, provider, error instanceof AgentRuntimeError
|
|
491
|
+
? error
|
|
492
|
+
: nativePhaseError(runtime, "native_thread_discovery_failed", "native session discovery failed", error));
|
|
493
|
+
});
|
|
494
|
+
}
|
|
495
|
+
/** Discovery failure is terminal for this partial native launch. Publish the
|
|
496
|
+
* exact cause first so an in-flight injection wakes, then drop the observer
|
|
497
|
+
* and TUI so a later task retry creates a clean launch instead of reusing an
|
|
498
|
+
* already-rejected readiness promise. */
|
|
499
|
+
failCodexThreadStartup(localThreadId, provider, error) {
|
|
500
|
+
if (!provider.failLiveStartup?.(localThreadId, error))
|
|
501
|
+
return false;
|
|
502
|
+
const watcher = this.terminalWatchers.get(localThreadId);
|
|
503
|
+
if (watcher)
|
|
504
|
+
clearInterval(watcher);
|
|
505
|
+
this.terminalWatchers.delete(localThreadId);
|
|
506
|
+
this.cancelTraexStartupWatcher(localThreadId);
|
|
507
|
+
try {
|
|
508
|
+
this.terminals.close(`${localThreadId}-main`);
|
|
509
|
+
}
|
|
510
|
+
catch (closeError) {
|
|
511
|
+
console.warn(`[runner] session=${localThreadId} failed to close native Terminal after startup failure: ${closeError instanceof Error ? closeError.message : String(closeError)}`);
|
|
512
|
+
}
|
|
513
|
+
if (provider.teardownLiveCodexSession) {
|
|
514
|
+
provider.teardownLiveCodexSession(localThreadId);
|
|
515
|
+
}
|
|
516
|
+
else {
|
|
517
|
+
provider.stopLiveCodexSession?.(localThreadId);
|
|
518
|
+
}
|
|
519
|
+
this.liveIds.delete(localThreadId);
|
|
520
|
+
return true;
|
|
521
|
+
}
|
|
522
|
+
async inject(msg) {
|
|
523
|
+
const provider = this.liveProvider;
|
|
524
|
+
try {
|
|
525
|
+
const input = msg.input ?? msg.text;
|
|
526
|
+
const result = (await provider.injectMessage?.(msg.localThreadId, input, msg.options))
|
|
527
|
+
?? { outcome: "notLive" };
|
|
528
|
+
// App-server injection is independent of the Terminal TUI startup, so it
|
|
529
|
+
// must not cancel prompt handling for the pane that is still starting.
|
|
530
|
+
const error = result.outcome === "injected" || result.outcome === "steered"
|
|
531
|
+
? undefined
|
|
532
|
+
: providerFailure(provider, msg.localThreadId, {
|
|
533
|
+
code: result.outcome === "notReady"
|
|
534
|
+
? "native_thread_not_ready"
|
|
535
|
+
: result.outcome === "notLive"
|
|
536
|
+
? "native_session_not_live"
|
|
537
|
+
: "native_message_injection_failed",
|
|
538
|
+
message: result.outcome === "notReady"
|
|
539
|
+
? "Native session was not ready for message injection"
|
|
540
|
+
: result.outcome === "notLive"
|
|
541
|
+
? "Native session was not live for message injection"
|
|
542
|
+
: "Native message injection failed",
|
|
543
|
+
});
|
|
191
544
|
this.transport.send({
|
|
192
|
-
t: "
|
|
545
|
+
t: "injected",
|
|
193
546
|
reqId: msg.reqId,
|
|
194
547
|
localThreadId: msg.localThreadId,
|
|
195
|
-
|
|
196
|
-
...(
|
|
548
|
+
result,
|
|
549
|
+
...(error ? { error } : {}),
|
|
197
550
|
});
|
|
198
551
|
}
|
|
199
552
|
catch (error) {
|
|
200
553
|
this.transport.send({
|
|
201
|
-
t: "
|
|
554
|
+
t: "injected",
|
|
202
555
|
reqId: msg.reqId,
|
|
203
556
|
localThreadId: msg.localThreadId,
|
|
204
|
-
|
|
205
|
-
error:
|
|
557
|
+
result: { outcome: "failed" },
|
|
558
|
+
error: toWireError(error),
|
|
206
559
|
});
|
|
207
560
|
}
|
|
208
561
|
}
|
|
209
|
-
async
|
|
210
|
-
const provider = this.liveProvider;
|
|
562
|
+
async updateCollaborationMode(msg) {
|
|
211
563
|
try {
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
564
|
+
if (!this.liveProvider.updateCollaborationMode) {
|
|
565
|
+
throw new AgentRuntimeError("Native collaboration mode is unavailable", 409, "collaboration_mode_unavailable");
|
|
566
|
+
}
|
|
567
|
+
await this.liveProvider.updateCollaborationMode(msg.localThreadId, msg.mode);
|
|
568
|
+
this.transport.send({
|
|
569
|
+
t: "collaboration.updated",
|
|
570
|
+
reqId: msg.reqId,
|
|
571
|
+
localThreadId: msg.localThreadId,
|
|
572
|
+
ok: true,
|
|
573
|
+
});
|
|
215
574
|
}
|
|
216
575
|
catch (error) {
|
|
217
576
|
this.transport.send({
|
|
218
|
-
t: "
|
|
577
|
+
t: "collaboration.updated",
|
|
219
578
|
reqId: msg.reqId,
|
|
220
579
|
localThreadId: msg.localThreadId,
|
|
221
|
-
|
|
222
|
-
error:
|
|
580
|
+
ok: false,
|
|
581
|
+
error: toWireError(error),
|
|
223
582
|
});
|
|
224
583
|
}
|
|
225
584
|
}
|
|
@@ -231,7 +590,7 @@ export class RunnerSession {
|
|
|
231
590
|
ok = (await provider.interruptLive?.(msg.localThreadId)) ?? false;
|
|
232
591
|
}
|
|
233
592
|
catch (e) {
|
|
234
|
-
error =
|
|
593
|
+
error = toWireError(e);
|
|
235
594
|
}
|
|
236
595
|
this.transport.send({
|
|
237
596
|
t: "interrupted",
|
|
@@ -248,7 +607,8 @@ export class RunnerSession {
|
|
|
248
607
|
const spec = await this.liveProvider.codexTerminalSpec?.(localThreadId);
|
|
249
608
|
if (!spec)
|
|
250
609
|
return;
|
|
251
|
-
const
|
|
610
|
+
const terminalId = `${localThreadId}-main`;
|
|
611
|
+
const term = this.terminals.getOrCreate(terminalId, {
|
|
252
612
|
cwd: spec.cwd,
|
|
253
613
|
command: spec.command,
|
|
254
614
|
args: spec.args,
|
|
@@ -256,15 +616,153 @@ export class RunnerSession {
|
|
|
256
616
|
rows: rows ?? 40,
|
|
257
617
|
...(spec.env ? { env: spec.env } : {}),
|
|
258
618
|
});
|
|
619
|
+
if (spec.skipTraexStartupPrompts) {
|
|
620
|
+
const watcher = Symbol(localThreadId);
|
|
621
|
+
this.traexStartupWatchers.set(localThreadId, watcher);
|
|
622
|
+
void this.skipTraexStartupPrompts(localThreadId, term, watcher);
|
|
623
|
+
}
|
|
259
624
|
this.liveProvider.attachTerminalInjector?.(localThreadId, term);
|
|
625
|
+
this.watchNativeTerminal(localThreadId, terminalId, term);
|
|
626
|
+
}
|
|
627
|
+
watchNativeTerminal(localThreadId, terminalId, terminal) {
|
|
628
|
+
const previous = this.terminalWatchers.get(localThreadId);
|
|
629
|
+
if (previous)
|
|
630
|
+
clearInterval(previous);
|
|
631
|
+
let checking = false;
|
|
632
|
+
const timer = setInterval(() => {
|
|
633
|
+
if (checking || this.shuttingDown || this.terminals.get(terminalId) !== terminal)
|
|
634
|
+
return;
|
|
635
|
+
checking = true;
|
|
636
|
+
const probe = terminal.lifecycleLivenessAsync
|
|
637
|
+
? terminal.lifecycleLivenessAsync()
|
|
638
|
+
: terminal.livenessAsync
|
|
639
|
+
? terminal.livenessAsync()
|
|
640
|
+
: terminal.isAliveAsync
|
|
641
|
+
? terminal.isAliveAsync().then((alive) => alive ? "alive" : "dead")
|
|
642
|
+
: Promise.resolve(terminal.isAlive() ? "alive" : "dead");
|
|
643
|
+
void probe.then((liveness) => {
|
|
644
|
+
// A failed pane probe is inconclusive. Only reference implementation's lifecycle
|
|
645
|
+
// evidence — capture target gone or explicit pane_dead — may fail it.
|
|
646
|
+
if (liveness !== "dead" ||
|
|
647
|
+
this.shuttingDown ||
|
|
648
|
+
this.terminals.get(terminalId) !== terminal)
|
|
649
|
+
return;
|
|
650
|
+
clearInterval(timer);
|
|
651
|
+
this.terminalWatchers.delete(localThreadId);
|
|
652
|
+
this.cancelTraexStartupWatcher(localThreadId);
|
|
653
|
+
const runtime = this.liveRuntimes.get(localThreadId);
|
|
654
|
+
const paneFailure = terminalFailureDetail(terminal.capturePane?.() ?? "");
|
|
655
|
+
const startupFailed = this.failCodexThreadStartup(localThreadId, this.liveProvider, nativePhaseError(runtime, "native_terminal_exited_before_session", "Terminal exited before native session discovery completed", paneFailure || undefined));
|
|
656
|
+
if (isCodexLineageProvider(runtime)) {
|
|
657
|
+
if (!startupFailed) {
|
|
658
|
+
try {
|
|
659
|
+
this.terminals.close(terminalId);
|
|
660
|
+
}
|
|
661
|
+
catch (closeError) {
|
|
662
|
+
console.warn(`[runner] session=${localThreadId} failed to close exited native Terminal: ${closeError instanceof Error ? closeError.message : String(closeError)}`);
|
|
663
|
+
}
|
|
664
|
+
const exitError = nativePhaseError(runtime, "native_terminal_exited", "Terminal exited unexpectedly", paneFailure || undefined);
|
|
665
|
+
if (this.liveProvider.teardownLiveCodexSession) {
|
|
666
|
+
this.liveProvider.teardownLiveCodexSession(localThreadId, exitError);
|
|
667
|
+
}
|
|
668
|
+
else {
|
|
669
|
+
this.liveProvider.failLiveSession?.(localThreadId, exitError);
|
|
670
|
+
this.liveProvider.stopLiveCodexSession?.(localThreadId);
|
|
671
|
+
}
|
|
672
|
+
this.liveIds.delete(localThreadId);
|
|
673
|
+
console.warn(`[runner] session=${localThreadId} ${providerDisplayName(runtime)} auxiliary Terminal exited; native runtime torn down for cold resume`);
|
|
674
|
+
}
|
|
675
|
+
return;
|
|
676
|
+
}
|
|
677
|
+
this.liveProvider.failLiveSession?.(localThreadId, nativePhaseError(runtime, "native_terminal_exited", "Terminal exited unexpectedly", paneFailure || undefined));
|
|
678
|
+
}).catch((error) => {
|
|
679
|
+
console.warn(`[runner] session=${localThreadId} native Terminal liveness probe failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
680
|
+
}).finally(() => {
|
|
681
|
+
checking = false;
|
|
682
|
+
});
|
|
683
|
+
}, 1_000);
|
|
684
|
+
timer.unref?.();
|
|
685
|
+
this.terminalWatchers.set(localThreadId, timer);
|
|
686
|
+
}
|
|
687
|
+
cancelTraexStartupWatcher(localThreadId) {
|
|
688
|
+
if (localThreadId)
|
|
689
|
+
this.traexStartupWatchers.delete(localThreadId);
|
|
690
|
+
}
|
|
691
|
+
async skipTraexStartupPrompts(localThreadId, terminal, watcher) {
|
|
692
|
+
const prompts = [
|
|
693
|
+
{
|
|
694
|
+
id: "welcome",
|
|
695
|
+
matches: (pane) => pane.includes("welcome to trae cli") && pane.includes("press enter to continue"),
|
|
696
|
+
dismiss: () => terminal.sendEnter(),
|
|
697
|
+
},
|
|
698
|
+
{
|
|
699
|
+
id: "migration",
|
|
700
|
+
matches: (pane) => (pane.includes("legacy traecode cli data detected") ||
|
|
701
|
+
pane.includes("legacy trae cli data detected")) &&
|
|
702
|
+
pane.includes("select what to import") &&
|
|
703
|
+
(pane.includes("skip for now") || pane.includes("don't ask again")),
|
|
704
|
+
dismiss: () => terminal.interrupt(),
|
|
705
|
+
},
|
|
706
|
+
{
|
|
707
|
+
id: "hooks",
|
|
708
|
+
matches: (pane) => pane.includes("hooks need review") &&
|
|
709
|
+
pane.includes("trust all and continue") &&
|
|
710
|
+
pane.includes("continue without trusting"),
|
|
711
|
+
dismiss: () => terminal.interrupt(),
|
|
712
|
+
},
|
|
713
|
+
];
|
|
714
|
+
let activePromptId;
|
|
715
|
+
let lastDismissedAt = 0;
|
|
716
|
+
const startedAt = Date.now();
|
|
717
|
+
let deadline = startedAt + TRAEX_STARTUP_WATCH_MS;
|
|
718
|
+
const authorizationDeadline = startedAt + TRAEX_AUTHORIZATION_WAIT_MS;
|
|
719
|
+
const terminalId = `${localThreadId}-main`;
|
|
720
|
+
while (!this.shuttingDown &&
|
|
721
|
+
Date.now() < deadline &&
|
|
722
|
+
this.traexStartupWatchers.get(localThreadId) === watcher &&
|
|
723
|
+
this.terminals.get(terminalId) === terminal) {
|
|
724
|
+
// Do not treat a composer frame as completion: Traex can render it before
|
|
725
|
+
// the startup modals arrive. The bounded deadline stops this watcher.
|
|
726
|
+
const pane = normalizeTraexPane(terminal.capturePane());
|
|
727
|
+
const prompt = prompts.find((candidate) => candidate.matches(pane));
|
|
728
|
+
const now = Date.now();
|
|
729
|
+
const authorizationPending = isTraexAuthorizationPending(pane);
|
|
730
|
+
// Human device authorization routinely takes longer than the normal
|
|
731
|
+
// startup-modal window. Keep watching while that known screen remains,
|
|
732
|
+
// then preserve a full window for welcome/migration/hooks after sign-in.
|
|
733
|
+
// The separate cap prevents an abandoned auth screen from polling forever.
|
|
734
|
+
if (authorizationPending && now < authorizationDeadline) {
|
|
735
|
+
deadline = now + TRAEX_STARTUP_WATCH_MS;
|
|
736
|
+
}
|
|
737
|
+
if (!prompt) {
|
|
738
|
+
activePromptId = undefined;
|
|
739
|
+
}
|
|
740
|
+
else if (prompt.id !== activePromptId ||
|
|
741
|
+
now - lastDismissedAt >= TRAEX_PROMPT_RETRY_MS) {
|
|
742
|
+
prompt.dismiss();
|
|
743
|
+
activePromptId = prompt.id;
|
|
744
|
+
lastDismissedAt = now;
|
|
745
|
+
}
|
|
746
|
+
await new Promise((resolve) => {
|
|
747
|
+
const timer = setTimeout(resolve, authorizationPending ? TRAEX_AUTHORIZATION_POLL_MS : TRAEX_STARTUP_POLL_MS);
|
|
748
|
+
timer.unref();
|
|
749
|
+
});
|
|
750
|
+
}
|
|
751
|
+
if (this.traexStartupWatchers.get(localThreadId) === watcher) {
|
|
752
|
+
this.traexStartupWatchers.delete(localThreadId);
|
|
753
|
+
}
|
|
260
754
|
}
|
|
261
755
|
stopLive() {
|
|
756
|
+
for (const timer of this.terminalWatchers.values())
|
|
757
|
+
clearInterval(timer);
|
|
758
|
+
this.terminalWatchers.clear();
|
|
262
759
|
for (const id of this.liveIds) {
|
|
263
760
|
this.liveProvider.stopLiveCodexSession?.(id, {
|
|
264
761
|
deferClaudeInteractionCleanup: true,
|
|
265
762
|
});
|
|
266
763
|
}
|
|
267
764
|
this.liveIds.clear();
|
|
765
|
+
this.liveRuntimes.clear();
|
|
268
766
|
}
|
|
269
767
|
/** Stop event forwarding, kill native terminals/hooks, then synchronously
|
|
270
768
|
* scrub provider handoff files before the child process is allowed to exit. */
|
|
@@ -272,6 +770,7 @@ export class RunnerSession {
|
|
|
272
770
|
if (this.shuttingDown)
|
|
273
771
|
return;
|
|
274
772
|
this.shuttingDown = true;
|
|
773
|
+
this.rejectMirrorImageAcks(new Error("runner is shutting down"));
|
|
275
774
|
this.stopLive();
|
|
276
775
|
this.terminals.closeAll();
|
|
277
776
|
this.liveProvider.finalizeStoppedLiveSessions?.();
|
|
@@ -336,6 +835,8 @@ export class RunnerSession {
|
|
|
336
835
|
return;
|
|
337
836
|
}
|
|
338
837
|
this.attachments.set(msg.attachId, attachment);
|
|
838
|
+
if (msg.localThreadId)
|
|
839
|
+
this.attachmentThreadIds.set(msg.attachId, msg.localThreadId);
|
|
339
840
|
attachment.onData((chunk) => this.transport.send({
|
|
340
841
|
t: "term.data",
|
|
341
842
|
attachId: msg.attachId,
|
|
@@ -343,6 +844,7 @@ export class RunnerSession {
|
|
|
343
844
|
}));
|
|
344
845
|
attachment.onExit((info) => {
|
|
345
846
|
this.attachments.delete(msg.attachId);
|
|
847
|
+
this.attachmentThreadIds.delete(msg.attachId);
|
|
346
848
|
this.transport.send({ t: "term.exit", attachId: msg.attachId, exitCode: info.exitCode });
|
|
347
849
|
});
|
|
348
850
|
this.transport.send({ t: "term.opened", attachId: msg.attachId, role });
|