@rynx-ai/runtime 0.1.11-beta.2 → 0.1.11-beta.20
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/models.d.ts +0 -5
- package/dist/claude/models.js +1 -7
- package/dist/claude/native-integration.d.ts +12 -6
- package/dist/claude/native-integration.js +137 -28
- package/dist/codex-app-server/forwarder.d.ts +33 -0
- package/dist/codex-app-server/forwarder.js +159 -0
- 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 +7 -4
- package/dist/codex-app-server/ws-channel.js +19 -19
- package/dist/codex-home.js +2 -4
- package/dist/host.d.ts +27 -7
- package/dist/host.js +298 -55
- package/dist/index.d.ts +1 -1
- package/dist/index.js +1 -1
- package/dist/models-catalog.d.ts +2 -1
- package/dist/models-catalog.js +82 -3
- package/dist/runner/child.d.ts +22 -6
- package/dist/runner/child.js +237 -30
- package/dist/runner/manager.d.ts +72 -4
- package/dist/runner/manager.js +539 -54
- package/dist/runner/protocol.d.ts +6 -4
- package/dist/runner/startup-policy.d.ts +4 -0
- package/dist/runner/startup-policy.js +5 -0
- package/dist/terminal/claude-tui.d.ts +3 -1
- package/dist/terminal/claude-tui.js +3 -1
- package/dist/terminal/tmux.d.ts +14 -2
- package/dist/terminal/tmux.js +72 -14
- package/package.json +4 -3
package/dist/runner/manager.js
CHANGED
|
@@ -22,7 +22,9 @@ import { fileURLToPath } from "node:url";
|
|
|
22
22
|
import { AgentRuntimeError, } from "@rynx-ai/core";
|
|
23
23
|
import { listRuntimeModels } from "../models-catalog.js";
|
|
24
24
|
import { probeRuntimeStatus } from "../runtime-status.js";
|
|
25
|
+
import { terminateTmuxServer } from "../terminal/tmux.js";
|
|
25
26
|
import { fromWireError, } from "./protocol.js";
|
|
27
|
+
import { isManagedNativeProvider } from "./startup-policy.js";
|
|
26
28
|
import { StdioRunnerTransport } from "./transport.js";
|
|
27
29
|
/** Routing key for the shared capability runner (slash-command RPCs). */
|
|
28
30
|
const CAP_KEY = "__cap__";
|
|
@@ -32,10 +34,29 @@ const STDERR_TAIL_LINES = 40;
|
|
|
32
34
|
const DEFAULT_SHUTDOWN_GRACE_MS = 5_000;
|
|
33
35
|
/** Time allowed for exit after SIGKILL before shutdown reports failure. */
|
|
34
36
|
const DEFAULT_SHUTDOWN_KILL_GRACE_MS = 5_000;
|
|
35
|
-
/** Setup-pane launch
|
|
36
|
-
|
|
37
|
-
|
|
37
|
+
/** Setup-pane launch includes the app-server readiness phase but does not wait
|
|
38
|
+
* for native thread discovery. Keep enough headroom around the app-server's
|
|
39
|
+
* own 10s gate so scheduling/IPC overhead cannot win the same deadline. */
|
|
40
|
+
const DEFAULT_LIVE_START_TIMEOUT_MS = 30_000;
|
|
41
|
+
/** Legacy fallback for a provider without a native startup policy. */
|
|
38
42
|
const DEFAULT_LIVE_READY_TIMEOUT_MS = 25_000;
|
|
43
|
+
/** Claude has no app-server bridge and keeps a parent-owned SessionStart
|
|
44
|
+
* deadline. Codex-lineage startup instead acknowledges pane/observer startup
|
|
45
|
+
* and lets thread discovery race injection. */
|
|
46
|
+
const DEFAULT_NATIVE_LIVE_START_TIMEOUT_MS = 60_000;
|
|
47
|
+
/** A submitted TUI command should become a mirrored turn or native rotation
|
|
48
|
+
* quickly. If it does not, the runner is fenced by a verified process-tree
|
|
49
|
+
* shutdown before maintenance may treat the submission as settled. */
|
|
50
|
+
const DEFAULT_TERMINAL_INPUT_HANDOFF_TIMEOUT_MS = 30_000;
|
|
51
|
+
/** Retry transient terminal-server cleanup without spinning forever. */
|
|
52
|
+
const DEFAULT_TERMINAL_INPUT_CLEANUP_RETRY_MS = 1_000;
|
|
53
|
+
const MAX_TERMINAL_INPUT_CLEANUP_RETRY_MS = 30_000;
|
|
54
|
+
/** A Provider that never closes its active response must not keep a detached
|
|
55
|
+
* runner + tmux server alive forever. This is intentionally much longer than
|
|
56
|
+
* the normal idle TTL so legitimate long-running turns are not treated as
|
|
57
|
+
* stale. User input/control activity refreshes the deadline; Provider output
|
|
58
|
+
* does not, because a runaway redraw loop is the failure mode this bounds. */
|
|
59
|
+
const DEFAULT_STALE_ACTIVE_TTL_MS = 60 * 60_000;
|
|
39
60
|
/** Keep per-Session spawn context small enough to remain an environment handoff,
|
|
40
61
|
* not an unbounded transport. */
|
|
41
62
|
const MAX_SESSION_CONTEXT_ENV_ENTRIES = 32;
|
|
@@ -136,9 +157,12 @@ export class RunnerManager {
|
|
|
136
157
|
sessionStore;
|
|
137
158
|
runnerEntry;
|
|
138
159
|
idleTtlMs;
|
|
160
|
+
staleActiveTtlMs;
|
|
139
161
|
spawn;
|
|
140
162
|
childEnv;
|
|
141
163
|
sessionContextProvider;
|
|
164
|
+
admissionOpen;
|
|
165
|
+
admissionReserve;
|
|
142
166
|
now;
|
|
143
167
|
defaultRuntime;
|
|
144
168
|
handles = new Map();
|
|
@@ -148,8 +172,14 @@ export class RunnerManager {
|
|
|
148
172
|
reapTimer;
|
|
149
173
|
shutdownGraceMs;
|
|
150
174
|
shutdownKillGraceMs;
|
|
175
|
+
signalChild;
|
|
176
|
+
terminateTerminalServer;
|
|
151
177
|
liveStartTimeoutMs;
|
|
152
178
|
liveReadyTimeoutMs;
|
|
179
|
+
nativeLiveStartTimeoutMs;
|
|
180
|
+
liveInterruptTimeoutMs;
|
|
181
|
+
terminalInputHandoffTimeoutMs;
|
|
182
|
+
terminalInputCleanupRetryMs;
|
|
153
183
|
stopping = false;
|
|
154
184
|
stopPromise;
|
|
155
185
|
/** Sink for mirrored {@link SessionEvent}s from every session's forwarder. */
|
|
@@ -175,20 +205,32 @@ export class RunnerManager {
|
|
|
175
205
|
forkOperations = new Map();
|
|
176
206
|
forkBufferedMessages = new Map();
|
|
177
207
|
forkBufferedTerminalInputs = new Map();
|
|
208
|
+
/** Owner TUI submissions accepted by the parent but not yet represented by a
|
|
209
|
+
* mirrored response or a durably published native rotation. */
|
|
210
|
+
terminalInputHandoffs = new Map();
|
|
178
211
|
constructor(opts) {
|
|
179
212
|
this.config = opts.config;
|
|
180
213
|
this.sessionStore = opts.sessionStore;
|
|
181
214
|
this.runnerEntry = opts.runnerEntry ?? defaultRunnerEntry();
|
|
182
215
|
this.idleTtlMs = opts.idleTtlMs ?? 300_000;
|
|
216
|
+
this.staleActiveTtlMs = Math.max(this.idleTtlMs, opts.staleActiveTtlMs ?? DEFAULT_STALE_ACTIVE_TTL_MS);
|
|
183
217
|
this.spawn = opts.spawn ?? nodeSpawn;
|
|
184
218
|
this.childEnv = opts.childEnv ?? {};
|
|
185
219
|
this.sessionContextProvider = opts.sessionContextProvider;
|
|
220
|
+
this.admissionOpen = opts.admissionOpen ?? (() => true);
|
|
221
|
+
this.admissionReserve = opts.admissionReserve;
|
|
186
222
|
this.now = opts.now ?? (() => Date.now());
|
|
187
|
-
this.defaultRuntime = opts.config.
|
|
223
|
+
this.defaultRuntime = opts.config.DEFAULT_RUNTIME ?? "codex";
|
|
188
224
|
this.shutdownGraceMs = Math.max(0, opts.shutdownGraceMs ?? DEFAULT_SHUTDOWN_GRACE_MS);
|
|
189
225
|
this.shutdownKillGraceMs = Math.max(0, opts.shutdownKillGraceMs ?? DEFAULT_SHUTDOWN_KILL_GRACE_MS);
|
|
226
|
+
this.signalChild = opts.signalChild ?? signalRunnerChild;
|
|
227
|
+
this.terminateTerminalServer = opts.terminateTerminalServer ?? terminateTmuxServer;
|
|
190
228
|
this.liveStartTimeoutMs = Math.max(1, opts.liveStartTimeoutMs ?? DEFAULT_LIVE_START_TIMEOUT_MS);
|
|
191
229
|
this.liveReadyTimeoutMs = Math.max(1, opts.liveReadyTimeoutMs ?? DEFAULT_LIVE_READY_TIMEOUT_MS);
|
|
230
|
+
this.nativeLiveStartTimeoutMs = Math.max(1, opts.nativeLiveStartTimeoutMs ?? DEFAULT_NATIVE_LIVE_START_TIMEOUT_MS);
|
|
231
|
+
this.liveInterruptTimeoutMs = Math.max(1, opts.liveInterruptTimeoutMs ?? 10_000);
|
|
232
|
+
this.terminalInputHandoffTimeoutMs = Math.max(1, opts.terminalInputHandoffTimeoutMs ?? DEFAULT_TERMINAL_INPUT_HANDOFF_TIMEOUT_MS);
|
|
233
|
+
this.terminalInputCleanupRetryMs = Math.max(1, opts.terminalInputCleanupRetryMs ?? DEFAULT_TERMINAL_INPUT_CLEANUP_RETRY_MS);
|
|
192
234
|
const reapIntervalMs = opts.reapIntervalMs ?? 60_000;
|
|
193
235
|
if (reapIntervalMs > 0) {
|
|
194
236
|
this.reapTimer = setInterval(() => this.reapIdle(), reapIntervalMs);
|
|
@@ -216,6 +258,7 @@ export class RunnerManager {
|
|
|
216
258
|
* and attach.
|
|
217
259
|
*/
|
|
218
260
|
openLiveTerminal(localThreadId, opts) {
|
|
261
|
+
this.assertAdmissionOpen();
|
|
219
262
|
if (this.forkReservations.has(localThreadId)) {
|
|
220
263
|
throw new TerminalOpenError("Session fork is still being committed", "terminal_not_live");
|
|
221
264
|
}
|
|
@@ -228,12 +271,38 @@ export class RunnerManager {
|
|
|
228
271
|
openTerminalOnHandle(handle, localThreadId, opts) {
|
|
229
272
|
handle.lastUsedAt = this.now();
|
|
230
273
|
const attachId = randomUUID();
|
|
274
|
+
const inputTracker = {
|
|
275
|
+
buffer: "",
|
|
276
|
+
previousWasCarriageReturn: false,
|
|
277
|
+
bracketedPaste: false,
|
|
278
|
+
pendingEscape: "",
|
|
279
|
+
};
|
|
231
280
|
const terminal = new ManagedTerminal(attachId, (msg) => {
|
|
281
|
+
if (msg.t === "term.input")
|
|
282
|
+
handle.lastUsedAt = this.now();
|
|
283
|
+
const handoffs = msg.t === "term.input" && opts.role === "owner"
|
|
284
|
+
? this.beginTerminalInputHandoffs(handle, this.currentTerminalSessionId(handle, localThreadId), trackedTerminalSubmissions(inputTracker, Buffer.from(msg.dataB64, "base64").toString("utf8")))
|
|
285
|
+
: [];
|
|
286
|
+
const send = () => {
|
|
287
|
+
try {
|
|
288
|
+
handle.transport.send(msg);
|
|
289
|
+
}
|
|
290
|
+
catch (error) {
|
|
291
|
+
for (const handoff of handoffs) {
|
|
292
|
+
this.finishTerminalInputHandoff(handle, handoff);
|
|
293
|
+
}
|
|
294
|
+
throw error;
|
|
295
|
+
}
|
|
296
|
+
};
|
|
232
297
|
const sourceReservation = this.sourceForkReservations.get(localThreadId);
|
|
233
298
|
if (msg.t === "term.input" && sourceReservation) {
|
|
234
299
|
void sourceReservation.then(() => {
|
|
235
300
|
if (!handle.dead)
|
|
236
|
-
|
|
301
|
+
send();
|
|
302
|
+
}).catch((error) => {
|
|
303
|
+
void this.terminateHandle(handle, `terminal input delivery failed: ${errorMessage(error)}`).catch((terminationError) => {
|
|
304
|
+
logTerminationFailure(handle, terminationError);
|
|
305
|
+
});
|
|
237
306
|
});
|
|
238
307
|
return;
|
|
239
308
|
}
|
|
@@ -246,7 +315,7 @@ export class RunnerManager {
|
|
|
246
315
|
return;
|
|
247
316
|
}
|
|
248
317
|
}
|
|
249
|
-
|
|
318
|
+
send();
|
|
250
319
|
}, () => handle.terminals.delete(attachId));
|
|
251
320
|
handle.terminals.set(attachId, terminal);
|
|
252
321
|
handle.transport.send({
|
|
@@ -284,20 +353,171 @@ export class RunnerManager {
|
|
|
284
353
|
onRotate(listener) {
|
|
285
354
|
this.rotateListener = listener;
|
|
286
355
|
}
|
|
356
|
+
/** Accepted TUI submissions that have not crossed into an observable runtime
|
|
357
|
+
* state. The daemon folds this into runningTurns after closing admission. */
|
|
358
|
+
pendingTerminalInputCount() {
|
|
359
|
+
let count = 0;
|
|
360
|
+
for (const handoffs of this.terminalInputHandoffs.values()) {
|
|
361
|
+
count += handoffs.length;
|
|
362
|
+
}
|
|
363
|
+
return count;
|
|
364
|
+
}
|
|
365
|
+
beginTerminalInputHandoffs(handle, sessionId, kinds) {
|
|
366
|
+
if (kinds.length === 0)
|
|
367
|
+
return [];
|
|
368
|
+
const created = [];
|
|
369
|
+
try {
|
|
370
|
+
for (const kind of kinds) {
|
|
371
|
+
const handoff = {
|
|
372
|
+
sessionId,
|
|
373
|
+
kind,
|
|
374
|
+
reservation: this.reserveAdmission(),
|
|
375
|
+
timer: undefined,
|
|
376
|
+
};
|
|
377
|
+
handoff.timer = setTimeout(() => {
|
|
378
|
+
this.expireTerminalInputHandoffs(handle);
|
|
379
|
+
}, this.terminalInputHandoffTimeoutMs);
|
|
380
|
+
handoff.timer.unref?.();
|
|
381
|
+
const handoffs = this.terminalInputHandoffs.get(handle);
|
|
382
|
+
if (handoffs)
|
|
383
|
+
handoffs.push(handoff);
|
|
384
|
+
else
|
|
385
|
+
this.terminalInputHandoffs.set(handle, [handoff]);
|
|
386
|
+
created.push(handoff);
|
|
387
|
+
}
|
|
388
|
+
return created;
|
|
389
|
+
}
|
|
390
|
+
catch (error) {
|
|
391
|
+
for (const handoff of created) {
|
|
392
|
+
this.finishTerminalInputHandoff(handle, handoff);
|
|
393
|
+
}
|
|
394
|
+
throw error;
|
|
395
|
+
}
|
|
396
|
+
}
|
|
397
|
+
settleTerminalInputHandoff(handle, sessionId, kind) {
|
|
398
|
+
const handoff = this.terminalInputHandoffs
|
|
399
|
+
.get(handle)
|
|
400
|
+
?.find((candidate) => candidate.sessionId === sessionId);
|
|
401
|
+
// Runtime observations settle accepted input in submission order. In
|
|
402
|
+
// particular, a response from the source Session while /clear or /fork is
|
|
403
|
+
// still publishing must not skip that rotation and release a later turn
|
|
404
|
+
// which has not yet been rebound or delivered.
|
|
405
|
+
if (handoff?.kind === kind)
|
|
406
|
+
this.finishTerminalInputHandoff(handle, handoff);
|
|
407
|
+
}
|
|
408
|
+
settleTerminalRotationHandoff(handle, sourceSessionId, targetSessionId) {
|
|
409
|
+
const handoffs = this.terminalInputHandoffs.get(handle);
|
|
410
|
+
const rotationIndex = handoffs?.findIndex((handoff) => handoff.sessionId === sourceSessionId && handoff.kind === "rotation") ?? -1;
|
|
411
|
+
if (!handoffs || rotationIndex < 0)
|
|
412
|
+
return;
|
|
413
|
+
// Turns submitted before /clear or /fork are superseded once the rotation
|
|
414
|
+
// is published. Inputs accepted afterwards belong to the transferred pane
|
|
415
|
+
// and must follow it to the target Session.
|
|
416
|
+
const rotation = handoffs[rotationIndex];
|
|
417
|
+
for (const handoff of [...handoffs.slice(0, rotationIndex)]) {
|
|
418
|
+
if (handoff.sessionId === sourceSessionId && handoff.kind === "turn") {
|
|
419
|
+
this.finishTerminalInputHandoff(handle, handoff);
|
|
420
|
+
}
|
|
421
|
+
}
|
|
422
|
+
if (rotation)
|
|
423
|
+
this.finishTerminalInputHandoff(handle, rotation);
|
|
424
|
+
for (const handoff of this.terminalInputHandoffs.get(handle) ?? []) {
|
|
425
|
+
if (handoff.sessionId === sourceSessionId)
|
|
426
|
+
handoff.sessionId = targetSessionId;
|
|
427
|
+
}
|
|
428
|
+
}
|
|
429
|
+
currentTerminalSessionId(handle, fallback) {
|
|
430
|
+
for (const [sessionId, candidate] of this.handles) {
|
|
431
|
+
if (candidate === handle && this.liveSessionKeys.has(sessionId))
|
|
432
|
+
return sessionId;
|
|
433
|
+
}
|
|
434
|
+
return fallback;
|
|
435
|
+
}
|
|
436
|
+
finishTerminalInputHandoff(handle, handoff) {
|
|
437
|
+
if (handoff.timer) {
|
|
438
|
+
clearTimeout(handoff.timer);
|
|
439
|
+
handoff.timer = undefined;
|
|
440
|
+
}
|
|
441
|
+
handoff.reservation?.release();
|
|
442
|
+
handoff.reservation = undefined;
|
|
443
|
+
const handoffs = this.terminalInputHandoffs.get(handle);
|
|
444
|
+
if (!handoffs)
|
|
445
|
+
return;
|
|
446
|
+
const index = handoffs.indexOf(handoff);
|
|
447
|
+
if (index >= 0)
|
|
448
|
+
handoffs.splice(index, 1);
|
|
449
|
+
if (handoffs.length === 0)
|
|
450
|
+
this.terminalInputHandoffs.delete(handle);
|
|
451
|
+
}
|
|
452
|
+
finishAllTerminalInputHandoffs(handle) {
|
|
453
|
+
if (handle.terminalCleanupRetryTimer) {
|
|
454
|
+
clearTimeout(handle.terminalCleanupRetryTimer);
|
|
455
|
+
delete handle.terminalCleanupRetryTimer;
|
|
456
|
+
}
|
|
457
|
+
handle.terminalCleanupRetryFailures = 0;
|
|
458
|
+
for (const handoff of [...(this.terminalInputHandoffs.get(handle) ?? [])]) {
|
|
459
|
+
this.finishTerminalInputHandoff(handle, handoff);
|
|
460
|
+
}
|
|
461
|
+
}
|
|
462
|
+
preserveTerminalInputHandoffsAsActivity(handle) {
|
|
463
|
+
for (const handoff of this.terminalInputHandoffs.get(handle) ?? []) {
|
|
464
|
+
if (handoff.timer) {
|
|
465
|
+
clearTimeout(handoff.timer);
|
|
466
|
+
handoff.timer = undefined;
|
|
467
|
+
}
|
|
468
|
+
handoff.reservation?.release();
|
|
469
|
+
handoff.reservation = undefined;
|
|
470
|
+
}
|
|
471
|
+
}
|
|
472
|
+
scheduleTerminalInputCleanupRetry(handle) {
|
|
473
|
+
if (handle.terminalCleanupRetryTimer ||
|
|
474
|
+
!this.terminalInputHandoffs.has(handle))
|
|
475
|
+
return;
|
|
476
|
+
const exponent = Math.min(handle.terminalCleanupRetryFailures - 1, 10);
|
|
477
|
+
const delay = Math.min(this.terminalInputCleanupRetryMs * (2 ** Math.max(0, exponent)), MAX_TERMINAL_INPUT_CLEANUP_RETRY_MS);
|
|
478
|
+
handle.terminalCleanupRetryTimer = setTimeout(() => {
|
|
479
|
+
delete handle.terminalCleanupRetryTimer;
|
|
480
|
+
if (!this.terminalInputHandoffs.has(handle))
|
|
481
|
+
return;
|
|
482
|
+
void this.terminateHandle(handle, "retrying unproven terminal input cleanup").catch((error) => {
|
|
483
|
+
logTerminationFailure(handle, error);
|
|
484
|
+
});
|
|
485
|
+
}, delay);
|
|
486
|
+
handle.terminalCleanupRetryTimer.unref?.();
|
|
487
|
+
}
|
|
488
|
+
expireTerminalInputHandoffs(handle) {
|
|
489
|
+
const handoffs = this.terminalInputHandoffs.get(handle);
|
|
490
|
+
if (!handoffs?.length)
|
|
491
|
+
return;
|
|
492
|
+
for (const handoff of handoffs) {
|
|
493
|
+
if (handoff.timer) {
|
|
494
|
+
clearTimeout(handoff.timer);
|
|
495
|
+
handoff.timer = undefined;
|
|
496
|
+
}
|
|
497
|
+
}
|
|
498
|
+
void this.terminateHandle(handle, "accepted terminal input did not become observable before maintenance timeout").catch((error) => {
|
|
499
|
+
logTerminationFailure(handle, error);
|
|
500
|
+
});
|
|
501
|
+
}
|
|
287
502
|
/**
|
|
288
503
|
* Eagerly bring up a session's codex-native live view (persistent forwarder +
|
|
289
504
|
* detached `codex --remote` TUI) in its runner child, spawning the runner if
|
|
290
|
-
* needed. Idempotent.
|
|
291
|
-
*
|
|
505
|
+
* needed. Idempotent. For Codex-lineage fresh sessions, resolves after the
|
|
506
|
+
* app-server/observer and pane are launched; thread discovery continues in
|
|
507
|
+
* parallel with injection. Other providers retain their readiness gate.
|
|
292
508
|
*/
|
|
293
509
|
ensureLiveSession(localThreadId, opts) {
|
|
294
|
-
|
|
510
|
+
const admission = this.reserveAdmission();
|
|
511
|
+
return this.requestLiveSession(localThreadId, opts, true)
|
|
512
|
+
.finally(() => admission.release());
|
|
295
513
|
}
|
|
296
514
|
/** Start the Provider pane without waiting for login/onboarding to create a
|
|
297
515
|
* native thread. This is the setup-terminal gate; callers may attach as soon
|
|
298
516
|
* as it resolves, while normal message delivery still uses ensureLiveSession. */
|
|
299
517
|
startLiveSession(localThreadId, opts) {
|
|
300
|
-
|
|
518
|
+
const admission = this.reserveAdmission();
|
|
519
|
+
return this.requestLiveSession(localThreadId, opts, false)
|
|
520
|
+
.finally(() => admission.release());
|
|
301
521
|
}
|
|
302
522
|
requestLiveSession(localThreadId, opts, waitForReady, allowReservedForkTarget = false) {
|
|
303
523
|
const sourceReservation = allowReservedForkTarget
|
|
@@ -323,12 +543,25 @@ export class RunnerManager {
|
|
|
323
543
|
this.liveSessionKeys.add(localThreadId);
|
|
324
544
|
const reqId = randomUUID();
|
|
325
545
|
return new Promise((resolve) => {
|
|
326
|
-
const timeoutMs = waitForReady
|
|
546
|
+
const timeoutMs = !waitForReady
|
|
547
|
+
? this.liveStartTimeoutMs
|
|
548
|
+
: opts.execution.provider === "claude"
|
|
549
|
+
? this.nativeLiveStartTimeoutMs
|
|
550
|
+
: isManagedNativeProvider(opts.execution.provider)
|
|
551
|
+
? this.liveStartTimeoutMs
|
|
552
|
+
: this.liveReadyTimeoutMs;
|
|
327
553
|
const timeout = setTimeout(() => {
|
|
328
554
|
if (!handle.live.delete(reqId))
|
|
329
555
|
return;
|
|
330
556
|
const finishTimeout = () => {
|
|
331
|
-
const
|
|
557
|
+
const provider = opts.execution.provider;
|
|
558
|
+
const reason = !waitForReady
|
|
559
|
+
? `runner did not acknowledge terminal start within ${timeoutMs}ms`
|
|
560
|
+
: provider === "claude"
|
|
561
|
+
? `Claude SessionStart was not observed within ${Math.round(timeoutMs / 1_000)}s; the runner process was reset before retry`
|
|
562
|
+
: provider === "codex" || provider === "traex"
|
|
563
|
+
? `${provider === "traex" ? "Traex" : "Codex"} app-server/observer/Terminal launch was not acknowledged within ${Math.round(timeoutMs / 1_000)}s; the runner process was reset before retry`
|
|
564
|
+
: `runner did not acknowledge live readiness within ${timeoutMs}ms`;
|
|
332
565
|
this.liveErrors.set(localThreadId, reason);
|
|
333
566
|
this.liveSessionKeys.delete(localThreadId);
|
|
334
567
|
// A child that cannot answer a bounded control round-trip is unsafe
|
|
@@ -395,13 +628,18 @@ export class RunnerManager {
|
|
|
395
628
|
* the session has no live forwarder (caller falls back to the run path).
|
|
396
629
|
*/
|
|
397
630
|
injectMessage(localThreadId, input) {
|
|
631
|
+
const admission = this.reserveAdmission();
|
|
632
|
+
return this.injectMessageAdmitted(localThreadId, input)
|
|
633
|
+
.finally(() => admission.release());
|
|
634
|
+
}
|
|
635
|
+
injectMessageAdmitted(localThreadId, input) {
|
|
398
636
|
const sourceReservation = this.sourceForkReservations.get(localThreadId);
|
|
399
637
|
if (sourceReservation) {
|
|
400
|
-
return sourceReservation.then(() => this.
|
|
638
|
+
return sourceReservation.then(() => this.injectMessageAdmitted(localThreadId, input));
|
|
401
639
|
}
|
|
402
640
|
const reservation = this.forkReservations.get(localThreadId);
|
|
403
641
|
if (reservation) {
|
|
404
|
-
return reservation.then(() => this.
|
|
642
|
+
return reservation.then(() => this.injectMessageAdmitted(localThreadId, input));
|
|
405
643
|
}
|
|
406
644
|
const handle = this.getOrSpawn(localThreadId);
|
|
407
645
|
handle.lastUsedAt = this.now();
|
|
@@ -444,7 +682,16 @@ export class RunnerManager {
|
|
|
444
682
|
handle.lastUsedAt = this.now();
|
|
445
683
|
const reqId = randomUUID();
|
|
446
684
|
return new Promise((resolve) => {
|
|
447
|
-
|
|
685
|
+
const timeout = setTimeout(() => {
|
|
686
|
+
if (!handle.live.delete(reqId))
|
|
687
|
+
return;
|
|
688
|
+
resolve(false);
|
|
689
|
+
}, this.liveInterruptTimeoutMs);
|
|
690
|
+
timeout.unref?.();
|
|
691
|
+
handle.live.set(reqId, (res) => {
|
|
692
|
+
clearTimeout(timeout);
|
|
693
|
+
resolve(res.ok ?? false);
|
|
694
|
+
});
|
|
448
695
|
handle.transport.send({ t: "live.interrupt", reqId, localThreadId });
|
|
449
696
|
});
|
|
450
697
|
}
|
|
@@ -488,6 +735,7 @@ export class RunnerManager {
|
|
|
488
735
|
return this.forwardCap("clearGoal", [localThreadId], localThreadId);
|
|
489
736
|
}
|
|
490
737
|
forkSession(currentLocalThreadId, newLocalThreadId, options) {
|
|
738
|
+
this.assertAdmissionOpen();
|
|
491
739
|
if (currentLocalThreadId === newLocalThreadId) {
|
|
492
740
|
return Promise.resolve({
|
|
493
741
|
ok: false,
|
|
@@ -573,7 +821,7 @@ export class RunnerManager {
|
|
|
573
821
|
}
|
|
574
822
|
/** Stop the runner bound to one session (if any), rejecting its in-flight work. */
|
|
575
823
|
stopRunner(localThreadId) {
|
|
576
|
-
const handle = this.
|
|
824
|
+
const handle = this.handleForCleanup(localThreadId);
|
|
577
825
|
if (!handle) {
|
|
578
826
|
return;
|
|
579
827
|
}
|
|
@@ -581,6 +829,15 @@ export class RunnerManager {
|
|
|
581
829
|
logTerminationFailure(handle, error);
|
|
582
830
|
});
|
|
583
831
|
}
|
|
832
|
+
/** Force-stop and join the runner bound to one Session. Unlike stopRunner,
|
|
833
|
+
* completion proves that the child process has exited. */
|
|
834
|
+
async terminateLiveSession(localThreadId) {
|
|
835
|
+
const handle = this.handleForCleanup(localThreadId);
|
|
836
|
+
if (!handle)
|
|
837
|
+
return false;
|
|
838
|
+
await this.terminateHandle(handle, "runner force-stopped");
|
|
839
|
+
return true;
|
|
840
|
+
}
|
|
584
841
|
/** Stop every runner and join all child exits. Idempotent across concurrent calls. */
|
|
585
842
|
stop() {
|
|
586
843
|
if (this.stopPromise)
|
|
@@ -589,9 +846,12 @@ export class RunnerManager {
|
|
|
589
846
|
if (this.reapTimer) {
|
|
590
847
|
clearInterval(this.reapTimer);
|
|
591
848
|
}
|
|
592
|
-
const children = [
|
|
593
|
-
|
|
594
|
-
|
|
849
|
+
const children = new Set([
|
|
850
|
+
...this.childHandles,
|
|
851
|
+
...this.terminalInputHandoffs.keys(),
|
|
852
|
+
]);
|
|
853
|
+
const attempt = (async () => {
|
|
854
|
+
const results = await Promise.allSettled([...children].map((handle) => this.terminateHandle(handle, "runner manager stopped")));
|
|
595
855
|
this.handles.clear();
|
|
596
856
|
this.liveSessionKeys.clear();
|
|
597
857
|
this.liveErrors.clear();
|
|
@@ -606,9 +866,19 @@ export class RunnerManager {
|
|
|
606
866
|
}
|
|
607
867
|
this.childHandles.clear();
|
|
608
868
|
})();
|
|
609
|
-
|
|
869
|
+
this.stopPromise = attempt;
|
|
870
|
+
void attempt.catch(() => {
|
|
871
|
+
if (this.stopPromise === attempt)
|
|
872
|
+
this.stopPromise = undefined;
|
|
873
|
+
});
|
|
874
|
+
return attempt;
|
|
610
875
|
}
|
|
611
876
|
// ── internals ──────────────────────────────────────────────────────────────
|
|
877
|
+
handleForCleanup(localThreadId) {
|
|
878
|
+
return this.handles.get(localThreadId) ??
|
|
879
|
+
[...this.terminalInputHandoffs].find(([handle, handoffs]) => handle.key === localThreadId ||
|
|
880
|
+
handoffs.some((handoff) => handoff.sessionId === localThreadId))?.[0];
|
|
881
|
+
}
|
|
612
882
|
/** Forward a capability to a runner child. Session-less caps (listModels/status)
|
|
613
883
|
* use the shared `CAP_KEY` child; per-thread caps pass the session's key so they
|
|
614
884
|
* run on that session's child (which owns its private CODEX_HOME). */
|
|
@@ -622,23 +892,48 @@ export class RunnerManager {
|
|
|
622
892
|
});
|
|
623
893
|
}
|
|
624
894
|
getOrSpawn(key, allowReservedForkTarget = false) {
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
existing
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
895
|
+
const admission = this.reserveAdmission();
|
|
896
|
+
try {
|
|
897
|
+
if (this.stopping) {
|
|
898
|
+
throw new AgentRuntimeError("runner manager is stopped", 503, "runner_stopped");
|
|
899
|
+
}
|
|
900
|
+
if (key !== CAP_KEY &&
|
|
901
|
+
this.forkReservations.has(key) &&
|
|
902
|
+
!allowReservedForkTarget) {
|
|
903
|
+
throw new AgentRuntimeError("Session fork is still being committed", 409, "session_fork_pending");
|
|
904
|
+
}
|
|
905
|
+
this.reapIdle();
|
|
906
|
+
const existing = this.handles.get(key);
|
|
907
|
+
if (existing && !existing.dead) {
|
|
908
|
+
existing.lastUsedAt = this.now();
|
|
909
|
+
return existing;
|
|
910
|
+
}
|
|
911
|
+
const handle = this.spawnHandle(key);
|
|
912
|
+
this.handles.set(key, handle);
|
|
913
|
+
return handle;
|
|
914
|
+
}
|
|
915
|
+
finally {
|
|
916
|
+
admission.release();
|
|
917
|
+
}
|
|
918
|
+
}
|
|
919
|
+
assertAdmissionOpen() {
|
|
920
|
+
const reservation = this.admissionReserve?.();
|
|
921
|
+
if (reservation) {
|
|
922
|
+
reservation.release();
|
|
923
|
+
return;
|
|
924
|
+
}
|
|
925
|
+
if (!this.admissionReserve && this.admissionOpen())
|
|
926
|
+
return;
|
|
927
|
+
throw new AgentRuntimeError("daemon maintenance is in progress", 503, "daemon_maintenance");
|
|
928
|
+
}
|
|
929
|
+
reserveAdmission() {
|
|
930
|
+
const reservation = this.admissionReserve?.();
|
|
931
|
+
if (reservation)
|
|
932
|
+
return reservation;
|
|
933
|
+
if (!this.admissionReserve && this.admissionOpen()) {
|
|
934
|
+
return { release() { } };
|
|
935
|
+
}
|
|
936
|
+
throw new AgentRuntimeError("daemon maintenance is in progress", 503, "daemon_maintenance");
|
|
642
937
|
}
|
|
643
938
|
async performManagedFork(currentLocalThreadId, newLocalThreadId, options) {
|
|
644
939
|
const existingTarget = await this.sessionStore.get(newLocalThreadId);
|
|
@@ -749,7 +1044,11 @@ export class RunnerManager {
|
|
|
749
1044
|
if (handle.dead)
|
|
750
1045
|
return;
|
|
751
1046
|
if (message.t === "mirror") {
|
|
1047
|
+
this.observeHandleRuntimeEvent(handle, message.event);
|
|
752
1048
|
this.mirrorListener?.(message.sessionId, message.event);
|
|
1049
|
+
if (message.event.type === "response.created") {
|
|
1050
|
+
this.settleTerminalInputHandoff(handle, message.sessionId, "turn");
|
|
1051
|
+
}
|
|
753
1052
|
return;
|
|
754
1053
|
}
|
|
755
1054
|
this.deliverRotateMessage(handle, message);
|
|
@@ -806,8 +1105,14 @@ export class RunnerManager {
|
|
|
806
1105
|
};
|
|
807
1106
|
void Promise.resolve(this.rotateListener?.(rotation))
|
|
808
1107
|
.then(() => {
|
|
1108
|
+
// The server's rotation listener resolves only after the target Session
|
|
1109
|
+
// is published. Release the terminal admission handoff afterwards so a
|
|
1110
|
+
// maintenance snapshot cannot observe a gap with neither source work
|
|
1111
|
+
// nor the target Session.
|
|
1112
|
+
this.settleTerminalRotationHandoff(handle, message.from, message.to);
|
|
809
1113
|
if (handle.dead)
|
|
810
1114
|
return;
|
|
1115
|
+
handle.activeResponseIds.clear();
|
|
811
1116
|
this.handles.set(message.to, handle);
|
|
812
1117
|
this.liveSessionKeys.add(message.to);
|
|
813
1118
|
this.liveOptions.set(message.to, {
|
|
@@ -845,10 +1150,12 @@ export class RunnerManager {
|
|
|
845
1150
|
const sessionContext = key === CAP_KEY
|
|
846
1151
|
? undefined
|
|
847
1152
|
: this.openSessionContext(key);
|
|
1153
|
+
const processGroup = process.platform !== "win32";
|
|
848
1154
|
let child;
|
|
849
1155
|
try {
|
|
850
1156
|
child = this.spawn(process.execPath, args, {
|
|
851
1157
|
stdio: ["pipe", "pipe", "pipe"],
|
|
1158
|
+
detached: processGroup,
|
|
852
1159
|
// `RYNX_RUNNER_SESSION` = the child's session key (localThreadId, or `__cap__`
|
|
853
1160
|
// for the shared capability child) so its LocalAgentHost scopes the private
|
|
854
1161
|
// CODEX_HOME to this session. It is deliberately applied last.
|
|
@@ -872,8 +1179,11 @@ export class RunnerManager {
|
|
|
872
1179
|
transport,
|
|
873
1180
|
stderr: [],
|
|
874
1181
|
lastUsedAt: this.now(),
|
|
1182
|
+
activeResponseIds: new Set(),
|
|
875
1183
|
dead: false,
|
|
876
1184
|
completion,
|
|
1185
|
+
terminalCleanupRetryFailures: 0,
|
|
1186
|
+
processGroup,
|
|
877
1187
|
...(sessionContext ? { sessionContext } : {}),
|
|
878
1188
|
caps: new Map(),
|
|
879
1189
|
terminals: new Map(),
|
|
@@ -936,7 +1246,14 @@ export class RunnerManager {
|
|
|
936
1246
|
return;
|
|
937
1247
|
if (this.bufferForkTargetMessage(handle, msg))
|
|
938
1248
|
return;
|
|
1249
|
+
this.observeHandleRuntimeEvent(handle, msg.event);
|
|
939
1250
|
this.mirrorListener?.(msg.sessionId, msg.event);
|
|
1251
|
+
if (msg.event.type === "response.created") {
|
|
1252
|
+
// The listener projects response.created into SessionRuntimeIndex
|
|
1253
|
+
// synchronously. Only then may the accepted terminal reservation
|
|
1254
|
+
// drain into a maintenance activity snapshot.
|
|
1255
|
+
this.settleTerminalInputHandoff(handle, msg.sessionId, "turn");
|
|
1256
|
+
}
|
|
940
1257
|
return;
|
|
941
1258
|
case "rotate": {
|
|
942
1259
|
if (handle.dead)
|
|
@@ -965,7 +1282,7 @@ export class RunnerManager {
|
|
|
965
1282
|
}
|
|
966
1283
|
}
|
|
967
1284
|
/** Mark a handle dead and reject every pending run/cap with the exit reason. */
|
|
968
|
-
failHandle(handle, reason) {
|
|
1285
|
+
failHandle(handle, reason, opts = {}) {
|
|
969
1286
|
if (handle.dead) {
|
|
970
1287
|
return;
|
|
971
1288
|
}
|
|
@@ -984,6 +1301,9 @@ export class RunnerManager {
|
|
|
984
1301
|
}
|
|
985
1302
|
const tail = handle.stderr.join("\n");
|
|
986
1303
|
const message = tail ? `${reason}\n--- runner log tail ---\n${tail}` : reason;
|
|
1304
|
+
if (opts.failActiveResponses !== false) {
|
|
1305
|
+
this.failActiveResponses(handle, "runner_crashed", message);
|
|
1306
|
+
}
|
|
987
1307
|
const error = new AgentRuntimeError(message, 500, "runner_crashed");
|
|
988
1308
|
for (const pending of handle.caps.values()) {
|
|
989
1309
|
pending.reject(error);
|
|
@@ -1002,7 +1322,11 @@ export class RunnerManager {
|
|
|
1002
1322
|
terminateHandle(handle, reason) {
|
|
1003
1323
|
if (handle.termination)
|
|
1004
1324
|
return handle.termination;
|
|
1005
|
-
handle.
|
|
1325
|
+
if (handle.terminalCleanupRetryTimer) {
|
|
1326
|
+
clearTimeout(handle.terminalCleanupRetryTimer);
|
|
1327
|
+
delete handle.terminalCleanupRetryTimer;
|
|
1328
|
+
}
|
|
1329
|
+
const attempt = (async () => {
|
|
1006
1330
|
if (!handle.dead) {
|
|
1007
1331
|
try {
|
|
1008
1332
|
handle.transport.send({ t: "shutdown" });
|
|
@@ -1010,38 +1334,126 @@ export class RunnerManager {
|
|
|
1010
1334
|
catch {
|
|
1011
1335
|
// The process signal below remains the authoritative shutdown path.
|
|
1012
1336
|
}
|
|
1013
|
-
this.failHandle(handle, reason);
|
|
1337
|
+
this.failHandle(handle, reason, { failActiveResponses: false });
|
|
1014
1338
|
}
|
|
1015
|
-
signalChild(handle.child, "SIGTERM");
|
|
1016
|
-
|
|
1017
|
-
|
|
1018
|
-
|
|
1019
|
-
|
|
1020
|
-
|
|
1339
|
+
this.signalChild(handle.child, "SIGTERM", handle.processGroup);
|
|
1340
|
+
let childExited = await waitForChildExit(handle.completion, this.shutdownGraceMs);
|
|
1341
|
+
if (!childExited) {
|
|
1342
|
+
this.signalChild(handle.child, "SIGKILL", handle.processGroup);
|
|
1343
|
+
childExited = await waitForChildExit(handle.completion, this.shutdownKillGraceMs);
|
|
1344
|
+
}
|
|
1345
|
+
const terminalStopped = handle.key === CAP_KEY
|
|
1346
|
+
? true
|
|
1347
|
+
: await Promise.resolve(this.terminateTerminalServer(`${handle.key}-main`)).catch(() => false);
|
|
1348
|
+
if (!childExited) {
|
|
1349
|
+
throw new Error(`runner child ${handle.child.pid ?? handle.key} did not exit after SIGKILL`);
|
|
1350
|
+
}
|
|
1351
|
+
if (!terminalStopped) {
|
|
1352
|
+
throw new Error(`runner terminal ${handle.key}-main remained live after child exit`);
|
|
1021
1353
|
}
|
|
1022
1354
|
this.childHandles.delete(handle);
|
|
1023
1355
|
})();
|
|
1024
|
-
|
|
1356
|
+
handle.termination = attempt;
|
|
1357
|
+
void attempt.then(() => {
|
|
1358
|
+
this.finishAllTerminalInputHandoffs(handle);
|
|
1359
|
+
}, () => {
|
|
1360
|
+
if (handle.termination === attempt)
|
|
1361
|
+
delete handle.termination;
|
|
1362
|
+
handle.terminalCleanupRetryFailures += 1;
|
|
1363
|
+
// A failed cleanup must not pin closeAndDrain forever. Keep the
|
|
1364
|
+
// submission in the daemon activity snapshot, but release its gate
|
|
1365
|
+
// reservation so maintenance returns a structured busy result.
|
|
1366
|
+
this.preserveTerminalInputHandoffsAsActivity(handle);
|
|
1367
|
+
this.scheduleTerminalInputCleanupRetry(handle);
|
|
1368
|
+
});
|
|
1369
|
+
return attempt;
|
|
1025
1370
|
}
|
|
1026
1371
|
reapIdle() {
|
|
1027
1372
|
const now = this.now();
|
|
1028
1373
|
for (const [key, handle] of this.handles) {
|
|
1029
|
-
if (handle.caps.size > 0 || handle.terminals.size > 0) {
|
|
1374
|
+
if (handle.caps.size > 0 || handle.terminals.size > 0 || handle.live.size > 0) {
|
|
1030
1375
|
continue;
|
|
1031
1376
|
}
|
|
1032
|
-
|
|
1033
|
-
|
|
1034
|
-
if (this.liveSessionKeys.has(key)) {
|
|
1377
|
+
const idleForMs = now - handle.lastUsedAt;
|
|
1378
|
+
if (idleForMs < this.idleTtlMs) {
|
|
1035
1379
|
continue;
|
|
1036
1380
|
}
|
|
1037
|
-
|
|
1381
|
+
const hasActiveResponse = handle.activeResponseIds.size > 0;
|
|
1382
|
+
if (hasActiveResponse && idleForMs < this.staleActiveTtlMs) {
|
|
1038
1383
|
continue;
|
|
1039
1384
|
}
|
|
1040
|
-
|
|
1385
|
+
const reason = hasActiveResponse
|
|
1386
|
+
? "stale active runner reaped"
|
|
1387
|
+
: this.liveSessionKeys.has(key)
|
|
1388
|
+
? "idle live runner reaped"
|
|
1389
|
+
: "idle runner reaped";
|
|
1390
|
+
if (hasActiveResponse) {
|
|
1391
|
+
this.failStaleActiveResponses(handle, idleForMs);
|
|
1392
|
+
}
|
|
1393
|
+
void this.terminateHandle(handle, reason).catch((error) => {
|
|
1041
1394
|
logTerminationFailure(handle, error);
|
|
1042
1395
|
});
|
|
1043
1396
|
}
|
|
1044
1397
|
}
|
|
1398
|
+
/** Close server-side runtime state before fencing a Provider that stayed
|
|
1399
|
+
* active past its hard inactivity deadline. The child transport is closed by
|
|
1400
|
+
* terminateHandle immediately afterwards, so these are the final events for
|
|
1401
|
+
* the abandoned responses. */
|
|
1402
|
+
failStaleActiveResponses(handle, idleForMs) {
|
|
1403
|
+
this.failActiveResponses(handle, "stale_runner_reaped", `Runner was reaped after ${Math.floor(idleForMs / 1000)}s without user activity`);
|
|
1404
|
+
}
|
|
1405
|
+
failActiveResponses(handle, code, message) {
|
|
1406
|
+
const responseIds = [...handle.activeResponseIds];
|
|
1407
|
+
handle.activeResponseIds.clear();
|
|
1408
|
+
const sessionId = this.currentTerminalSessionId(handle, handle.key);
|
|
1409
|
+
for (const responseId of responseIds) {
|
|
1410
|
+
this.mirrorListener?.(sessionId, {
|
|
1411
|
+
type: "response.failed",
|
|
1412
|
+
responseId,
|
|
1413
|
+
error: {
|
|
1414
|
+
source: "execution",
|
|
1415
|
+
code,
|
|
1416
|
+
message,
|
|
1417
|
+
},
|
|
1418
|
+
});
|
|
1419
|
+
}
|
|
1420
|
+
}
|
|
1421
|
+
/** Track only response lifecycle, not output volume. Output deltas from a
|
|
1422
|
+
* runaway TUI/Provider must not refresh the stale-active deadline. */
|
|
1423
|
+
observeHandleRuntimeEvent(handle, event) {
|
|
1424
|
+
switch (event.type) {
|
|
1425
|
+
case "response.created":
|
|
1426
|
+
case "response.output_text.delta":
|
|
1427
|
+
case "response.reasoning_summary_text.delta":
|
|
1428
|
+
case "response.function_call_output.delta":
|
|
1429
|
+
case "response.output_item.done":
|
|
1430
|
+
handle.activeResponseIds.add(event.responseId);
|
|
1431
|
+
return;
|
|
1432
|
+
case "session.interaction.requested":
|
|
1433
|
+
handle.activeResponseIds.add(event.responseId);
|
|
1434
|
+
return;
|
|
1435
|
+
case "response.completed":
|
|
1436
|
+
case "response.failed":
|
|
1437
|
+
handle.activeResponseIds.delete(event.responseId);
|
|
1438
|
+
return;
|
|
1439
|
+
case "session.status":
|
|
1440
|
+
if (event.status === "running" && event.responseId) {
|
|
1441
|
+
handle.activeResponseIds.add(event.responseId);
|
|
1442
|
+
}
|
|
1443
|
+
else if (event.status !== "running") {
|
|
1444
|
+
if (event.responseId)
|
|
1445
|
+
handle.activeResponseIds.delete(event.responseId);
|
|
1446
|
+
else
|
|
1447
|
+
handle.activeResponseIds.clear();
|
|
1448
|
+
}
|
|
1449
|
+
return;
|
|
1450
|
+
case "session.rotated":
|
|
1451
|
+
handle.activeResponseIds.clear();
|
|
1452
|
+
return;
|
|
1453
|
+
default:
|
|
1454
|
+
return;
|
|
1455
|
+
}
|
|
1456
|
+
}
|
|
1045
1457
|
openSessionContext(sessionId) {
|
|
1046
1458
|
if (!this.sessionContextProvider)
|
|
1047
1459
|
return undefined;
|
|
@@ -1060,6 +1472,70 @@ export class RunnerManager {
|
|
|
1060
1472
|
}
|
|
1061
1473
|
}
|
|
1062
1474
|
}
|
|
1475
|
+
function trackedTerminalSubmissions(tracker, data) {
|
|
1476
|
+
const pasteStart = "\u001b[200~";
|
|
1477
|
+
const pasteEnd = "\u001b[201~";
|
|
1478
|
+
const input = tracker.pendingEscape + data;
|
|
1479
|
+
tracker.pendingEscape = "";
|
|
1480
|
+
const submissions = [];
|
|
1481
|
+
for (let index = 0; index < input.length; index += 1) {
|
|
1482
|
+
const character = input[index] ?? "";
|
|
1483
|
+
if (character === "\u001b") {
|
|
1484
|
+
const remaining = input.slice(index);
|
|
1485
|
+
if (remaining.startsWith(pasteStart)) {
|
|
1486
|
+
tracker.bracketedPaste = true;
|
|
1487
|
+
tracker.previousWasCarriageReturn = false;
|
|
1488
|
+
index += pasteStart.length - 1;
|
|
1489
|
+
continue;
|
|
1490
|
+
}
|
|
1491
|
+
if (remaining.startsWith(pasteEnd)) {
|
|
1492
|
+
tracker.bracketedPaste = false;
|
|
1493
|
+
tracker.previousWasCarriageReturn = false;
|
|
1494
|
+
index += pasteEnd.length - 1;
|
|
1495
|
+
continue;
|
|
1496
|
+
}
|
|
1497
|
+
if (pasteStart.startsWith(remaining) || pasteEnd.startsWith(remaining)) {
|
|
1498
|
+
tracker.pendingEscape = remaining;
|
|
1499
|
+
break;
|
|
1500
|
+
}
|
|
1501
|
+
}
|
|
1502
|
+
if (tracker.bracketedPaste) {
|
|
1503
|
+
tracker.buffer += character;
|
|
1504
|
+
tracker.previousWasCarriageReturn = false;
|
|
1505
|
+
continue;
|
|
1506
|
+
}
|
|
1507
|
+
if (character === "\n" && tracker.previousWasCarriageReturn) {
|
|
1508
|
+
tracker.previousWasCarriageReturn = false;
|
|
1509
|
+
continue;
|
|
1510
|
+
}
|
|
1511
|
+
tracker.previousWasCarriageReturn = character === "\r";
|
|
1512
|
+
if (character === "\r" || character === "\n") {
|
|
1513
|
+
const command = tracker.buffer.trim();
|
|
1514
|
+
tracker.buffer = "";
|
|
1515
|
+
if (command.length > 0 &&
|
|
1516
|
+
(command.includes("\u001b") ||
|
|
1517
|
+
!command.startsWith("/") ||
|
|
1518
|
+
/^\/(?:clear|fork)(?:\s|$)/u.test(command))) {
|
|
1519
|
+
submissions.push(/^\/(?:clear|fork)(?:\s|$)/u.test(command) ? "rotation" : "turn");
|
|
1520
|
+
}
|
|
1521
|
+
continue;
|
|
1522
|
+
}
|
|
1523
|
+
if (character === "\b" || character === "\u007f") {
|
|
1524
|
+
tracker.buffer = tracker.buffer.slice(0, -1);
|
|
1525
|
+
continue;
|
|
1526
|
+
}
|
|
1527
|
+
if (character === "\u0015" || character === "\u0003") {
|
|
1528
|
+
tracker.buffer = "";
|
|
1529
|
+
continue;
|
|
1530
|
+
}
|
|
1531
|
+
// Printable text and tabs are enough to distinguish work-producing prompts
|
|
1532
|
+
// from local TUI slash commands. Other control sequences are ignored.
|
|
1533
|
+
if (character === "\u001b" || character === "\t" || character >= " ") {
|
|
1534
|
+
tracker.buffer += character;
|
|
1535
|
+
}
|
|
1536
|
+
}
|
|
1537
|
+
return submissions;
|
|
1538
|
+
}
|
|
1063
1539
|
function validateSessionContext(context) {
|
|
1064
1540
|
if (!context || typeof context !== "object") {
|
|
1065
1541
|
throw new Error("Session context environment provider must return an object");
|
|
@@ -1164,9 +1640,18 @@ function observeChildCompletion(child) {
|
|
|
1164
1640
|
child.once("exit", onExit);
|
|
1165
1641
|
});
|
|
1166
1642
|
}
|
|
1167
|
-
function
|
|
1643
|
+
function signalRunnerChild(child, signal, processGroup) {
|
|
1168
1644
|
if (childHasExited(child))
|
|
1169
1645
|
return;
|
|
1646
|
+
if (processGroup && child.pid) {
|
|
1647
|
+
try {
|
|
1648
|
+
process.kill(-child.pid, signal);
|
|
1649
|
+
return;
|
|
1650
|
+
}
|
|
1651
|
+
catch {
|
|
1652
|
+
// The group may have exited between the completion check and the signal.
|
|
1653
|
+
}
|
|
1654
|
+
}
|
|
1170
1655
|
try {
|
|
1171
1656
|
child.kill(signal);
|
|
1172
1657
|
}
|