@rynx-ai/runtime 0.1.11-beta.5 → 0.1.11-beta.51
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 +103 -1
- package/dist/claude/native-bridge.js +460 -30
- package/dist/claude/native-hook-main.js +81 -1
- package/dist/claude/native-hooks.js +7 -0
- package/dist/claude/native-integration.d.ts +178 -26
- package/dist/claude/native-integration.js +1571 -170
- package/dist/claude/session-status.d.ts +39 -0
- package/dist/claude/session-status.js +163 -0
- package/dist/claude/transcript-clone.d.ts +18 -0
- package/dist/claude/transcript-clone.js +497 -0
- package/dist/claude/transcript.d.ts +27 -4
- package/dist/claude/transcript.js +183 -46
- 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 +96 -3
- package/dist/codex-app-server/forwarder.js +542 -57
- package/dist/codex-app-server/mapping.d.ts +3 -6
- package/dist/codex-app-server/mapping.js +228 -36
- 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/process-registry.d.ts +36 -0
- package/dist/codex-app-server/process-registry.js +320 -0
- package/dist/codex-app-server/protocol.d.ts +68 -7
- package/dist/codex-app-server/transport.d.ts +2 -2
- package/dist/codex-app-server/transport.js +4 -0
- package/dist/codex-app-server/ws-channel.d.ts +8 -1
- package/dist/codex-app-server/ws-channel.js +113 -28
- package/dist/codex-child-env.js +3 -32
- package/dist/codex-home.d.ts +35 -3
- package/dist/codex-home.js +323 -18
- package/dist/codex-session-store.d.ts +23 -0
- package/dist/codex-session-store.js +21 -0
- package/dist/host.d.ts +106 -48
- package/dist/host.js +2024 -640
- package/dist/index.d.ts +3 -3
- package/dist/index.js +1 -1
- package/dist/input-resources.d.ts +4 -0
- package/dist/input-resources.js +21 -5
- package/dist/models-catalog.js +12 -3
- package/dist/runner/child.d.ts +105 -28
- package/dist/runner/child.js +1625 -100
- package/dist/runner/manager.d.ts +114 -46
- package/dist/runner/manager.js +1523 -446
- package/dist/runner/protocol.d.ts +212 -24
- 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/runner/transport.d.ts +18 -2
- package/dist/runner/transport.js +82 -3
- package/dist/runner-main.js +8 -3
- package/dist/terminal/codex-tui.d.ts +4 -0
- package/dist/terminal/codex-tui.js +5 -0
- package/dist/terminal/control-parser.d.ts +39 -0
- package/dist/terminal/control-parser.js +172 -0
- package/dist/terminal/registry.d.ts +18 -15
- package/dist/terminal/registry.js +44 -23
- package/dist/terminal/spool.d.ts +47 -0
- package/dist/terminal/spool.js +231 -0
- package/dist/terminal/tmux.d.ts +126 -74
- package/dist/terminal/tmux.js +807 -211
- package/package.json +4 -4
package/dist/runner/child.js
CHANGED
|
@@ -1,20 +1,230 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
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 { appendFileSync, mkdirSync, renameSync, rmSync, statSync, } from "node:fs";
|
|
12
|
+
import { dirname } from "node:path";
|
|
13
|
+
import { AgentRuntimeError, } from "@rynx-ai/core";
|
|
14
|
+
import { TerminalRegistry, } from "../terminal/registry.js";
|
|
15
|
+
import { RUNNER_IMAGE_CHUNK_CHARS, RUNNER_IMAGE_MAX_RESULT_CHARS, toWireError, } from "./protocol.js";
|
|
16
|
+
import { isCodexLineageProvider } from "./startup-policy.js";
|
|
17
|
+
import { RunnerTransportWriteError, } from "./transport.js";
|
|
18
|
+
const TERMINAL_PREPARATION_TTL_MS = 30_000;
|
|
19
|
+
/** Maximum time for a fresh Codex-lineage TUI to publish its native thread. */
|
|
20
|
+
const CODEX_THREAD_START_TIMEOUT_MS = 30_000;
|
|
21
|
+
/** Traex may spend most of the normal discovery window installing an update.
|
|
22
|
+
* Keep one fresh discovery window after visible update activity, but never let
|
|
23
|
+
* a stuck updater hold the partial native launch indefinitely. */
|
|
24
|
+
const TRAEX_UPDATE_THREAD_START_MAX_MS = 2 * 60_000;
|
|
25
|
+
function providerDisplayName(runtime) {
|
|
26
|
+
return runtime === "traex" ? "Traex" : runtime === "claude" ? "Claude" : "Codex";
|
|
27
|
+
}
|
|
28
|
+
function providerFailure(provider, localThreadId, fallback) {
|
|
29
|
+
return provider.liveSessionFailure?.(localThreadId) ?? {
|
|
30
|
+
message: provider.liveSessionError?.(localThreadId) ?? fallback.message,
|
|
31
|
+
code: fallback.code,
|
|
32
|
+
statusCode: 503,
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
function nativePhaseError(runtime, code, phase, cause) {
|
|
36
|
+
const detail = cause === undefined
|
|
37
|
+
? ""
|
|
38
|
+
: `: ${cause instanceof Error ? cause.message : String(cause)}`;
|
|
39
|
+
return new AgentRuntimeError(`${providerDisplayName(runtime)} ${phase}${detail}`, 503, code);
|
|
40
|
+
}
|
|
41
|
+
function terminalFailureDetail(pane) {
|
|
42
|
+
// A captured pane is a screen snapshot, not stderr. Do not present ordinary
|
|
43
|
+
// TUI chrome ("Working", shortcuts, model/status bars) as the process's exit
|
|
44
|
+
// cause. Retain only lines that plausibly carry an actual startup/runtime
|
|
45
|
+
// failure; the structured error still reports the proven pane exit itself.
|
|
46
|
+
return pane
|
|
47
|
+
.split("\n")
|
|
48
|
+
.map((line) => line.trim())
|
|
49
|
+
.filter(Boolean)
|
|
50
|
+
.filter((line) => /\b(?:error|fatal|failed|failure|panic|refused|denied|unauthorized|invalid)\b/i.test(line))
|
|
51
|
+
.slice(-6)
|
|
52
|
+
.join(" ")
|
|
53
|
+
.slice(-1_000);
|
|
54
|
+
}
|
|
55
|
+
const TRAEX_STARTUP_WATCH_MS = 20_000;
|
|
56
|
+
const TRAEX_STARTUP_DISCOVERY_MS = 5_000;
|
|
57
|
+
const TRAEX_STARTUP_POLL_MS = 100;
|
|
58
|
+
const TRAEX_UPDATE_POLL_MS = 1_000;
|
|
59
|
+
const TRAEX_AUTHORIZATION_POLL_MS = 500;
|
|
60
|
+
const TRAEX_AUTHORIZATION_WAIT_MS = 15 * 60_000;
|
|
61
|
+
const TRAEX_ESCAPE_CONFIRM_MS = 1_000;
|
|
62
|
+
const TRAEX_ESCAPE_COOLDOWN_MS = 1_000;
|
|
63
|
+
const TRAEX_REPEAT_PROMPT_CONFIRM_MS = 5_000;
|
|
64
|
+
const MIRROR_IMAGE_ACK_TIMEOUT_MS = 30_000;
|
|
65
|
+
const MIRROR_ACK_TIMEOUT_MS = 30_000;
|
|
66
|
+
const MIRROR_DEAD_LETTER_MAX_BYTES = 50 * 1024 * 1024;
|
|
67
|
+
class MirrorDeliveryError extends Error {
|
|
68
|
+
classification;
|
|
69
|
+
deliveryId;
|
|
70
|
+
constructor(message, classification, deliveryId) {
|
|
71
|
+
super(message);
|
|
72
|
+
this.classification = classification;
|
|
73
|
+
this.deliveryId = deliveryId;
|
|
74
|
+
this.name = "MirrorDeliveryError";
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
class MirrorGenerationSupersededError extends Error {
|
|
78
|
+
generation;
|
|
79
|
+
deliveryId;
|
|
80
|
+
constructor(generation, deliveryId) {
|
|
81
|
+
super(`mirror generation ${generation} was superseded by Session rotation`);
|
|
82
|
+
this.generation = generation;
|
|
83
|
+
this.deliveryId = deliveryId;
|
|
84
|
+
this.name = "MirrorGenerationSupersededError";
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
/** Delivery retry schedule: 1, 2, 4, 8, 16, 30, 30… seconds.
|
|
88
|
+
* Clamp before exponentiation so an indefinitely transient outage cannot
|
|
89
|
+
* overflow after thousands of attempts. */
|
|
90
|
+
export function mirrorRetryDelayMs(failedAttempt) {
|
|
91
|
+
const exponent = Math.min(Math.max(failedAttempt - 1, 0), 32);
|
|
92
|
+
return Math.min(2 ** exponent, 30) * 1_000;
|
|
93
|
+
}
|
|
94
|
+
function defaultMirrorDeliveryPolicy(event) {
|
|
95
|
+
switch (event.type) {
|
|
96
|
+
case "response.output_item.done":
|
|
97
|
+
case "session.input.consumed":
|
|
98
|
+
case "session.interaction.requested":
|
|
99
|
+
case "session.interaction.resolved":
|
|
100
|
+
case "session.interaction.cancelled":
|
|
101
|
+
return "ordinary";
|
|
102
|
+
default:
|
|
103
|
+
return "best-effort";
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
function appendMirrorDeadLetter(path, entry) {
|
|
107
|
+
const line = `${JSON.stringify(entry)}\n`;
|
|
108
|
+
mkdirSync(dirname(path), { recursive: true, mode: 0o700 });
|
|
109
|
+
let currentBytes = 0;
|
|
110
|
+
try {
|
|
111
|
+
currentBytes = statSync(path).size;
|
|
112
|
+
}
|
|
113
|
+
catch {
|
|
114
|
+
// absent
|
|
115
|
+
}
|
|
116
|
+
if (currentBytes >= MIRROR_DEAD_LETTER_MAX_BYTES) {
|
|
117
|
+
rmSync(`${path}.1`, { force: true });
|
|
118
|
+
try {
|
|
119
|
+
renameSync(path, `${path}.1`);
|
|
120
|
+
}
|
|
121
|
+
catch {
|
|
122
|
+
// absent/raced — append creates the active file below
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
appendFileSync(path, line, { mode: 0o600 });
|
|
126
|
+
}
|
|
127
|
+
function normalizeTraexPane(pane) {
|
|
128
|
+
return pane.toLowerCase().replace(/\s+/g, " ").trim();
|
|
129
|
+
}
|
|
130
|
+
function isTraexAuthorizationPending(pane) {
|
|
131
|
+
return pane.includes("waiting for authorization") &&
|
|
132
|
+
pane.includes("open this link in your browser") &&
|
|
133
|
+
pane.includes("press esc to cancel");
|
|
134
|
+
}
|
|
135
|
+
function isTraexStartupActivity(pane) {
|
|
136
|
+
// The visible pane can include old conversation text. A mounted composer is
|
|
137
|
+
// evidence that an update phrase belongs to history rather than the active
|
|
138
|
+
// startup screen.
|
|
139
|
+
const composerReady = pane.includes("❯") &&
|
|
140
|
+
pane.includes("context ") &&
|
|
141
|
+
pane.includes(" left");
|
|
142
|
+
return !composerReady &&
|
|
143
|
+
(pane.includes("updating traecode cli") || pane.includes("updating trae cli"));
|
|
144
|
+
}
|
|
145
|
+
function isTerminalProtocolResponse(input) {
|
|
146
|
+
// xterm answers terminal queries through the same onData channel as real
|
|
147
|
+
// keystrokes. CSI carries device/focus/position reports; OSC carries color
|
|
148
|
+
// query replies such as `OSC 10;rgb:... ST` and `OSC 11;rgb:... ST`.
|
|
149
|
+
// Neither is evidence that the user has taken over startup prompt handling.
|
|
150
|
+
return /^(?:\x1b\[(?:(?:\?|>)[0-9;]*c|[0-9;]*(?:n|R|t)|[IO])|\x1b\][0-9]+;[^\x07\x1b]*(?:\x07|\x1b\\))+$/.test(input);
|
|
151
|
+
}
|
|
152
|
+
function detachGeneratedImageResult(event) {
|
|
153
|
+
if (event.type !== "response.output_item.done" ||
|
|
154
|
+
event.item.type !== "function_call_output") {
|
|
155
|
+
return undefined;
|
|
156
|
+
}
|
|
157
|
+
const data = event.item.data;
|
|
158
|
+
const pending = data.__rynxGeneratedImage;
|
|
159
|
+
if (!pending?.result)
|
|
160
|
+
return undefined;
|
|
161
|
+
const { result, ...withoutResult } = pending;
|
|
162
|
+
return {
|
|
163
|
+
result,
|
|
164
|
+
event: {
|
|
165
|
+
...event,
|
|
166
|
+
item: {
|
|
167
|
+
...event.item,
|
|
168
|
+
data: {
|
|
169
|
+
...data,
|
|
170
|
+
__rynxGeneratedImage: withoutResult,
|
|
171
|
+
},
|
|
172
|
+
},
|
|
173
|
+
},
|
|
174
|
+
};
|
|
175
|
+
}
|
|
176
|
+
function generatedImagePreviewUnavailable(event) {
|
|
177
|
+
if (event.type !== "response.output_item.done" ||
|
|
178
|
+
event.item.type !== "function_call_output") {
|
|
179
|
+
return event;
|
|
180
|
+
}
|
|
181
|
+
const data = event.item.data;
|
|
182
|
+
const { __rynxGeneratedImage: _omitted, ...publicData } = data;
|
|
183
|
+
return {
|
|
184
|
+
...event,
|
|
185
|
+
item: {
|
|
186
|
+
...event.item,
|
|
187
|
+
data: {
|
|
188
|
+
...publicData,
|
|
189
|
+
output: `${event.item.data.output}\nImage preview unavailable.`.trim(),
|
|
190
|
+
},
|
|
191
|
+
},
|
|
192
|
+
};
|
|
193
|
+
}
|
|
3
194
|
export class RunnerSession {
|
|
4
195
|
transport;
|
|
5
196
|
executor;
|
|
6
197
|
onShutdown;
|
|
7
198
|
/** Live terminals hosted by this session, and per-attach client handles. */
|
|
8
199
|
terminals = new TerminalRegistry();
|
|
200
|
+
preparations = new Map();
|
|
9
201
|
attachments = new Map();
|
|
10
202
|
attachmentThreadIds = new Map();
|
|
203
|
+
attachmentRoles = new Map();
|
|
11
204
|
traexStartupWatchers = new Map();
|
|
205
|
+
traexStartupGates = new Map();
|
|
206
|
+
traexUpdateWatchers = new Map();
|
|
207
|
+
traexStartupUpdateActivity = new Map();
|
|
208
|
+
codexStartupMonitors = new Map();
|
|
209
|
+
terminalWatchers = new Map();
|
|
12
210
|
/** Opens are async; a close received before attach resolves tombstones the id. */
|
|
13
211
|
pendingTerminalOpens = new Set();
|
|
14
212
|
cancelledTerminalOpens = new Set();
|
|
15
213
|
/** Session ids with a live codex forwarder started here (stopped on shutdown). */
|
|
16
214
|
liveIds = new Set();
|
|
215
|
+
mirrorQueues = new Map();
|
|
216
|
+
mirrorAcks = new Map();
|
|
217
|
+
mirrorImageAcks = new Map();
|
|
218
|
+
mirrorRetryWaiters = new Map();
|
|
219
|
+
rotationPublishAcks = new Map();
|
|
220
|
+
rotationAppliedAcks = new Map();
|
|
221
|
+
/** Provider name retained for asynchronous Terminal-exit diagnostics. */
|
|
222
|
+
liveRuntimes = new Map();
|
|
223
|
+
terminalStatuses = new Map();
|
|
224
|
+
consecutiveMirrorFailures = 0;
|
|
225
|
+
mirrorDegraded = false;
|
|
17
226
|
shuttingDown = false;
|
|
227
|
+
shutdownPromise = null;
|
|
18
228
|
constructor({ transport, executor, onShutdown }) {
|
|
19
229
|
this.transport = transport;
|
|
20
230
|
this.executor = executor;
|
|
@@ -24,15 +234,107 @@ export class RunnerSession {
|
|
|
24
234
|
transport.close();
|
|
25
235
|
});
|
|
26
236
|
this.transport.onMessage((msg) => this.handle(msg));
|
|
237
|
+
this.transport.onClose((error) => {
|
|
238
|
+
this.rejectMirrorAcks(new MirrorDeliveryError(error?.message ?? "runner transport closed before mirror acknowledgement", "ambiguous"));
|
|
239
|
+
this.rejectMirrorImageAcks(error ?? new Error("runner transport closed"));
|
|
240
|
+
this.rejectRotationAcks(error ?? new Error("runner transport closed"));
|
|
241
|
+
});
|
|
27
242
|
this.transport.send({ t: "ready" });
|
|
28
243
|
}
|
|
29
244
|
handle(msg) {
|
|
30
245
|
switch (msg.t) {
|
|
246
|
+
case "mirror.ack": {
|
|
247
|
+
const pending = this.mirrorAcks.get(msg.deliveryId);
|
|
248
|
+
if (!pending || msg.generation !== pending.generation) {
|
|
249
|
+
return;
|
|
250
|
+
}
|
|
251
|
+
clearTimeout(pending.timer);
|
|
252
|
+
this.mirrorAcks.delete(msg.deliveryId);
|
|
253
|
+
pending.resolve();
|
|
254
|
+
return;
|
|
255
|
+
}
|
|
256
|
+
case "mirror.nack": {
|
|
257
|
+
const pending = this.mirrorAcks.get(msg.deliveryId);
|
|
258
|
+
if (!pending || msg.generation !== pending.generation) {
|
|
259
|
+
return;
|
|
260
|
+
}
|
|
261
|
+
clearTimeout(pending.timer);
|
|
262
|
+
this.mirrorAcks.delete(msg.deliveryId);
|
|
263
|
+
pending.reject(new MirrorDeliveryError(msg.message, msg.classification, msg.deliveryId));
|
|
264
|
+
return;
|
|
265
|
+
}
|
|
266
|
+
case "mirror.image.ack": {
|
|
267
|
+
const pending = this.mirrorImageAcks.get(msg.transferId);
|
|
268
|
+
if (!pending || msg.generation !== pending.generation) {
|
|
269
|
+
return;
|
|
270
|
+
}
|
|
271
|
+
if (pending.seq !== msg.seq) {
|
|
272
|
+
pending.reject(new Error("generated image acknowledgement sequence changed"));
|
|
273
|
+
return;
|
|
274
|
+
}
|
|
275
|
+
if (pending.timer)
|
|
276
|
+
clearTimeout(pending.timer);
|
|
277
|
+
this.mirrorImageAcks.delete(msg.transferId);
|
|
278
|
+
pending.resolve();
|
|
279
|
+
return;
|
|
280
|
+
}
|
|
281
|
+
case "mirror.image.hold": {
|
|
282
|
+
const pending = this.mirrorImageAcks.get(msg.transferId);
|
|
283
|
+
if (!pending ||
|
|
284
|
+
pending.seq !== msg.seq ||
|
|
285
|
+
msg.generation !== pending.generation)
|
|
286
|
+
return;
|
|
287
|
+
if (pending.timer)
|
|
288
|
+
clearTimeout(pending.timer);
|
|
289
|
+
pending.timer = undefined;
|
|
290
|
+
return;
|
|
291
|
+
}
|
|
292
|
+
case "mirror.image.nack": {
|
|
293
|
+
const pending = this.mirrorImageAcks.get(msg.transferId);
|
|
294
|
+
if (!pending ||
|
|
295
|
+
pending.seq !== msg.seq ||
|
|
296
|
+
msg.generation !== pending.generation)
|
|
297
|
+
return;
|
|
298
|
+
if (pending.timer)
|
|
299
|
+
clearTimeout(pending.timer);
|
|
300
|
+
this.mirrorImageAcks.delete(msg.transferId);
|
|
301
|
+
pending.reject(new MirrorDeliveryError(msg.message, msg.classification));
|
|
302
|
+
return;
|
|
303
|
+
}
|
|
304
|
+
case "rotate.ack": {
|
|
305
|
+
const pending = this.rotationPublishAcks.get(msg.rotationId);
|
|
306
|
+
if (!pending || pending.generation !== msg.generation)
|
|
307
|
+
return;
|
|
308
|
+
clearTimeout(pending.timer);
|
|
309
|
+
this.rotationPublishAcks.delete(msg.rotationId);
|
|
310
|
+
pending.resolve();
|
|
311
|
+
return;
|
|
312
|
+
}
|
|
313
|
+
case "rotate.nack": {
|
|
314
|
+
const pending = this.rotationPublishAcks.get(msg.rotationId);
|
|
315
|
+
if (!pending || pending.generation !== msg.generation)
|
|
316
|
+
return;
|
|
317
|
+
clearTimeout(pending.timer);
|
|
318
|
+
this.rotationPublishAcks.delete(msg.rotationId);
|
|
319
|
+
pending.reject(new MirrorDeliveryError(msg.message, msg.classification));
|
|
320
|
+
return;
|
|
321
|
+
}
|
|
322
|
+
case "rotate.applied.ack": {
|
|
323
|
+
const pending = this.rotationAppliedAcks.get(msg.rotationId);
|
|
324
|
+
if (!pending || pending.generation !== msg.generation)
|
|
325
|
+
return;
|
|
326
|
+
clearTimeout(pending.timer);
|
|
327
|
+
this.rotationAppliedAcks.delete(msg.rotationId);
|
|
328
|
+
pending.resolve();
|
|
329
|
+
return;
|
|
330
|
+
}
|
|
31
331
|
case "cap":
|
|
32
332
|
void this.runCap(msg.capId, msg.name, msg.args);
|
|
33
333
|
return;
|
|
34
334
|
case "term.open":
|
|
35
|
-
if (this.pendingTerminalOpens.has(msg.attachId) ||
|
|
335
|
+
if (this.pendingTerminalOpens.has(msg.attachId) ||
|
|
336
|
+
this.preparations.has(msg.attachId) ||
|
|
337
|
+
this.attachments.has(msg.attachId)) {
|
|
36
338
|
this.transport.send({
|
|
37
339
|
t: "term.error",
|
|
38
340
|
attachId: msg.attachId,
|
|
@@ -47,20 +349,35 @@ export class RunnerSession {
|
|
|
47
349
|
this.cancelledTerminalOpens.delete(msg.attachId);
|
|
48
350
|
});
|
|
49
351
|
return;
|
|
352
|
+
case "term.seed.read":
|
|
353
|
+
void this.readTerminalSeed(msg);
|
|
354
|
+
return;
|
|
355
|
+
case "term.start":
|
|
356
|
+
void this.startTerminal(msg);
|
|
357
|
+
return;
|
|
358
|
+
case "term.read":
|
|
359
|
+
void this.readTerminalOutput(msg);
|
|
360
|
+
return;
|
|
50
361
|
case "term.input":
|
|
51
|
-
this.
|
|
52
|
-
this.attachments.get(msg.attachId)?.write(Buffer.from(msg.dataB64, "base64").toString("utf8"));
|
|
362
|
+
void this.writeTerminalInput(msg);
|
|
53
363
|
return;
|
|
54
364
|
case "term.resize":
|
|
55
|
-
this.
|
|
365
|
+
void this.resizeTerminal(msg);
|
|
56
366
|
return;
|
|
57
367
|
case "term.close": {
|
|
58
368
|
if (this.pendingTerminalOpens.has(msg.attachId)) {
|
|
59
369
|
this.cancelledTerminalOpens.add(msg.attachId);
|
|
60
370
|
}
|
|
371
|
+
const pending = this.preparations.get(msg.attachId);
|
|
372
|
+
if (pending) {
|
|
373
|
+
clearTimeout(pending.timer);
|
|
374
|
+
this.preparations.delete(msg.attachId);
|
|
375
|
+
pending.preparation.kill();
|
|
376
|
+
}
|
|
61
377
|
const attachment = this.attachments.get(msg.attachId);
|
|
62
378
|
this.attachments.delete(msg.attachId);
|
|
63
379
|
this.attachmentThreadIds.delete(msg.attachId);
|
|
380
|
+
this.attachmentRoles.delete(msg.attachId);
|
|
64
381
|
attachment?.kill();
|
|
65
382
|
return;
|
|
66
383
|
}
|
|
@@ -74,11 +391,17 @@ export class RunnerSession {
|
|
|
74
391
|
case "inject":
|
|
75
392
|
void this.inject(msg);
|
|
76
393
|
return;
|
|
394
|
+
case "collaboration.update":
|
|
395
|
+
void this.updateCollaborationMode(msg);
|
|
396
|
+
return;
|
|
77
397
|
case "live.interrupt":
|
|
78
398
|
void this.interruptLive(msg);
|
|
79
399
|
return;
|
|
400
|
+
case "terminal.reap":
|
|
401
|
+
void this.reapTerminal(msg);
|
|
402
|
+
return;
|
|
80
403
|
case "shutdown":
|
|
81
|
-
this.shutdown();
|
|
404
|
+
void this.shutdown();
|
|
82
405
|
return;
|
|
83
406
|
}
|
|
84
407
|
}
|
|
@@ -109,28 +432,571 @@ export class RunnerSession {
|
|
|
109
432
|
* new session AND tells the daemon to alias the runner (terminal transfer) so
|
|
110
433
|
* the new session stays injectable. */
|
|
111
434
|
mirrorChannel(localThreadId) {
|
|
112
|
-
const target = { id: localThreadId };
|
|
113
|
-
|
|
435
|
+
const target = { id: localThreadId, generation: 0 };
|
|
436
|
+
let pendingRetarget;
|
|
437
|
+
const emit = (event, delivery) => {
|
|
438
|
+
const sessionId = target.id;
|
|
439
|
+
const generation = target.generation;
|
|
440
|
+
if (event.type === "session.status") {
|
|
441
|
+
this.terminalStatuses.set(sessionId, event.status);
|
|
442
|
+
}
|
|
443
|
+
return this.enqueueMirror(delivery?.lane ?? "main", () => this.sendMirroredEvent(sessionId, event, delivery, generation, () => generation === target.generation));
|
|
444
|
+
};
|
|
114
445
|
const retarget = (newId, meta) => {
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
to
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
446
|
+
let publication = pendingRetarget;
|
|
447
|
+
if (!publication && target.id === newId)
|
|
448
|
+
return Promise.resolve();
|
|
449
|
+
if (publication && publication.to !== newId) {
|
|
450
|
+
return Promise.reject(new Error(`Session rotation to ${publication.to} is still pending; cannot retarget to ${newId}`));
|
|
451
|
+
}
|
|
452
|
+
if (!publication) {
|
|
453
|
+
const previousGeneration = target.generation;
|
|
454
|
+
publication = {
|
|
455
|
+
from: target.id,
|
|
456
|
+
to: newId,
|
|
457
|
+
generation: previousGeneration + 1,
|
|
458
|
+
rotationId: `rotate_${randomUUID()}`,
|
|
459
|
+
meta,
|
|
460
|
+
};
|
|
461
|
+
pendingRetarget = publication;
|
|
462
|
+
// Fence every source delivery immediately, but do not mutate the target
|
|
463
|
+
// identity or transfer resources until the parent ACKs publication.
|
|
464
|
+
target.generation = publication.generation;
|
|
465
|
+
this.cancelMirrorGeneration(previousGeneration);
|
|
466
|
+
}
|
|
467
|
+
if (publication.operation)
|
|
468
|
+
return publication.operation;
|
|
469
|
+
const current = publication;
|
|
470
|
+
const operation = this.enqueueMirror("control", async () => {
|
|
471
|
+
if (!current.resourcesApplied) {
|
|
472
|
+
let attempt = 0;
|
|
473
|
+
let publicationAcked = false;
|
|
474
|
+
while (!publicationAcked) {
|
|
475
|
+
attempt += 1;
|
|
476
|
+
try {
|
|
477
|
+
await this.sendRotationFrame(current.rotationId, current.generation, {
|
|
478
|
+
t: "rotate",
|
|
479
|
+
rotationId: current.rotationId,
|
|
480
|
+
generation: current.generation,
|
|
481
|
+
from: current.from,
|
|
482
|
+
to: current.to,
|
|
483
|
+
kind: current.meta.kind,
|
|
484
|
+
workspace: current.meta.workspace,
|
|
485
|
+
execution: current.meta.execution,
|
|
486
|
+
...(current.meta.parentSessionId
|
|
487
|
+
? { parentSessionId: current.meta.parentSessionId }
|
|
488
|
+
: {}),
|
|
489
|
+
});
|
|
490
|
+
publicationAcked = true;
|
|
491
|
+
}
|
|
492
|
+
catch (error) {
|
|
493
|
+
const failure = error instanceof MirrorDeliveryError
|
|
494
|
+
? error
|
|
495
|
+
: new MirrorDeliveryError(error instanceof Error ? error.message : String(error), "transient");
|
|
496
|
+
if (failure.classification === "permanent" && attempt >= 3) {
|
|
497
|
+
// The parent still owns source/target reservations for this
|
|
498
|
+
// half-published cutover. End the child so failHandle releases
|
|
499
|
+
// them; leaving a live fenced pane would deadlock both ids.
|
|
500
|
+
await this.shutdown();
|
|
501
|
+
throw failure;
|
|
502
|
+
}
|
|
503
|
+
await this.waitForMirrorRetry(attempt, current.generation);
|
|
504
|
+
}
|
|
505
|
+
}
|
|
506
|
+
if (pendingRetarget !== current)
|
|
507
|
+
return;
|
|
508
|
+
this.transferNativeSessionResources(current.from, current.to);
|
|
509
|
+
target.id = current.to;
|
|
510
|
+
current.resourcesApplied = true;
|
|
511
|
+
}
|
|
512
|
+
if (pendingRetarget !== current)
|
|
513
|
+
return;
|
|
514
|
+
if (!current.hostApplied) {
|
|
515
|
+
await current.meta.beforeApplied?.();
|
|
516
|
+
if (pendingRetarget !== current)
|
|
517
|
+
return;
|
|
518
|
+
current.hostApplied = true;
|
|
519
|
+
}
|
|
520
|
+
// Ordered after the actual child-side ownership transfer. The parent
|
|
521
|
+
// keeps source and target admissions reserved until it observes this.
|
|
522
|
+
await this.sendRotationAppliedFrame(current.rotationId, current.generation, {
|
|
523
|
+
t: "rotate.applied",
|
|
524
|
+
rotationId: current.rotationId,
|
|
525
|
+
generation: current.generation,
|
|
526
|
+
from: current.from,
|
|
527
|
+
to: current.to,
|
|
528
|
+
});
|
|
529
|
+
if (pendingRetarget !== current)
|
|
530
|
+
return;
|
|
531
|
+
pendingRetarget = undefined;
|
|
532
|
+
}, { recoverable: true }).finally(() => {
|
|
533
|
+
if (pendingRetarget === current)
|
|
534
|
+
delete current.operation;
|
|
124
535
|
});
|
|
536
|
+
current.operation = operation;
|
|
537
|
+
return operation;
|
|
125
538
|
};
|
|
126
539
|
return { emit, retarget };
|
|
127
540
|
}
|
|
541
|
+
transferNativeSessionResources(sourceId, targetId) {
|
|
542
|
+
if (sourceId === targetId)
|
|
543
|
+
return;
|
|
544
|
+
const sourceTerminalId = `${sourceId}-main`;
|
|
545
|
+
const targetTerminalId = `${targetId}-main`;
|
|
546
|
+
this.terminals.transfer(sourceTerminalId, targetTerminalId);
|
|
547
|
+
const watcher = this.terminalWatchers.get(sourceId);
|
|
548
|
+
if (watcher)
|
|
549
|
+
clearInterval(watcher);
|
|
550
|
+
this.terminalWatchers.delete(sourceId);
|
|
551
|
+
const runtime = this.liveRuntimes.get(sourceId);
|
|
552
|
+
if (runtime) {
|
|
553
|
+
this.liveRuntimes.delete(sourceId);
|
|
554
|
+
this.liveRuntimes.set(targetId, runtime);
|
|
555
|
+
}
|
|
556
|
+
const status = this.terminalStatuses.get(sourceId);
|
|
557
|
+
if (status) {
|
|
558
|
+
this.terminalStatuses.delete(sourceId);
|
|
559
|
+
this.terminalStatuses.set(targetId, status);
|
|
560
|
+
}
|
|
561
|
+
if (this.liveIds.delete(sourceId))
|
|
562
|
+
this.liveIds.add(targetId);
|
|
563
|
+
for (const [attachId, sessionId] of this.attachmentThreadIds) {
|
|
564
|
+
if (sessionId === sourceId)
|
|
565
|
+
this.attachmentThreadIds.set(attachId, targetId);
|
|
566
|
+
}
|
|
567
|
+
const terminal = this.terminals.get(targetTerminalId);
|
|
568
|
+
if (terminal)
|
|
569
|
+
this.watchNativeTerminal(targetId, targetTerminalId, terminal);
|
|
570
|
+
}
|
|
571
|
+
enqueueMirror(lane, operation, options = {}) {
|
|
572
|
+
const previous = this.mirrorQueues.get(lane) ?? Promise.resolve();
|
|
573
|
+
const queued = previous.then(operation);
|
|
574
|
+
const tail = queued.catch((error) => {
|
|
575
|
+
if (!this.shuttingDown && !options.recoverable) {
|
|
576
|
+
this.rejectMirrorImageAcks(error instanceof Error ? error : new Error(String(error)));
|
|
577
|
+
void this.shutdown();
|
|
578
|
+
}
|
|
579
|
+
});
|
|
580
|
+
this.mirrorQueues.set(lane, tail);
|
|
581
|
+
void tail.finally(() => {
|
|
582
|
+
if (this.mirrorQueues.get(lane) === tail)
|
|
583
|
+
this.mirrorQueues.delete(lane);
|
|
584
|
+
});
|
|
585
|
+
return queued;
|
|
586
|
+
}
|
|
587
|
+
async sendMirroredEvent(sessionId, event, delivery, generation = 0, isCurrent = () => true) {
|
|
588
|
+
if (!isCurrent())
|
|
589
|
+
return;
|
|
590
|
+
try {
|
|
591
|
+
const detached = detachGeneratedImageResult(event);
|
|
592
|
+
if (!detached) {
|
|
593
|
+
await this.deliverMirrorEvent(sessionId, event, delivery, generation, isCurrent);
|
|
594
|
+
return;
|
|
595
|
+
}
|
|
596
|
+
if (detached.result.length > RUNNER_IMAGE_MAX_RESULT_CHARS) {
|
|
597
|
+
await this.deliverMirrorEvent(sessionId, generatedImagePreviewUnavailable(detached.event), delivery, generation, isCurrent);
|
|
598
|
+
return;
|
|
599
|
+
}
|
|
600
|
+
await this.deliverMirrorImageEvent(sessionId, detached.event, detached.result, delivery, generation, isCurrent);
|
|
601
|
+
}
|
|
602
|
+
catch (error) {
|
|
603
|
+
if (error instanceof MirrorGenerationSupersededError)
|
|
604
|
+
return;
|
|
605
|
+
throw error;
|
|
606
|
+
}
|
|
607
|
+
}
|
|
608
|
+
async deliverMirrorImageEvent(sessionId, event, result, delivery, generation, isCurrent) {
|
|
609
|
+
const transferId = `img_${randomUUID()}`;
|
|
610
|
+
const abandon = () => this.transport.sendAndDrain({
|
|
611
|
+
t: "mirror.image.abandon",
|
|
612
|
+
transferId,
|
|
613
|
+
generation,
|
|
614
|
+
}).catch(() => {
|
|
615
|
+
// Parent/child teardown already releases process-local transfer state.
|
|
616
|
+
});
|
|
617
|
+
let seq = 0;
|
|
618
|
+
try {
|
|
619
|
+
await this.deliverMirrorImageTransportFrame(transferId, seq, generation, {
|
|
620
|
+
t: "mirror.image.begin",
|
|
621
|
+
transferId,
|
|
622
|
+
sessionId,
|
|
623
|
+
event,
|
|
624
|
+
totalChars: result.length,
|
|
625
|
+
generation,
|
|
626
|
+
}, isCurrent);
|
|
627
|
+
for (let offset = 0; offset < result.length; offset += RUNNER_IMAGE_CHUNK_CHARS) {
|
|
628
|
+
if (!isCurrent())
|
|
629
|
+
throw new MirrorGenerationSupersededError(generation);
|
|
630
|
+
seq += 1;
|
|
631
|
+
await this.deliverMirrorImageTransportFrame(transferId, seq, generation, {
|
|
632
|
+
t: "mirror.image.chunk",
|
|
633
|
+
transferId,
|
|
634
|
+
seq,
|
|
635
|
+
data: result.slice(offset, offset + RUNNER_IMAGE_CHUNK_CHARS),
|
|
636
|
+
generation,
|
|
637
|
+
}, isCurrent);
|
|
638
|
+
}
|
|
639
|
+
}
|
|
640
|
+
catch (error) {
|
|
641
|
+
await abandon();
|
|
642
|
+
throw error;
|
|
643
|
+
}
|
|
644
|
+
seq += 1;
|
|
645
|
+
const policy = delivery?.policy ?? defaultMirrorDeliveryPolicy(event);
|
|
646
|
+
let attempt = 0;
|
|
647
|
+
while (!this.shuttingDown) {
|
|
648
|
+
if (!isCurrent()) {
|
|
649
|
+
delivery?.onOutcome?.("superseded");
|
|
650
|
+
await abandon();
|
|
651
|
+
return;
|
|
652
|
+
}
|
|
653
|
+
attempt += 1;
|
|
654
|
+
try {
|
|
655
|
+
await this.sendMirrorImageFrame(transferId, seq, generation, {
|
|
656
|
+
t: "mirror.image.commit",
|
|
657
|
+
transferId,
|
|
658
|
+
seq,
|
|
659
|
+
generation,
|
|
660
|
+
}, "ambiguous");
|
|
661
|
+
this.noteMirrorDeliveryRecovered();
|
|
662
|
+
delivery?.onOutcome?.("confirmed");
|
|
663
|
+
return;
|
|
664
|
+
}
|
|
665
|
+
catch (error) {
|
|
666
|
+
if (error instanceof MirrorGenerationSupersededError || !isCurrent()) {
|
|
667
|
+
delivery?.onOutcome?.("superseded");
|
|
668
|
+
await abandon();
|
|
669
|
+
return;
|
|
670
|
+
}
|
|
671
|
+
if (this.shuttingDown)
|
|
672
|
+
break;
|
|
673
|
+
const failure = error instanceof MirrorDeliveryError
|
|
674
|
+
? error
|
|
675
|
+
: new MirrorDeliveryError(error instanceof Error ? error.message : String(error), "ambiguous");
|
|
676
|
+
this.noteMirrorDeliveryFailed(failure);
|
|
677
|
+
if (policy === "best-effort") {
|
|
678
|
+
delivery?.onOutcome?.("dropped");
|
|
679
|
+
await abandon();
|
|
680
|
+
return;
|
|
681
|
+
}
|
|
682
|
+
if (failure.classification === "ambiguous" &&
|
|
683
|
+
(policy === "ordinary" || policy === "compaction" || policy === "compaction-hook")) {
|
|
684
|
+
this.noteMirrorDeliveryRecovered();
|
|
685
|
+
delivery?.onOutcome?.("ambiguous");
|
|
686
|
+
await abandon();
|
|
687
|
+
return;
|
|
688
|
+
}
|
|
689
|
+
if (failure.classification === "permanent" &&
|
|
690
|
+
policy !== "compaction" &&
|
|
691
|
+
attempt >= 3) {
|
|
692
|
+
if (policy === "ordinary" && delivery?.deadLetterPath) {
|
|
693
|
+
try {
|
|
694
|
+
appendMirrorDeadLetter(delivery.deadLetterPath, {
|
|
695
|
+
ts: Date.now() / 1_000,
|
|
696
|
+
session_id: sessionId,
|
|
697
|
+
event_type: "session_event",
|
|
698
|
+
reason: "permanent IPC persistence failure after retries",
|
|
699
|
+
delivered_ambiguous: false,
|
|
700
|
+
http_status: null,
|
|
701
|
+
transport_error: null,
|
|
702
|
+
payload: {
|
|
703
|
+
source_id: delivery.sourceId,
|
|
704
|
+
attempts: attempt,
|
|
705
|
+
event,
|
|
706
|
+
generated_image_result: result,
|
|
707
|
+
},
|
|
708
|
+
});
|
|
709
|
+
}
|
|
710
|
+
catch (deadLetterError) {
|
|
711
|
+
// The dead-letter writer is observability-only and never
|
|
712
|
+
// turns an exhausted poison item into a runner-wide failure.
|
|
713
|
+
console.error("[mirror] failed to append dead letter:", deadLetterError);
|
|
714
|
+
}
|
|
715
|
+
}
|
|
716
|
+
delivery?.onOutcome?.("dropped");
|
|
717
|
+
await abandon();
|
|
718
|
+
return;
|
|
719
|
+
}
|
|
720
|
+
await this.waitForMirrorRetry(attempt, generation);
|
|
721
|
+
}
|
|
722
|
+
}
|
|
723
|
+
await abandon();
|
|
724
|
+
throw new MirrorDeliveryError("runner shut down before generated image delivery was handled", "transient");
|
|
725
|
+
}
|
|
726
|
+
async deliverMirrorImageTransportFrame(transferId, seq, generation, frame, isCurrent) {
|
|
727
|
+
let attempt = 0;
|
|
728
|
+
while (!this.shuttingDown) {
|
|
729
|
+
if (!isCurrent())
|
|
730
|
+
throw new MirrorGenerationSupersededError(generation);
|
|
731
|
+
attempt += 1;
|
|
732
|
+
try {
|
|
733
|
+
await this.sendMirrorImageFrame(transferId, seq, generation, frame, "transient");
|
|
734
|
+
return;
|
|
735
|
+
}
|
|
736
|
+
catch (error) {
|
|
737
|
+
if (error instanceof MirrorGenerationSupersededError || !isCurrent())
|
|
738
|
+
throw error;
|
|
739
|
+
const failure = error instanceof MirrorDeliveryError
|
|
740
|
+
? error
|
|
741
|
+
: new MirrorDeliveryError(error instanceof Error ? error.message : String(error), "transient");
|
|
742
|
+
this.noteMirrorDeliveryFailed(failure);
|
|
743
|
+
await this.waitForMirrorRetry(attempt, generation);
|
|
744
|
+
}
|
|
745
|
+
}
|
|
746
|
+
throw new MirrorDeliveryError("runner shut down during generated image transfer", "transient");
|
|
747
|
+
}
|
|
748
|
+
async deliverMirrorEvent(sessionId, event, delivery, generation = 0, isCurrent = () => true) {
|
|
749
|
+
const policy = delivery?.policy ?? defaultMirrorDeliveryPolicy(event);
|
|
750
|
+
let attempt = 0;
|
|
751
|
+
let lastFailedDeliveryId;
|
|
752
|
+
while (!this.shuttingDown) {
|
|
753
|
+
if (!isCurrent()) {
|
|
754
|
+
delivery?.onOutcome?.("superseded");
|
|
755
|
+
await this.abandonMirrorDelivery(lastFailedDeliveryId, generation);
|
|
756
|
+
return;
|
|
757
|
+
}
|
|
758
|
+
attempt += 1;
|
|
759
|
+
try {
|
|
760
|
+
await this.sendMirrorEventFrame(sessionId, event, generation, attempt);
|
|
761
|
+
this.noteMirrorDeliveryRecovered();
|
|
762
|
+
delivery?.onOutcome?.("confirmed");
|
|
763
|
+
return;
|
|
764
|
+
}
|
|
765
|
+
catch (error) {
|
|
766
|
+
if (error instanceof MirrorGenerationSupersededError || !isCurrent()) {
|
|
767
|
+
delivery?.onOutcome?.("superseded");
|
|
768
|
+
await this.abandonMirrorDelivery(error instanceof MirrorGenerationSupersededError
|
|
769
|
+
? error.deliveryId
|
|
770
|
+
: error instanceof MirrorDeliveryError
|
|
771
|
+
? error.deliveryId
|
|
772
|
+
: lastFailedDeliveryId, generation);
|
|
773
|
+
return;
|
|
774
|
+
}
|
|
775
|
+
if (this.shuttingDown)
|
|
776
|
+
break;
|
|
777
|
+
const failure = error instanceof MirrorDeliveryError
|
|
778
|
+
? error
|
|
779
|
+
: new MirrorDeliveryError(error instanceof Error ? error.message : String(error), "ambiguous");
|
|
780
|
+
lastFailedDeliveryId = failure.deliveryId;
|
|
781
|
+
this.noteMirrorDeliveryFailed(failure);
|
|
782
|
+
if (policy === "best-effort") {
|
|
783
|
+
console.warn(`[mirror] best-effort event dropped: ${failure.message}`);
|
|
784
|
+
delivery?.onOutcome?.("dropped");
|
|
785
|
+
return;
|
|
786
|
+
}
|
|
787
|
+
if (failure.classification === "ambiguous" &&
|
|
788
|
+
(policy === "ordinary" || policy === "compaction" || policy === "compaction-hook")) {
|
|
789
|
+
console.warn(`[mirror] ordinary delivery result is ambiguous; treating as handled: ${failure.message}`);
|
|
790
|
+
this.noteMirrorDeliveryRecovered();
|
|
791
|
+
delivery?.onOutcome?.("ambiguous");
|
|
792
|
+
await this.abandonMirrorDelivery(failure.deliveryId, generation);
|
|
793
|
+
return;
|
|
794
|
+
}
|
|
795
|
+
if (failure.classification === "permanent" &&
|
|
796
|
+
policy !== "compaction" &&
|
|
797
|
+
attempt >= 3) {
|
|
798
|
+
if (policy === "ordinary" && delivery?.deadLetterPath) {
|
|
799
|
+
try {
|
|
800
|
+
appendMirrorDeadLetter(delivery.deadLetterPath, {
|
|
801
|
+
ts: Date.now() / 1_000,
|
|
802
|
+
session_id: sessionId,
|
|
803
|
+
event_type: "session_event",
|
|
804
|
+
reason: "permanent IPC persistence failure after retries",
|
|
805
|
+
delivered_ambiguous: false,
|
|
806
|
+
http_status: null,
|
|
807
|
+
transport_error: null,
|
|
808
|
+
payload: {
|
|
809
|
+
source_id: delivery.sourceId,
|
|
810
|
+
attempts: attempt,
|
|
811
|
+
event,
|
|
812
|
+
},
|
|
813
|
+
});
|
|
814
|
+
}
|
|
815
|
+
catch (deadLetterError) {
|
|
816
|
+
console.error("[mirror] failed to append dead letter:", deadLetterError);
|
|
817
|
+
}
|
|
818
|
+
}
|
|
819
|
+
console.error(`[mirror] permanent delivery failed after ${attempt} attempts; skipping: ${failure.message}`);
|
|
820
|
+
delivery?.onOutcome?.("dropped");
|
|
821
|
+
await this.abandonMirrorDelivery(failure.deliveryId, generation);
|
|
822
|
+
return;
|
|
823
|
+
}
|
|
824
|
+
await this.waitForMirrorRetry(attempt, generation);
|
|
825
|
+
}
|
|
826
|
+
}
|
|
827
|
+
throw new MirrorDeliveryError("runner shut down before mirror delivery was handled", "transient");
|
|
828
|
+
}
|
|
829
|
+
noteMirrorDeliveryFailed(error) {
|
|
830
|
+
this.consecutiveMirrorFailures += 1;
|
|
831
|
+
if (!this.mirrorDegraded && this.consecutiveMirrorFailures >= 5) {
|
|
832
|
+
this.mirrorDegraded = true;
|
|
833
|
+
console.warn(`[mirror] delivery degraded after ${this.consecutiveMirrorFailures} consecutive failures: ${error.message}`);
|
|
834
|
+
}
|
|
835
|
+
}
|
|
836
|
+
noteMirrorDeliveryRecovered() {
|
|
837
|
+
if (this.mirrorDegraded)
|
|
838
|
+
console.info("[mirror] delivery recovered");
|
|
839
|
+
this.consecutiveMirrorFailures = 0;
|
|
840
|
+
this.mirrorDegraded = false;
|
|
841
|
+
}
|
|
842
|
+
sendMirrorEventFrame(sessionId, event, generation, attempt) {
|
|
843
|
+
const deliveryId = `mirror_${randomUUID()}`;
|
|
844
|
+
return new Promise((resolve, reject) => {
|
|
845
|
+
const timer = setTimeout(() => {
|
|
846
|
+
this.mirrorAcks.delete(deliveryId);
|
|
847
|
+
reject(new MirrorDeliveryError("mirror acknowledgement timed out", "ambiguous", deliveryId));
|
|
848
|
+
}, MIRROR_ACK_TIMEOUT_MS);
|
|
849
|
+
timer.unref?.();
|
|
850
|
+
this.mirrorAcks.set(deliveryId, { generation, resolve, reject, timer });
|
|
851
|
+
void this.transport.sendAndDrain({
|
|
852
|
+
t: "mirror",
|
|
853
|
+
deliveryId,
|
|
854
|
+
generation,
|
|
855
|
+
attempt,
|
|
856
|
+
sessionId,
|
|
857
|
+
event,
|
|
858
|
+
}).catch((error) => {
|
|
859
|
+
const pending = this.mirrorAcks.get(deliveryId);
|
|
860
|
+
if (!pending)
|
|
861
|
+
return;
|
|
862
|
+
clearTimeout(pending.timer);
|
|
863
|
+
this.mirrorAcks.delete(deliveryId);
|
|
864
|
+
const classification = error instanceof RunnerTransportWriteError &&
|
|
865
|
+
error.delivery === "not-sent"
|
|
866
|
+
? "transient"
|
|
867
|
+
: "ambiguous";
|
|
868
|
+
pending.reject(new MirrorDeliveryError(error instanceof Error ? error.message : String(error), classification, deliveryId));
|
|
869
|
+
});
|
|
870
|
+
});
|
|
871
|
+
}
|
|
872
|
+
async abandonMirrorDelivery(deliveryId, generation) {
|
|
873
|
+
if (!deliveryId)
|
|
874
|
+
return;
|
|
875
|
+
await this.transport.sendAndDrain({
|
|
876
|
+
t: "mirror.abandon",
|
|
877
|
+
deliveryId,
|
|
878
|
+
generation,
|
|
879
|
+
}).catch(() => {
|
|
880
|
+
// Parent/child teardown already releases process-local projection state.
|
|
881
|
+
});
|
|
882
|
+
}
|
|
883
|
+
waitForMirrorRetry(failedAttempt, generation) {
|
|
884
|
+
return new Promise((resolve) => {
|
|
885
|
+
const waiters = this.mirrorRetryWaiters.get(generation) ?? new Set();
|
|
886
|
+
let timer;
|
|
887
|
+
const done = () => {
|
|
888
|
+
clearTimeout(timer);
|
|
889
|
+
waiters.delete(done);
|
|
890
|
+
if (waiters.size === 0)
|
|
891
|
+
this.mirrorRetryWaiters.delete(generation);
|
|
892
|
+
resolve();
|
|
893
|
+
};
|
|
894
|
+
timer = setTimeout(done, mirrorRetryDelayMs(failedAttempt));
|
|
895
|
+
timer.unref?.();
|
|
896
|
+
waiters.add(done);
|
|
897
|
+
this.mirrorRetryWaiters.set(generation, waiters);
|
|
898
|
+
});
|
|
899
|
+
}
|
|
900
|
+
cancelMirrorGeneration(generation) {
|
|
901
|
+
const error = new MirrorGenerationSupersededError(generation);
|
|
902
|
+
for (const [deliveryId, pending] of this.mirrorAcks) {
|
|
903
|
+
if (pending.generation !== generation)
|
|
904
|
+
continue;
|
|
905
|
+
clearTimeout(pending.timer);
|
|
906
|
+
this.mirrorAcks.delete(deliveryId);
|
|
907
|
+
pending.reject(new MirrorGenerationSupersededError(generation, deliveryId));
|
|
908
|
+
}
|
|
909
|
+
for (const [transferId, pending] of this.mirrorImageAcks) {
|
|
910
|
+
if (pending.generation !== generation)
|
|
911
|
+
continue;
|
|
912
|
+
if (pending.timer)
|
|
913
|
+
clearTimeout(pending.timer);
|
|
914
|
+
this.mirrorImageAcks.delete(transferId);
|
|
915
|
+
pending.reject(error);
|
|
916
|
+
}
|
|
917
|
+
for (const wake of this.mirrorRetryWaiters.get(generation) ?? [])
|
|
918
|
+
wake();
|
|
919
|
+
this.mirrorRetryWaiters.delete(generation);
|
|
920
|
+
}
|
|
921
|
+
rejectMirrorAcks(error) {
|
|
922
|
+
for (const pending of this.mirrorAcks.values()) {
|
|
923
|
+
clearTimeout(pending.timer);
|
|
924
|
+
pending.reject(error);
|
|
925
|
+
}
|
|
926
|
+
this.mirrorAcks.clear();
|
|
927
|
+
}
|
|
928
|
+
sendMirrorImageFrame(transferId, seq, generation, frame, timeoutClassification = "transient") {
|
|
929
|
+
return new Promise((resolve, reject) => {
|
|
930
|
+
const pending = { generation, seq, resolve, reject };
|
|
931
|
+
const timer = setTimeout(() => {
|
|
932
|
+
if (this.mirrorImageAcks.get(transferId) !== pending)
|
|
933
|
+
return;
|
|
934
|
+
this.mirrorImageAcks.delete(transferId);
|
|
935
|
+
reject(new MirrorDeliveryError("generated image transfer acknowledgement timed out", timeoutClassification));
|
|
936
|
+
}, MIRROR_IMAGE_ACK_TIMEOUT_MS);
|
|
937
|
+
timer.unref?.();
|
|
938
|
+
pending.timer = timer;
|
|
939
|
+
this.mirrorImageAcks.set(transferId, pending);
|
|
940
|
+
void this.transport.sendAndDrain(frame).catch((error) => {
|
|
941
|
+
if (this.mirrorImageAcks.get(transferId) !== pending)
|
|
942
|
+
return;
|
|
943
|
+
clearTimeout(timer);
|
|
944
|
+
this.mirrorImageAcks.delete(transferId);
|
|
945
|
+
const classification = timeoutClassification === "ambiguous" &&
|
|
946
|
+
(!(error instanceof RunnerTransportWriteError) || error.delivery === "ambiguous")
|
|
947
|
+
? "ambiguous"
|
|
948
|
+
: "transient";
|
|
949
|
+
reject(new MirrorDeliveryError(error instanceof Error ? error.message : String(error), classification));
|
|
950
|
+
});
|
|
951
|
+
});
|
|
952
|
+
}
|
|
953
|
+
rejectMirrorImageAcks(error) {
|
|
954
|
+
for (const pending of this.mirrorImageAcks.values()) {
|
|
955
|
+
if (pending.timer)
|
|
956
|
+
clearTimeout(pending.timer);
|
|
957
|
+
pending.reject(error);
|
|
958
|
+
}
|
|
959
|
+
this.mirrorImageAcks.clear();
|
|
960
|
+
}
|
|
961
|
+
sendRotationFrame(rotationId, generation, frame) {
|
|
962
|
+
return new Promise((resolve, reject) => {
|
|
963
|
+
const timer = setTimeout(() => {
|
|
964
|
+
this.rotationPublishAcks.delete(rotationId);
|
|
965
|
+
reject(new Error("Session rotation acknowledgement timed out"));
|
|
966
|
+
}, MIRROR_ACK_TIMEOUT_MS);
|
|
967
|
+
timer.unref?.();
|
|
968
|
+
this.rotationPublishAcks.set(rotationId, { generation, resolve, reject, timer });
|
|
969
|
+
this.transport.send(frame);
|
|
970
|
+
});
|
|
971
|
+
}
|
|
972
|
+
sendRotationAppliedFrame(rotationId, generation, frame) {
|
|
973
|
+
return new Promise((resolve, reject) => {
|
|
974
|
+
const timer = setTimeout(() => {
|
|
975
|
+
this.rotationAppliedAcks.delete(rotationId);
|
|
976
|
+
reject(new Error("Session rotation applied acknowledgement timed out"));
|
|
977
|
+
}, MIRROR_ACK_TIMEOUT_MS);
|
|
978
|
+
timer.unref?.();
|
|
979
|
+
this.rotationAppliedAcks.set(rotationId, { generation, resolve, reject, timer });
|
|
980
|
+
this.transport.send(frame);
|
|
981
|
+
});
|
|
982
|
+
}
|
|
983
|
+
rejectRotationAcks(error) {
|
|
984
|
+
for (const pending of this.rotationPublishAcks.values()) {
|
|
985
|
+
clearTimeout(pending.timer);
|
|
986
|
+
pending.reject(error);
|
|
987
|
+
}
|
|
988
|
+
this.rotationPublishAcks.clear();
|
|
989
|
+
for (const pending of this.rotationAppliedAcks.values()) {
|
|
990
|
+
clearTimeout(pending.timer);
|
|
991
|
+
pending.reject(error);
|
|
992
|
+
}
|
|
993
|
+
this.rotationAppliedAcks.clear();
|
|
994
|
+
}
|
|
128
995
|
/**
|
|
129
|
-
* Eagerly bring up a session's codex-native live view
|
|
130
|
-
*
|
|
131
|
-
*
|
|
132
|
-
*
|
|
133
|
-
* so the TUI is usable immediately and its turns mirror to chat.
|
|
996
|
+
* Eagerly bring up a session's codex-native live view. Fresh sessions connect
|
|
997
|
+
* the discovery listener before launching the detached TUI; known-thread
|
|
998
|
+
* resumes preload first, launch the replacement TUI, then start their
|
|
999
|
+
* persistent observer in the background.
|
|
134
1000
|
*/
|
|
135
1001
|
async ensureLive(msg) {
|
|
136
1002
|
const provider = this.liveProvider;
|
|
@@ -142,43 +1008,150 @@ export class RunnerSession {
|
|
|
142
1008
|
retargetMirror: retarget,
|
|
143
1009
|
});
|
|
144
1010
|
if (!started) {
|
|
1011
|
+
const runtime = msg.execution?.provider;
|
|
145
1012
|
this.transport.send({
|
|
146
1013
|
t: "live.ready",
|
|
147
1014
|
reqId: msg.reqId,
|
|
148
1015
|
localThreadId: msg.localThreadId,
|
|
149
1016
|
ok: false,
|
|
150
|
-
error:
|
|
1017
|
+
error: providerFailure(provider, msg.localThreadId, {
|
|
1018
|
+
runtime,
|
|
1019
|
+
code: "native_start_failed",
|
|
1020
|
+
message: `${providerDisplayName(runtime)} native session did not start`,
|
|
1021
|
+
}),
|
|
151
1022
|
});
|
|
152
1023
|
return;
|
|
153
1024
|
}
|
|
154
1025
|
this.liveIds.add(msg.localThreadId);
|
|
1026
|
+
const runtime = msg.execution?.provider;
|
|
1027
|
+
if (runtime)
|
|
1028
|
+
this.liveRuntimes.set(msg.localThreadId, runtime);
|
|
155
1029
|
// Launch the TUI attached to the already-bound thread. Re-launch when the
|
|
156
1030
|
// pane is absent OR its process has died (`isAlive` probes `#{pane_dead}`) — so a
|
|
157
1031
|
// reconnect after the TUI exited restarts it instead of skipping (a
|
|
158
1032
|
// launched-once guard would leave a dead "Pane is dead" husk forever).
|
|
159
1033
|
if (!this.terminals.get(`${msg.localThreadId}-main`)?.isAlive()) {
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
1034
|
+
// Codex/Traex `ensureLiveCodexSession` already performs the backend
|
|
1035
|
+
// preload for an existing thread. Match the native lifecycle: launch
|
|
1036
|
+
// the TUI immediately after that preload and let the observer subscribe
|
|
1037
|
+
// in the background. Fresh sessions likewise launch before discovery.
|
|
1038
|
+
const terminalReady = isCodexLineageProvider(runtime)
|
|
1039
|
+
? true
|
|
1040
|
+
: provider.waitTerminalReady
|
|
1041
|
+
? await provider.waitTerminalReady(msg.localThreadId)
|
|
1042
|
+
: true;
|
|
163
1043
|
if (!terminalReady) {
|
|
1044
|
+
const providerName = runtime === "traex" ? "Traex" : runtime === "codex" ? "Codex" : "Provider";
|
|
164
1045
|
this.transport.send({
|
|
165
1046
|
t: "live.ready",
|
|
166
1047
|
reqId: msg.reqId,
|
|
167
1048
|
localThreadId: msg.localThreadId,
|
|
168
1049
|
ok: false,
|
|
169
|
-
error:
|
|
1050
|
+
error: providerFailure(provider, msg.localThreadId, {
|
|
1051
|
+
runtime,
|
|
1052
|
+
code: "native_resume_failed",
|
|
1053
|
+
message: runtime === "codex" || runtime === "traex"
|
|
1054
|
+
? `${providerName} app-server was ready, but its existing session could not be resumed for the Terminal`
|
|
1055
|
+
: "Provider thread was not ready for Terminal resume",
|
|
1056
|
+
}),
|
|
170
1057
|
});
|
|
171
1058
|
return;
|
|
172
1059
|
}
|
|
173
|
-
|
|
1060
|
+
try {
|
|
1061
|
+
await this.launchCodexPane(msg.localThreadId, msg.cols, msg.rows);
|
|
1062
|
+
}
|
|
1063
|
+
catch (error) {
|
|
1064
|
+
throw error instanceof AgentRuntimeError
|
|
1065
|
+
? error
|
|
1066
|
+
: nativePhaseError(runtime, "native_terminal_launch_failed", "Terminal failed to launch", error);
|
|
1067
|
+
}
|
|
1068
|
+
}
|
|
1069
|
+
// reference implementation's known-thread path launches `codex --remote resume` first,
|
|
1070
|
+
// then creates `_codex_forward_known_thread` as background work. Keep the
|
|
1071
|
+
// ordering explicit here: observer connection failure cannot delay or
|
|
1072
|
+
// reject cold-resume admission.
|
|
1073
|
+
if (isCodexLineageProvider(runtime)) {
|
|
1074
|
+
provider.startLiveCodexObserver?.(msg.localThreadId);
|
|
1075
|
+
}
|
|
1076
|
+
let ready = true;
|
|
1077
|
+
if (runtime === "codex" || runtime === "traex") {
|
|
1078
|
+
// Discover thread/started in the background while injection waits for
|
|
1079
|
+
// either bridge readiness or the runtime-specific startup deadline.
|
|
1080
|
+
// Do not serialize the two waits here.
|
|
1081
|
+
this.monitorCodexThreadStartup(msg.localThreadId, runtime, provider);
|
|
1082
|
+
}
|
|
1083
|
+
else if (msg.waitForReady !== false && provider.waitLiveReady) {
|
|
1084
|
+
ready = await provider.waitLiveReady(msg.localThreadId);
|
|
1085
|
+
}
|
|
1086
|
+
// Message delivery is serialized behind Traex's startup modal handling.
|
|
1087
|
+
// Otherwise app-server can accept a Turn while the TUI watcher is still
|
|
1088
|
+
// sending Escape, and the same key becomes `turn/interrupt` as soon as
|
|
1089
|
+
// the modal disappears. Setup-terminal opens remain non-blocking.
|
|
1090
|
+
if (runtime === "traex" && msg.waitForReady !== false) {
|
|
1091
|
+
await this.traexStartupGates.get(msg.localThreadId);
|
|
1092
|
+
}
|
|
1093
|
+
const readinessError = ready
|
|
1094
|
+
? undefined
|
|
1095
|
+
: providerFailure(provider, msg.localThreadId, {
|
|
1096
|
+
runtime,
|
|
1097
|
+
code: "native_readiness_failed",
|
|
1098
|
+
message: `${providerDisplayName(runtime)} native session was not ready`,
|
|
1099
|
+
});
|
|
1100
|
+
this.transport.send({
|
|
1101
|
+
t: "live.ready",
|
|
1102
|
+
reqId: msg.reqId,
|
|
1103
|
+
localThreadId: msg.localThreadId,
|
|
1104
|
+
ok: ready,
|
|
1105
|
+
...(ready ? {} : { error: readinessError }),
|
|
1106
|
+
});
|
|
1107
|
+
}
|
|
1108
|
+
catch (error) {
|
|
1109
|
+
this.transport.send({
|
|
1110
|
+
t: "live.ready",
|
|
1111
|
+
reqId: msg.reqId,
|
|
1112
|
+
localThreadId: msg.localThreadId,
|
|
1113
|
+
ok: false,
|
|
1114
|
+
error: toWireError(error),
|
|
1115
|
+
});
|
|
1116
|
+
}
|
|
1117
|
+
}
|
|
1118
|
+
monitorCodexThreadStartup(localThreadId, runtime, provider) {
|
|
1119
|
+
// Starting (or declining to start) a new monitor invalidates every callback
|
|
1120
|
+
// owned by the previous launch before it can touch the replacement state.
|
|
1121
|
+
this.codexStartupMonitors.delete(localThreadId);
|
|
1122
|
+
if (!provider.waitLiveReady)
|
|
1123
|
+
return;
|
|
1124
|
+
if (provider.liveSessionFailure?.(localThreadId)?.code === "login_required")
|
|
1125
|
+
return;
|
|
1126
|
+
const expectedTerminal = this.terminals.get(`${localThreadId}-main`);
|
|
1127
|
+
const monitor = Symbol(localThreadId);
|
|
1128
|
+
this.codexStartupMonitors.set(localThreadId, monitor);
|
|
1129
|
+
const claimCurrentLaunch = () => {
|
|
1130
|
+
if (this.codexStartupMonitors.get(localThreadId) !== monitor ||
|
|
1131
|
+
this.terminals.get(`${localThreadId}-main`) !== expectedTerminal)
|
|
1132
|
+
return false;
|
|
1133
|
+
this.codexStartupMonitors.delete(localThreadId);
|
|
1134
|
+
if (runtime === "traex") {
|
|
1135
|
+
this.cancelTraexUpdateWatcher(localThreadId);
|
|
1136
|
+
this.traexStartupUpdateActivity.delete(localThreadId);
|
|
1137
|
+
}
|
|
1138
|
+
return true;
|
|
1139
|
+
};
|
|
1140
|
+
const handleResult = (result) => {
|
|
1141
|
+
if (result.ready) {
|
|
1142
|
+
if (this.codexStartupMonitors.get(localThreadId) === monitor) {
|
|
1143
|
+
this.codexStartupMonitors.delete(localThreadId);
|
|
1144
|
+
if (runtime === "traex") {
|
|
1145
|
+
this.cancelTraexUpdateWatcher(localThreadId);
|
|
1146
|
+
this.traexStartupUpdateActivity.delete(localThreadId);
|
|
1147
|
+
}
|
|
1148
|
+
}
|
|
1149
|
+
return;
|
|
174
1150
|
}
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
: true;
|
|
180
|
-
const terminal = this.terminals.get(`${msg.localThreadId}-main`);
|
|
181
|
-
const paneFailure = !ready && terminal && !terminal.isAlive()
|
|
1151
|
+
if (!claimCurrentLaunch())
|
|
1152
|
+
return;
|
|
1153
|
+
const terminal = this.terminals.get(`${localThreadId}-main`);
|
|
1154
|
+
const paneFailure = terminal && !terminal.isAlive()
|
|
182
1155
|
? terminal
|
|
183
1156
|
.capturePane()
|
|
184
1157
|
.split("\n")
|
|
@@ -188,44 +1161,176 @@ export class RunnerSession {
|
|
|
188
1161
|
.join(" ")
|
|
189
1162
|
.slice(-1_000)
|
|
190
1163
|
: "";
|
|
191
|
-
const
|
|
1164
|
+
const detail = provider.liveSessionError?.(localThreadId)
|
|
192
1165
|
?? (paneFailure
|
|
193
|
-
?
|
|
194
|
-
:
|
|
1166
|
+
? `${providerDisplayName(runtime)} Terminal exited before native session discovery: ${paneFailure}`
|
|
1167
|
+
: result.updateObserved
|
|
1168
|
+
? `Traex TUI did not publish thread/started before its update-aware startup deadline (${Math.ceil(result.elapsedMs / 1_000)}s elapsed)`
|
|
1169
|
+
: `${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`);
|
|
1170
|
+
this.failCodexThreadStartup(localThreadId, provider, new AgentRuntimeError(detail, 503, paneFailure
|
|
1171
|
+
? "native_terminal_exited_before_session"
|
|
1172
|
+
: "native_thread_discovery_timeout"), expectedTerminal);
|
|
1173
|
+
};
|
|
1174
|
+
const handleError = (error) => {
|
|
1175
|
+
if (!claimCurrentLaunch())
|
|
1176
|
+
return;
|
|
1177
|
+
this.failCodexThreadStartup(localThreadId, provider, error instanceof AgentRuntimeError
|
|
1178
|
+
? error
|
|
1179
|
+
: nativePhaseError(runtime, "native_thread_discovery_failed", "native session discovery failed", error), expectedTerminal);
|
|
1180
|
+
};
|
|
1181
|
+
if (runtime === "traex") {
|
|
1182
|
+
void this.waitForTraexThreadStartup(localThreadId, provider, expectedTerminal, monitor).then(handleResult).catch(handleError);
|
|
1183
|
+
return;
|
|
1184
|
+
}
|
|
1185
|
+
void provider.waitLiveReady(localThreadId, CODEX_THREAD_START_TIMEOUT_MS).then((ready) => {
|
|
1186
|
+
handleResult({
|
|
1187
|
+
ready,
|
|
1188
|
+
updateObserved: false,
|
|
1189
|
+
elapsedMs: CODEX_THREAD_START_TIMEOUT_MS,
|
|
1190
|
+
});
|
|
1191
|
+
}).catch(handleError);
|
|
1192
|
+
}
|
|
1193
|
+
async waitForTraexThreadStartup(localThreadId, provider, expectedTerminal, monitor) {
|
|
1194
|
+
const startedAt = Date.now();
|
|
1195
|
+
const hardDeadline = startedAt + TRAEX_UPDATE_THREAD_START_MAX_MS;
|
|
1196
|
+
let deadline = startedAt + CODEX_THREAD_START_TIMEOUT_MS;
|
|
1197
|
+
let updateObserved = false;
|
|
1198
|
+
const readiness = provider.waitLiveReady(localThreadId, null);
|
|
1199
|
+
return await new Promise((resolve, reject) => {
|
|
1200
|
+
let settled = false;
|
|
1201
|
+
let timer;
|
|
1202
|
+
const finish = (ready, now = Date.now()) => {
|
|
1203
|
+
if (settled)
|
|
1204
|
+
return;
|
|
1205
|
+
settled = true;
|
|
1206
|
+
if (timer)
|
|
1207
|
+
clearTimeout(timer);
|
|
1208
|
+
resolve({ ready, updateObserved, elapsedMs: now - startedAt });
|
|
1209
|
+
};
|
|
1210
|
+
const checkDeadline = () => {
|
|
1211
|
+
if (this.codexStartupMonitors.get(localThreadId) !== monitor ||
|
|
1212
|
+
this.terminals.get(`${localThreadId}-main`) !== expectedTerminal) {
|
|
1213
|
+
finish(false);
|
|
1214
|
+
return;
|
|
1215
|
+
}
|
|
1216
|
+
const now = Date.now();
|
|
1217
|
+
const updateObservedAt = this.traexStartupUpdateActivity.get(localThreadId);
|
|
1218
|
+
if (updateObservedAt !== undefined) {
|
|
1219
|
+
if (!updateObserved) {
|
|
1220
|
+
console.info(`[runner] session=${localThreadId} Traex startup update detected; extending native thread discovery up to ${TRAEX_UPDATE_THREAD_START_MAX_MS / 1_000}s`);
|
|
1221
|
+
}
|
|
1222
|
+
updateObserved = true;
|
|
1223
|
+
deadline = Math.min(hardDeadline, updateObservedAt + CODEX_THREAD_START_TIMEOUT_MS);
|
|
1224
|
+
}
|
|
1225
|
+
const remainingMs = Math.min(deadline, hardDeadline) - now;
|
|
1226
|
+
if (remainingMs <= 0) {
|
|
1227
|
+
finish(false, now);
|
|
1228
|
+
return;
|
|
1229
|
+
}
|
|
1230
|
+
timer = setTimeout(checkDeadline, Math.min(TRAEX_STARTUP_POLL_MS, remainingMs));
|
|
1231
|
+
timer.unref?.();
|
|
1232
|
+
};
|
|
1233
|
+
void readiness.then((ready) => finish(ready), (error) => {
|
|
1234
|
+
if (settled)
|
|
1235
|
+
return;
|
|
1236
|
+
settled = true;
|
|
1237
|
+
if (timer)
|
|
1238
|
+
clearTimeout(timer);
|
|
1239
|
+
reject(error);
|
|
1240
|
+
});
|
|
1241
|
+
checkDeadline();
|
|
1242
|
+
});
|
|
1243
|
+
}
|
|
1244
|
+
/** Discovery failure is terminal for this partial native launch. Publish the
|
|
1245
|
+
* exact cause first so an in-flight injection wakes, then drop the observer
|
|
1246
|
+
* and TUI so a later task retry creates a clean launch instead of reusing an
|
|
1247
|
+
* already-rejected readiness promise. */
|
|
1248
|
+
failCodexThreadStartup(localThreadId, provider, error, expectedTerminal) {
|
|
1249
|
+
if (!provider.failLiveStartup?.(localThreadId, error))
|
|
1250
|
+
return false;
|
|
1251
|
+
this.codexStartupMonitors.delete(localThreadId);
|
|
1252
|
+
const watcher = this.terminalWatchers.get(localThreadId);
|
|
1253
|
+
if (watcher)
|
|
1254
|
+
clearInterval(watcher);
|
|
1255
|
+
this.terminalWatchers.delete(localThreadId);
|
|
1256
|
+
this.cancelTraexStartupWatcher(localThreadId);
|
|
1257
|
+
this.cancelTraexUpdateWatcher(localThreadId);
|
|
1258
|
+
this.traexStartupUpdateActivity.delete(localThreadId);
|
|
1259
|
+
try {
|
|
1260
|
+
this.terminals.close(`${localThreadId}-main`, expectedTerminal);
|
|
1261
|
+
}
|
|
1262
|
+
catch (closeError) {
|
|
1263
|
+
console.warn(`[runner] session=${localThreadId} failed to close native Terminal after startup failure: ${closeError instanceof Error ? closeError.message : String(closeError)}`);
|
|
1264
|
+
}
|
|
1265
|
+
if (provider.teardownLiveCodexSession) {
|
|
1266
|
+
provider.teardownLiveCodexSession(localThreadId);
|
|
1267
|
+
}
|
|
1268
|
+
else {
|
|
1269
|
+
provider.stopLiveCodexSession?.(localThreadId);
|
|
1270
|
+
}
|
|
1271
|
+
this.liveIds.delete(localThreadId);
|
|
1272
|
+
return true;
|
|
1273
|
+
}
|
|
1274
|
+
async inject(msg) {
|
|
1275
|
+
const provider = this.liveProvider;
|
|
1276
|
+
try {
|
|
1277
|
+
const input = msg.input ?? msg.text;
|
|
1278
|
+
const result = (await provider.injectMessage?.(msg.localThreadId, input, msg.options))
|
|
1279
|
+
?? { outcome: "notLive" };
|
|
1280
|
+
// App-server injection is independent of the Terminal TUI startup, so it
|
|
1281
|
+
// must not cancel prompt handling for the pane that is still starting.
|
|
1282
|
+
const error = result.outcome === "injected" || result.outcome === "steered"
|
|
1283
|
+
? undefined
|
|
1284
|
+
: providerFailure(provider, msg.localThreadId, {
|
|
1285
|
+
code: result.outcome === "notReady"
|
|
1286
|
+
? "native_thread_not_ready"
|
|
1287
|
+
: result.outcome === "notLive"
|
|
1288
|
+
? "native_session_not_live"
|
|
1289
|
+
: "native_message_injection_failed",
|
|
1290
|
+
message: result.outcome === "notReady"
|
|
1291
|
+
? "Native session was not ready for message injection"
|
|
1292
|
+
: result.outcome === "notLive"
|
|
1293
|
+
? "Native session was not live for message injection"
|
|
1294
|
+
: "Native message injection failed",
|
|
1295
|
+
});
|
|
195
1296
|
this.transport.send({
|
|
196
|
-
t: "
|
|
1297
|
+
t: "injected",
|
|
197
1298
|
reqId: msg.reqId,
|
|
198
1299
|
localThreadId: msg.localThreadId,
|
|
199
|
-
|
|
200
|
-
...(
|
|
1300
|
+
result,
|
|
1301
|
+
...(error ? { error } : {}),
|
|
201
1302
|
});
|
|
202
1303
|
}
|
|
203
1304
|
catch (error) {
|
|
204
1305
|
this.transport.send({
|
|
205
|
-
t: "
|
|
1306
|
+
t: "injected",
|
|
206
1307
|
reqId: msg.reqId,
|
|
207
1308
|
localThreadId: msg.localThreadId,
|
|
208
|
-
|
|
209
|
-
error:
|
|
1309
|
+
result: { outcome: "failed" },
|
|
1310
|
+
error: toWireError(error),
|
|
210
1311
|
});
|
|
211
1312
|
}
|
|
212
1313
|
}
|
|
213
|
-
async
|
|
214
|
-
const provider = this.liveProvider;
|
|
1314
|
+
async updateCollaborationMode(msg) {
|
|
215
1315
|
try {
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
this.transport.send({
|
|
1316
|
+
if (!this.liveProvider.updateCollaborationMode) {
|
|
1317
|
+
throw new AgentRuntimeError("Native collaboration mode is unavailable", 409, "collaboration_mode_unavailable");
|
|
1318
|
+
}
|
|
1319
|
+
await this.liveProvider.updateCollaborationMode(msg.localThreadId, msg.mode);
|
|
1320
|
+
this.transport.send({
|
|
1321
|
+
t: "collaboration.updated",
|
|
1322
|
+
reqId: msg.reqId,
|
|
1323
|
+
localThreadId: msg.localThreadId,
|
|
1324
|
+
ok: true,
|
|
1325
|
+
});
|
|
221
1326
|
}
|
|
222
1327
|
catch (error) {
|
|
223
1328
|
this.transport.send({
|
|
224
|
-
t: "
|
|
1329
|
+
t: "collaboration.updated",
|
|
225
1330
|
reqId: msg.reqId,
|
|
226
1331
|
localThreadId: msg.localThreadId,
|
|
227
|
-
|
|
228
|
-
error:
|
|
1332
|
+
ok: false,
|
|
1333
|
+
error: toWireError(error),
|
|
229
1334
|
});
|
|
230
1335
|
}
|
|
231
1336
|
}
|
|
@@ -237,7 +1342,7 @@ export class RunnerSession {
|
|
|
237
1342
|
ok = (await provider.interruptLive?.(msg.localThreadId)) ?? false;
|
|
238
1343
|
}
|
|
239
1344
|
catch (e) {
|
|
240
|
-
error =
|
|
1345
|
+
error = toWireError(e);
|
|
241
1346
|
}
|
|
242
1347
|
this.transport.send({
|
|
243
1348
|
t: "interrupted",
|
|
@@ -247,6 +1352,44 @@ export class RunnerSession {
|
|
|
247
1352
|
...(error ? { error } : {}),
|
|
248
1353
|
});
|
|
249
1354
|
}
|
|
1355
|
+
async reapTerminal(msg) {
|
|
1356
|
+
let error;
|
|
1357
|
+
try {
|
|
1358
|
+
const lifecycle = this.terminals.lifecycle(msg.terminalId);
|
|
1359
|
+
const watcher = this.terminalWatchers.get(msg.localThreadId);
|
|
1360
|
+
if (watcher)
|
|
1361
|
+
clearInterval(watcher);
|
|
1362
|
+
this.terminalWatchers.delete(msg.localThreadId);
|
|
1363
|
+
this.cancelTraexStartupWatcher(msg.localThreadId);
|
|
1364
|
+
this.cancelTraexUpdateWatcher(msg.localThreadId);
|
|
1365
|
+
for (const [attachId, sessionId] of this.attachmentThreadIds) {
|
|
1366
|
+
if (sessionId !== msg.localThreadId)
|
|
1367
|
+
continue;
|
|
1368
|
+
this.attachments.get(attachId)?.kill();
|
|
1369
|
+
this.attachments.delete(attachId);
|
|
1370
|
+
this.attachmentThreadIds.delete(attachId);
|
|
1371
|
+
this.attachmentRoles.delete(attachId);
|
|
1372
|
+
}
|
|
1373
|
+
this.terminals.close(msg.terminalId);
|
|
1374
|
+
if (lifecycle === "auxiliary") {
|
|
1375
|
+
await this.liveProvider.teardownAuxiliaryTerminalRuntime?.(msg.localThreadId);
|
|
1376
|
+
this.liveIds.delete(msg.localThreadId);
|
|
1377
|
+
this.liveRuntimes.delete(msg.localThreadId);
|
|
1378
|
+
this.terminalStatuses.delete(msg.localThreadId);
|
|
1379
|
+
}
|
|
1380
|
+
}
|
|
1381
|
+
catch (cause) {
|
|
1382
|
+
error = toWireError(cause);
|
|
1383
|
+
}
|
|
1384
|
+
this.transport.send({
|
|
1385
|
+
t: "terminal.reaped",
|
|
1386
|
+
reqId: msg.reqId,
|
|
1387
|
+
localThreadId: msg.localThreadId,
|
|
1388
|
+
terminalId: msg.terminalId,
|
|
1389
|
+
ok: error === undefined,
|
|
1390
|
+
...(error ? { error } : {}),
|
|
1391
|
+
});
|
|
1392
|
+
}
|
|
250
1393
|
/** Launch (idempotently) the session's codex TUI pane from the executor's
|
|
251
1394
|
* `codexTerminalSpec`. Shares the `${id}-main` terminal id with `term.open`,
|
|
252
1395
|
* so the web attach reuses the same detached pane. */
|
|
@@ -255,70 +1398,275 @@ export class RunnerSession {
|
|
|
255
1398
|
if (!spec)
|
|
256
1399
|
return;
|
|
257
1400
|
const terminalId = `${localThreadId}-main`;
|
|
258
|
-
const coldStart = !this.terminals.has(terminalId);
|
|
259
1401
|
const term = this.terminals.getOrCreate(terminalId, {
|
|
1402
|
+
lifecycle: spec.lifecycle,
|
|
260
1403
|
cwd: spec.cwd,
|
|
261
1404
|
command: spec.command,
|
|
262
1405
|
args: spec.args,
|
|
263
1406
|
cols: cols ?? 120,
|
|
264
1407
|
rows: rows ?? 40,
|
|
265
1408
|
...(spec.env ? { env: spec.env } : {}),
|
|
1409
|
+
...(spec.scrollback === undefined ? {} : { scrollback: spec.scrollback }),
|
|
1410
|
+
...(spec.tmuxAllowPassthrough === undefined
|
|
1411
|
+
? {}
|
|
1412
|
+
: { tmuxAllowPassthrough: spec.tmuxAllowPassthrough }),
|
|
1413
|
+
...(spec.tmuxStartOnAttach === undefined
|
|
1414
|
+
? {}
|
|
1415
|
+
: { tmuxStartOnAttach: spec.tmuxStartOnAttach }),
|
|
1416
|
+
...(spec.keepAliveAfterExit === undefined
|
|
1417
|
+
? {}
|
|
1418
|
+
: { keepAliveAfterExit: spec.keepAliveAfterExit }),
|
|
266
1419
|
});
|
|
267
|
-
if (
|
|
268
|
-
const watcher = Symbol(localThreadId);
|
|
1420
|
+
if (spec.skipTraexStartupPrompts) {
|
|
1421
|
+
const watcher = Symbol(`${localThreadId}:prompts`);
|
|
1422
|
+
const updateWatcher = Symbol(`${localThreadId}:updates`);
|
|
1423
|
+
this.traexStartupUpdateActivity.delete(localThreadId);
|
|
269
1424
|
this.traexStartupWatchers.set(localThreadId, watcher);
|
|
270
|
-
|
|
1425
|
+
this.traexUpdateWatchers.set(localThreadId, updateWatcher);
|
|
1426
|
+
const gate = this.skipTraexStartupPrompts(localThreadId, term, watcher);
|
|
1427
|
+
void this.watchTraexStartupUpdates(localThreadId, term, updateWatcher);
|
|
1428
|
+
this.traexStartupGates.set(localThreadId, gate);
|
|
1429
|
+
void gate.finally(() => {
|
|
1430
|
+
if (this.traexStartupGates.get(localThreadId) === gate) {
|
|
1431
|
+
this.traexStartupGates.delete(localThreadId);
|
|
1432
|
+
}
|
|
1433
|
+
});
|
|
271
1434
|
}
|
|
272
1435
|
this.liveProvider.attachTerminalInjector?.(localThreadId, term);
|
|
1436
|
+
this.watchNativeTerminal(localThreadId, terminalId, term);
|
|
1437
|
+
}
|
|
1438
|
+
watchNativeTerminal(localThreadId, terminalId, terminal) {
|
|
1439
|
+
const previous = this.terminalWatchers.get(localThreadId);
|
|
1440
|
+
if (previous)
|
|
1441
|
+
clearInterval(previous);
|
|
1442
|
+
let checking = false;
|
|
1443
|
+
const timer = setInterval(() => {
|
|
1444
|
+
if (checking || this.shuttingDown || this.terminals.get(terminalId) !== terminal)
|
|
1445
|
+
return;
|
|
1446
|
+
checking = true;
|
|
1447
|
+
const probe = terminal.lifecycleLivenessAsync
|
|
1448
|
+
? terminal.lifecycleLivenessAsync()
|
|
1449
|
+
: terminal.livenessAsync
|
|
1450
|
+
? terminal.livenessAsync()
|
|
1451
|
+
: terminal.isAliveAsync
|
|
1452
|
+
? terminal.isAliveAsync().then((alive) => alive ? "alive" : "dead")
|
|
1453
|
+
: Promise.resolve(terminal.isAlive() ? "alive" : "dead");
|
|
1454
|
+
void probe.then((liveness) => {
|
|
1455
|
+
// A failed pane probe is inconclusive. Only reference implementation's lifecycle
|
|
1456
|
+
// evidence — capture target gone or explicit pane_dead — may fail it.
|
|
1457
|
+
if (liveness !== "dead" ||
|
|
1458
|
+
this.shuttingDown ||
|
|
1459
|
+
this.terminals.get(terminalId) !== terminal)
|
|
1460
|
+
return;
|
|
1461
|
+
clearInterval(timer);
|
|
1462
|
+
this.terminalWatchers.delete(localThreadId);
|
|
1463
|
+
this.cancelTraexStartupWatcher(localThreadId);
|
|
1464
|
+
this.cancelTraexUpdateWatcher(localThreadId);
|
|
1465
|
+
const runtime = this.liveRuntimes.get(localThreadId);
|
|
1466
|
+
const lifecycle = this.terminals.lifecycle(terminalId);
|
|
1467
|
+
const paneFailure = terminalFailureDetail(terminal.capturePane?.() ?? "");
|
|
1468
|
+
const startupFailed = this.failCodexThreadStartup(localThreadId, this.liveProvider, nativePhaseError(runtime, "native_terminal_exited_before_session", "Terminal exited before native session discovery completed", paneFailure || undefined), terminal);
|
|
1469
|
+
if (startupFailed || lifecycle === undefined)
|
|
1470
|
+
return;
|
|
1471
|
+
try {
|
|
1472
|
+
this.terminals.close(terminalId, terminal);
|
|
1473
|
+
}
|
|
1474
|
+
catch (closeError) {
|
|
1475
|
+
console.warn(`[runner] session=${localThreadId} failed to close exited native Terminal: ${closeError instanceof Error ? closeError.message : String(closeError)}`);
|
|
1476
|
+
}
|
|
1477
|
+
const status = this.terminalStatuses.get(localThreadId) ?? "unknown";
|
|
1478
|
+
if (lifecycle === "auxiliary") {
|
|
1479
|
+
console.warn(`[runner] session=${localThreadId} ${providerDisplayName(runtime)} auxiliary Terminal exited`);
|
|
1480
|
+
}
|
|
1481
|
+
else {
|
|
1482
|
+
if (status !== "idle") {
|
|
1483
|
+
this.liveProvider.failLiveSession?.(localThreadId, nativePhaseError(runtime, "required_terminal_exited", "Required Terminal exited unexpectedly", paneFailure || undefined));
|
|
1484
|
+
}
|
|
1485
|
+
this.liveProvider.stopLiveCodexSession?.(localThreadId);
|
|
1486
|
+
this.liveIds.delete(localThreadId);
|
|
1487
|
+
this.liveRuntimes.delete(localThreadId);
|
|
1488
|
+
this.terminalStatuses.delete(localThreadId);
|
|
1489
|
+
}
|
|
1490
|
+
this.enqueueMirror("main", () => {
|
|
1491
|
+
this.transport.send({
|
|
1492
|
+
t: "terminal.lifecycle.ended",
|
|
1493
|
+
localThreadId,
|
|
1494
|
+
terminalId,
|
|
1495
|
+
lifecycle,
|
|
1496
|
+
status,
|
|
1497
|
+
});
|
|
1498
|
+
});
|
|
1499
|
+
}).catch((error) => {
|
|
1500
|
+
console.warn(`[runner] session=${localThreadId} native Terminal liveness probe failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
1501
|
+
}).finally(() => {
|
|
1502
|
+
checking = false;
|
|
1503
|
+
});
|
|
1504
|
+
}, 1_000);
|
|
1505
|
+
timer.unref?.();
|
|
1506
|
+
this.terminalWatchers.set(localThreadId, timer);
|
|
273
1507
|
}
|
|
274
1508
|
cancelTraexStartupWatcher(localThreadId) {
|
|
275
1509
|
if (localThreadId)
|
|
276
1510
|
this.traexStartupWatchers.delete(localThreadId);
|
|
277
1511
|
}
|
|
1512
|
+
cancelTraexUpdateWatcher(localThreadId) {
|
|
1513
|
+
if (localThreadId)
|
|
1514
|
+
this.traexUpdateWatchers.delete(localThreadId);
|
|
1515
|
+
}
|
|
1516
|
+
/** Observe update-only startup screens independently from prompt handling.
|
|
1517
|
+
* The low-frequency capture spans the full normal discovery window and only
|
|
1518
|
+
* remains alive longer after a positively identified update. */
|
|
1519
|
+
async watchTraexStartupUpdates(localThreadId, terminal, watcher) {
|
|
1520
|
+
const startedAt = Date.now();
|
|
1521
|
+
const hardDeadline = startedAt + TRAEX_UPDATE_THREAD_START_MAX_MS;
|
|
1522
|
+
let deadline = startedAt + CODEX_THREAD_START_TIMEOUT_MS;
|
|
1523
|
+
const terminalId = `${localThreadId}-main`;
|
|
1524
|
+
while (!this.shuttingDown &&
|
|
1525
|
+
this.traexUpdateWatchers.get(localThreadId) === watcher &&
|
|
1526
|
+
this.terminals.get(terminalId) === terminal) {
|
|
1527
|
+
const now = Date.now();
|
|
1528
|
+
if (now > deadline)
|
|
1529
|
+
break;
|
|
1530
|
+
try {
|
|
1531
|
+
const pane = normalizeTraexPane(terminal.capturePane());
|
|
1532
|
+
if (isTraexStartupActivity(pane)) {
|
|
1533
|
+
this.traexStartupUpdateActivity.set(localThreadId, now);
|
|
1534
|
+
deadline = Math.min(hardDeadline, now + CODEX_THREAD_START_TIMEOUT_MS);
|
|
1535
|
+
}
|
|
1536
|
+
}
|
|
1537
|
+
catch {
|
|
1538
|
+
// A concurrent terminal teardown owns the lifecycle failure. Keep the
|
|
1539
|
+
// bounded observer alive until that path invalidates this watcher.
|
|
1540
|
+
}
|
|
1541
|
+
const remainingMs = deadline - Date.now();
|
|
1542
|
+
if (remainingMs <= 0)
|
|
1543
|
+
break;
|
|
1544
|
+
await new Promise((resolve) => {
|
|
1545
|
+
const timer = setTimeout(resolve, Math.min(TRAEX_UPDATE_POLL_MS, remainingMs));
|
|
1546
|
+
timer.unref();
|
|
1547
|
+
});
|
|
1548
|
+
}
|
|
1549
|
+
if (this.traexUpdateWatchers.get(localThreadId) === watcher) {
|
|
1550
|
+
this.traexUpdateWatchers.delete(localThreadId);
|
|
1551
|
+
}
|
|
1552
|
+
}
|
|
278
1553
|
async skipTraexStartupPrompts(localThreadId, terminal, watcher) {
|
|
279
1554
|
const prompts = [
|
|
280
1555
|
{
|
|
281
1556
|
id: "welcome",
|
|
282
|
-
matches: (pane) => pane.includes("
|
|
1557
|
+
matches: (pane) => pane.includes("welcome to trae cli") && pane.includes("press enter to continue"),
|
|
283
1558
|
dismiss: () => terminal.sendEnter(),
|
|
1559
|
+
requiresEscapeConfirmation: false,
|
|
284
1560
|
},
|
|
285
1561
|
{
|
|
286
1562
|
id: "migration",
|
|
287
|
-
matches: (pane) => pane.includes("
|
|
288
|
-
pane.includes("
|
|
289
|
-
pane.includes("
|
|
1563
|
+
matches: (pane) => (pane.includes("legacy traecode cli data detected") ||
|
|
1564
|
+
pane.includes("legacy trae cli data detected")) &&
|
|
1565
|
+
pane.includes("select what to import") &&
|
|
1566
|
+
(pane.includes("skip for now") || pane.includes("don't ask again")),
|
|
290
1567
|
dismiss: () => terminal.interrupt(),
|
|
1568
|
+
requiresEscapeConfirmation: true,
|
|
291
1569
|
},
|
|
292
1570
|
{
|
|
293
1571
|
id: "hooks",
|
|
294
|
-
matches: (pane) => pane.includes("
|
|
295
|
-
pane.includes("
|
|
296
|
-
pane.includes("
|
|
297
|
-
pane.includes("Continue without trusting"),
|
|
1572
|
+
matches: (pane) => pane.includes("hooks need review") &&
|
|
1573
|
+
pane.includes("trust all and continue") &&
|
|
1574
|
+
pane.includes("continue without trusting"),
|
|
298
1575
|
dismiss: () => terminal.interrupt(),
|
|
1576
|
+
requiresEscapeConfirmation: true,
|
|
299
1577
|
},
|
|
300
1578
|
];
|
|
301
|
-
|
|
302
|
-
|
|
1579
|
+
let activeWelcome = false;
|
|
1580
|
+
let pendingEscapePromptId;
|
|
1581
|
+
const dismissedPromptIds = new Set();
|
|
1582
|
+
let dismissedAnyPrompt = false;
|
|
1583
|
+
const startedAt = Date.now();
|
|
1584
|
+
let deadline = startedAt + TRAEX_STARTUP_WATCH_MS;
|
|
1585
|
+
let discoveryDeadline = startedAt + TRAEX_STARTUP_DISCOVERY_MS;
|
|
1586
|
+
const authorizationDeadline = startedAt + TRAEX_AUTHORIZATION_WAIT_MS;
|
|
303
1587
|
const terminalId = `${localThreadId}-main`;
|
|
304
1588
|
while (!this.shuttingDown &&
|
|
305
1589
|
Date.now() < deadline &&
|
|
306
1590
|
this.traexStartupWatchers.get(localThreadId) === watcher &&
|
|
307
1591
|
this.terminals.get(terminalId) === terminal) {
|
|
308
|
-
|
|
309
|
-
//
|
|
310
|
-
|
|
311
|
-
if (pane.includes("TRAE CLI Next"))
|
|
312
|
-
break;
|
|
1592
|
+
// Do not treat a composer frame as completion: Traex can render it before
|
|
1593
|
+
// the startup modals arrive. The bounded deadline stops this watcher.
|
|
1594
|
+
const pane = normalizeTraexPane(terminal.capturePane());
|
|
313
1595
|
const prompt = prompts.find((candidate) => candidate.matches(pane));
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
1596
|
+
const now = Date.now();
|
|
1597
|
+
const authorizationPending = isTraexAuthorizationPending(pane);
|
|
1598
|
+
const updateActive = isTraexStartupActivity(pane);
|
|
1599
|
+
if (updateActive) {
|
|
1600
|
+
discoveryDeadline = now + TRAEX_STARTUP_DISCOVERY_MS;
|
|
1601
|
+
}
|
|
1602
|
+
// Human device authorization routinely takes longer than the normal
|
|
1603
|
+
// startup-modal window. Keep watching while that known screen remains,
|
|
1604
|
+
// then preserve a full window for welcome/migration/hooks after sign-in.
|
|
1605
|
+
// The separate cap prevents an abandoned auth screen from polling forever.
|
|
1606
|
+
if (authorizationPending && now < authorizationDeadline) {
|
|
1607
|
+
deadline = now + TRAEX_STARTUP_WATCH_MS;
|
|
1608
|
+
}
|
|
1609
|
+
if (!prompt) {
|
|
1610
|
+
activeWelcome = false;
|
|
1611
|
+
pendingEscapePromptId = undefined;
|
|
1612
|
+
// Every dismissal is followed by a one-second cooldown before the next
|
|
1613
|
+
// capture. If no known modal remains then, startup prompt handling is
|
|
1614
|
+
// complete and message injection may safely begin.
|
|
1615
|
+
if (dismissedAnyPrompt)
|
|
1616
|
+
break;
|
|
1617
|
+
// A clean Traex home may have no startup modal at all. Preserve a
|
|
1618
|
+
// five-second discovery window for late modals, but do not impose the
|
|
1619
|
+
// old full 20-second watcher lifetime on every cold message. A visible
|
|
1620
|
+
// updater keeps the full startup window because prompts may follow it.
|
|
1621
|
+
if (now >= discoveryDeadline &&
|
|
1622
|
+
!authorizationPending &&
|
|
1623
|
+
!updateActive)
|
|
318
1624
|
break;
|
|
319
1625
|
}
|
|
1626
|
+
else if (!prompt.requiresEscapeConfirmation) {
|
|
1627
|
+
pendingEscapePromptId = undefined;
|
|
1628
|
+
if (!activeWelcome && !dismissedPromptIds.has(prompt.id)) {
|
|
1629
|
+
prompt.dismiss();
|
|
1630
|
+
dismissedPromptIds.add(prompt.id);
|
|
1631
|
+
dismissedAnyPrompt = true;
|
|
1632
|
+
await new Promise((resolve) => {
|
|
1633
|
+
const timer = setTimeout(resolve, TRAEX_ESCAPE_COOLDOWN_MS);
|
|
1634
|
+
timer.unref();
|
|
1635
|
+
});
|
|
1636
|
+
continue;
|
|
1637
|
+
}
|
|
1638
|
+
activeWelcome = true;
|
|
1639
|
+
}
|
|
1640
|
+
else if (prompt.id !== pendingEscapePromptId) {
|
|
1641
|
+
activeWelcome = false;
|
|
1642
|
+
pendingEscapePromptId = prompt.id;
|
|
1643
|
+
await new Promise((resolve) => {
|
|
1644
|
+
// Traex can show hooks -> migration -> hooks during one startup. The
|
|
1645
|
+
// trailing hooks screen normally disappears on its own, so observe a
|
|
1646
|
+
// previously handled prompt type for five seconds before acting.
|
|
1647
|
+
const timer = setTimeout(resolve, dismissedPromptIds.has(prompt.id)
|
|
1648
|
+
? TRAEX_REPEAT_PROMPT_CONFIRM_MS
|
|
1649
|
+
: TRAEX_ESCAPE_CONFIRM_MS);
|
|
1650
|
+
timer.unref();
|
|
1651
|
+
});
|
|
1652
|
+
continue;
|
|
1653
|
+
}
|
|
1654
|
+
else {
|
|
1655
|
+
prompt.dismiss();
|
|
1656
|
+
dismissedPromptIds.add(prompt.id);
|
|
1657
|
+
dismissedAnyPrompt = true;
|
|
1658
|
+
// Keep the id through the cooldown. If the same prompt is genuinely
|
|
1659
|
+
// still active after a sent key, the next check retries it; a prompt
|
|
1660
|
+
// that disappeared during the cooldown never receives another key.
|
|
1661
|
+
pendingEscapePromptId = prompt.id;
|
|
1662
|
+
await new Promise((resolve) => {
|
|
1663
|
+
const timer = setTimeout(resolve, TRAEX_ESCAPE_COOLDOWN_MS);
|
|
1664
|
+
timer.unref();
|
|
1665
|
+
});
|
|
1666
|
+
continue;
|
|
1667
|
+
}
|
|
320
1668
|
await new Promise((resolve) => {
|
|
321
|
-
const timer = setTimeout(resolve,
|
|
1669
|
+
const timer = setTimeout(resolve, authorizationPending ? TRAEX_AUTHORIZATION_POLL_MS : TRAEX_STARTUP_POLL_MS);
|
|
322
1670
|
timer.unref();
|
|
323
1671
|
});
|
|
324
1672
|
}
|
|
@@ -327,28 +1675,56 @@ export class RunnerSession {
|
|
|
327
1675
|
}
|
|
328
1676
|
}
|
|
329
1677
|
stopLive() {
|
|
1678
|
+
for (const timer of this.terminalWatchers.values())
|
|
1679
|
+
clearInterval(timer);
|
|
1680
|
+
this.terminalWatchers.clear();
|
|
1681
|
+
this.codexStartupMonitors.clear();
|
|
1682
|
+
this.traexUpdateWatchers.clear();
|
|
1683
|
+
this.traexStartupUpdateActivity.clear();
|
|
330
1684
|
for (const id of this.liveIds) {
|
|
331
1685
|
this.liveProvider.stopLiveCodexSession?.(id, {
|
|
332
1686
|
deferClaudeInteractionCleanup: true,
|
|
333
1687
|
});
|
|
334
1688
|
}
|
|
335
1689
|
this.liveIds.clear();
|
|
1690
|
+
this.liveRuntimes.clear();
|
|
336
1691
|
}
|
|
337
1692
|
/** Stop event forwarding, kill native terminals/hooks, then synchronously
|
|
338
1693
|
* scrub provider handoff files before the child process is allowed to exit. */
|
|
339
1694
|
shutdown() {
|
|
340
|
-
if (this.
|
|
341
|
-
return;
|
|
1695
|
+
if (this.shutdownPromise)
|
|
1696
|
+
return this.shutdownPromise;
|
|
342
1697
|
this.shuttingDown = true;
|
|
343
|
-
this.
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
1698
|
+
this.shutdownPromise = (async () => {
|
|
1699
|
+
const shutdownError = new Error("runner is shutting down");
|
|
1700
|
+
this.rejectMirrorAcks(shutdownError);
|
|
1701
|
+
this.rejectMirrorImageAcks(shutdownError);
|
|
1702
|
+
this.rejectRotationAcks(shutdownError);
|
|
1703
|
+
for (const waiters of this.mirrorRetryWaiters.values()) {
|
|
1704
|
+
for (const wake of waiters)
|
|
1705
|
+
wake();
|
|
1706
|
+
}
|
|
1707
|
+
this.mirrorRetryWaiters.clear();
|
|
1708
|
+
this.stopLive();
|
|
1709
|
+
for (const pending of this.preparations.values()) {
|
|
1710
|
+
clearTimeout(pending.timer);
|
|
1711
|
+
pending.preparation.kill();
|
|
1712
|
+
}
|
|
1713
|
+
this.preparations.clear();
|
|
1714
|
+
for (const attachment of this.attachments.values())
|
|
1715
|
+
attachment.kill();
|
|
1716
|
+
this.terminals.closeAll();
|
|
1717
|
+
this.attachments.clear();
|
|
1718
|
+
this.attachmentThreadIds.clear();
|
|
1719
|
+
this.attachmentRoles.clear();
|
|
1720
|
+
await this.liveProvider.finalizeStoppedLiveSessions?.();
|
|
1721
|
+
this.onShutdown();
|
|
1722
|
+
})();
|
|
1723
|
+
return this.shutdownPromise;
|
|
347
1724
|
}
|
|
348
1725
|
async openTerminal(msg) {
|
|
349
1726
|
try {
|
|
350
|
-
// codex/claude-native session (no explicit command): DUMB ATTACH
|
|
351
|
-
// reattach (codex_native.py:905-942, `app_server=None`). A tab switch ONLY
|
|
1727
|
+
// codex/claude-native session (no explicit command): DUMB ATTACH. A tab switch ONLY
|
|
352
1728
|
// attaches an already-live pane; it NEVER ensures the forwarder or relaunches a
|
|
353
1729
|
// dead pane. Creation/relaunch happens on message-send (`live.ensure`) or an
|
|
354
1730
|
// explicit restart. A missing/dead pane → `term.error`, so the web shows the
|
|
@@ -370,6 +1746,7 @@ export class RunnerSession {
|
|
|
370
1746
|
}
|
|
371
1747
|
// Login-shell terminal (explicit command): create-or-reuse as before.
|
|
372
1748
|
this.terminals.getOrCreate(msg.terminalId, {
|
|
1749
|
+
lifecycle: "auxiliary",
|
|
373
1750
|
cwd: msg.cwd,
|
|
374
1751
|
command: msg.command,
|
|
375
1752
|
args: msg.args,
|
|
@@ -387,14 +1764,14 @@ export class RunnerSession {
|
|
|
387
1764
|
});
|
|
388
1765
|
}
|
|
389
1766
|
}
|
|
390
|
-
/**
|
|
1767
|
+
/** Prepare captured history without starting the live control client. */
|
|
391
1768
|
async attachExisting(msg) {
|
|
392
|
-
const {
|
|
1769
|
+
const { preparation, role } = await this.terminals.prepare(msg.terminalId, msg.role, {
|
|
393
1770
|
cols: msg.cols,
|
|
394
1771
|
rows: msg.rows,
|
|
395
1772
|
});
|
|
396
1773
|
if (this.cancelledTerminalOpens.has(msg.attachId)) {
|
|
397
|
-
|
|
1774
|
+
preparation.kill();
|
|
398
1775
|
this.transport.send({
|
|
399
1776
|
t: "term.error",
|
|
400
1777
|
attachId: msg.attachId,
|
|
@@ -403,20 +1780,168 @@ export class RunnerSession {
|
|
|
403
1780
|
});
|
|
404
1781
|
return;
|
|
405
1782
|
}
|
|
406
|
-
|
|
1783
|
+
const timer = setTimeout(() => {
|
|
1784
|
+
const pending = this.preparations.get(msg.attachId);
|
|
1785
|
+
if (!pending || pending.preparation !== preparation)
|
|
1786
|
+
return;
|
|
1787
|
+
this.preparations.delete(msg.attachId);
|
|
1788
|
+
this.attachmentThreadIds.delete(msg.attachId);
|
|
1789
|
+
this.attachmentRoles.delete(msg.attachId);
|
|
1790
|
+
preparation.kill();
|
|
1791
|
+
}, TERMINAL_PREPARATION_TTL_MS);
|
|
1792
|
+
timer.unref?.();
|
|
1793
|
+
this.preparations.set(msg.attachId, { preparation, timer });
|
|
1794
|
+
this.attachmentRoles.set(msg.attachId, role);
|
|
407
1795
|
if (msg.localThreadId)
|
|
408
1796
|
this.attachmentThreadIds.set(msg.attachId, msg.localThreadId);
|
|
409
|
-
|
|
410
|
-
t: "term.
|
|
1797
|
+
this.transport.send({
|
|
1798
|
+
t: "term.prepared",
|
|
411
1799
|
attachId: msg.attachId,
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
1800
|
+
role,
|
|
1801
|
+
seedBytes: preparation.seedBytes,
|
|
1802
|
+
...(preparation.dimensions ?? {}),
|
|
1803
|
+
});
|
|
1804
|
+
}
|
|
1805
|
+
async readTerminalSeed(msg) {
|
|
1806
|
+
const pending = this.preparations.get(msg.attachId);
|
|
1807
|
+
if (!pending) {
|
|
1808
|
+
this.sendTerminalOperationError(msg.attachId, msg.reqId, "terminal is not prepared");
|
|
1809
|
+
return;
|
|
1810
|
+
}
|
|
1811
|
+
try {
|
|
1812
|
+
const result = await pending.preparation.readSeed(msg.offset, msg.maxBytes);
|
|
1813
|
+
await this.transport.sendAndDrain({
|
|
1814
|
+
t: "term.seed.chunk",
|
|
1815
|
+
attachId: msg.attachId,
|
|
1816
|
+
reqId: msg.reqId,
|
|
1817
|
+
dataB64: Buffer.from(result.data).toString("base64"),
|
|
1818
|
+
nextOffset: result.nextOffset,
|
|
1819
|
+
done: result.done,
|
|
1820
|
+
...(result.finalOffset === undefined ? {} : { finalOffset: result.finalOffset }),
|
|
1821
|
+
});
|
|
1822
|
+
}
|
|
1823
|
+
catch (error) {
|
|
1824
|
+
this.sendTerminalOperationError(msg.attachId, msg.reqId, error);
|
|
1825
|
+
}
|
|
1826
|
+
}
|
|
1827
|
+
async startTerminal(msg) {
|
|
1828
|
+
const pending = this.preparations.get(msg.attachId);
|
|
1829
|
+
if (!pending) {
|
|
1830
|
+
this.sendTerminalOperationError(msg.attachId, msg.reqId, "terminal is not prepared");
|
|
1831
|
+
return;
|
|
1832
|
+
}
|
|
1833
|
+
clearTimeout(pending.timer);
|
|
1834
|
+
this.preparations.delete(msg.attachId);
|
|
1835
|
+
try {
|
|
1836
|
+
const attachment = await pending.preparation.start();
|
|
1837
|
+
this.attachments.set(msg.attachId, attachment);
|
|
1838
|
+
attachment.onResize(({ cols, rows }) => {
|
|
1839
|
+
this.transport.send({ t: "term.dimensions", attachId: msg.attachId, cols, rows });
|
|
1840
|
+
});
|
|
1841
|
+
await this.transport.sendAndDrain({
|
|
1842
|
+
t: "term.started",
|
|
1843
|
+
attachId: msg.attachId,
|
|
1844
|
+
reqId: msg.reqId,
|
|
1845
|
+
});
|
|
1846
|
+
void attachment.readerDone.then(async (info) => {
|
|
1847
|
+
await this.transport.sendAndDrain({
|
|
1848
|
+
t: "term.reader.done",
|
|
1849
|
+
attachId: msg.attachId,
|
|
1850
|
+
finalOffset: info.finalOffset,
|
|
1851
|
+
reason: info.reason,
|
|
1852
|
+
exitCode: info.exitCode,
|
|
1853
|
+
}).catch(() => undefined);
|
|
1854
|
+
});
|
|
1855
|
+
}
|
|
1856
|
+
catch (error) {
|
|
1857
|
+
pending.preparation.kill();
|
|
416
1858
|
this.attachmentThreadIds.delete(msg.attachId);
|
|
417
|
-
this.
|
|
1859
|
+
this.attachmentRoles.delete(msg.attachId);
|
|
1860
|
+
this.sendTerminalOperationError(msg.attachId, msg.reqId, error);
|
|
1861
|
+
}
|
|
1862
|
+
}
|
|
1863
|
+
async readTerminalOutput(msg) {
|
|
1864
|
+
const attachment = this.attachments.get(msg.attachId);
|
|
1865
|
+
if (!attachment) {
|
|
1866
|
+
this.sendTerminalOperationError(msg.attachId, msg.reqId, "terminal has not started");
|
|
1867
|
+
return;
|
|
1868
|
+
}
|
|
1869
|
+
try {
|
|
1870
|
+
const result = await attachment.read(msg.offset, msg.maxBytes);
|
|
1871
|
+
await this.transport.sendAndDrain({
|
|
1872
|
+
t: "term.chunk",
|
|
1873
|
+
attachId: msg.attachId,
|
|
1874
|
+
reqId: msg.reqId,
|
|
1875
|
+
dataB64: Buffer.from(result.data).toString("base64"),
|
|
1876
|
+
nextOffset: result.nextOffset,
|
|
1877
|
+
done: result.done,
|
|
1878
|
+
...(result.finalOffset === undefined ? {} : { finalOffset: result.finalOffset }),
|
|
1879
|
+
});
|
|
1880
|
+
}
|
|
1881
|
+
catch (error) {
|
|
1882
|
+
this.sendTerminalOperationError(msg.attachId, msg.reqId, error);
|
|
1883
|
+
}
|
|
1884
|
+
}
|
|
1885
|
+
async writeTerminalInput(msg) {
|
|
1886
|
+
if (this.attachmentRoles.get(msg.attachId) !== "owner") {
|
|
1887
|
+
await this.transport.sendAndDrain({
|
|
1888
|
+
t: "term.ack",
|
|
1889
|
+
attachId: msg.attachId,
|
|
1890
|
+
reqId: msg.reqId,
|
|
1891
|
+
operation: "input",
|
|
1892
|
+
});
|
|
1893
|
+
return;
|
|
1894
|
+
}
|
|
1895
|
+
const attachment = this.attachments.get(msg.attachId);
|
|
1896
|
+
if (!attachment) {
|
|
1897
|
+
this.sendTerminalOperationError(msg.attachId, msg.reqId, "terminal has not started");
|
|
1898
|
+
return;
|
|
1899
|
+
}
|
|
1900
|
+
const localThreadId = this.attachmentThreadIds.get(msg.attachId);
|
|
1901
|
+
const input = Buffer.from(msg.dataB64, "base64");
|
|
1902
|
+
if (!isTerminalProtocolResponse(input.toString("utf8"))) {
|
|
1903
|
+
this.cancelTraexStartupWatcher(localThreadId);
|
|
1904
|
+
}
|
|
1905
|
+
try {
|
|
1906
|
+
await attachment.write(input);
|
|
1907
|
+
await this.transport.sendAndDrain({
|
|
1908
|
+
t: "term.ack",
|
|
1909
|
+
attachId: msg.attachId,
|
|
1910
|
+
reqId: msg.reqId,
|
|
1911
|
+
operation: "input",
|
|
1912
|
+
});
|
|
1913
|
+
}
|
|
1914
|
+
catch (error) {
|
|
1915
|
+
this.sendTerminalOperationError(msg.attachId, msg.reqId, error);
|
|
1916
|
+
}
|
|
1917
|
+
}
|
|
1918
|
+
async resizeTerminal(msg) {
|
|
1919
|
+
const attachment = this.attachments.get(msg.attachId);
|
|
1920
|
+
if (!attachment) {
|
|
1921
|
+
this.sendTerminalOperationError(msg.attachId, msg.reqId, "terminal has not started");
|
|
1922
|
+
return;
|
|
1923
|
+
}
|
|
1924
|
+
try {
|
|
1925
|
+
await attachment.resize(msg.cols, msg.rows);
|
|
1926
|
+
await this.transport.sendAndDrain({
|
|
1927
|
+
t: "term.ack",
|
|
1928
|
+
attachId: msg.attachId,
|
|
1929
|
+
reqId: msg.reqId,
|
|
1930
|
+
operation: "resize",
|
|
1931
|
+
});
|
|
1932
|
+
}
|
|
1933
|
+
catch (error) {
|
|
1934
|
+
this.sendTerminalOperationError(msg.attachId, msg.reqId, error);
|
|
1935
|
+
}
|
|
1936
|
+
}
|
|
1937
|
+
sendTerminalOperationError(attachId, reqId, error) {
|
|
1938
|
+
this.transport.send({
|
|
1939
|
+
t: "term.error",
|
|
1940
|
+
attachId,
|
|
1941
|
+
reqId,
|
|
1942
|
+
code: "terminal_open_failed",
|
|
1943
|
+
message: error instanceof Error ? error.message : String(error),
|
|
418
1944
|
});
|
|
419
|
-
this.transport.send({ t: "term.opened", attachId: msg.attachId, role });
|
|
420
1945
|
}
|
|
421
1946
|
async runCap(capId, name, args) {
|
|
422
1947
|
try {
|