@rynx-ai/runtime 0.1.10 → 0.1.11-beta.10
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-bridge.js +3 -8
- package/dist/claude/native-integration.d.ts +12 -1
- package/dist/claude/native-integration.js +16 -2
- package/dist/claude/transcript.d.ts +0 -7
- package/dist/claude/transcript.js +6 -20
- package/dist/codex-app-server/client.d.ts +2 -1
- package/dist/codex-app-server/forwarder.d.ts +4 -1
- package/dist/codex-app-server/forwarder.js +19 -1
- package/dist/codex-app-server/protocol.d.ts +45 -1
- package/dist/codex-home.d.ts +9 -26
- package/dist/codex-home.js +37 -65
- package/dist/codex-session-store.d.ts +22 -10
- package/dist/codex-session-store.js +277 -12
- package/dist/host.d.ts +48 -47
- package/dist/host.js +810 -355
- package/dist/index.d.ts +2 -3
- package/dist/index.js +1 -2
- package/dist/models-catalog.d.ts +3 -1
- package/dist/models-catalog.js +125 -4
- package/dist/provider-workspace.d.ts +56 -0
- package/dist/provider-workspace.js +83 -0
- package/dist/runner/child.d.ts +59 -6
- package/dist/runner/child.js +138 -19
- package/dist/runner/manager.d.ts +104 -19
- package/dist/runner/manager.js +922 -89
- package/dist/runner/protocol.d.ts +7 -18
- package/dist/runner-main.js +12 -4
- package/dist/runtime-state-paths.d.ts +10 -0
- package/dist/runtime-state-paths.js +53 -0
- package/dist/terminal/claude-tui.d.ts +10 -1
- package/dist/terminal/claude-tui.js +9 -1
- package/dist/terminal/codex-tui.d.ts +5 -1
- package/dist/terminal/codex-tui.js +12 -3
- package/dist/terminal/tmux.d.ts +11 -1
- package/dist/terminal/tmux.js +61 -12
- package/package.json +3 -3
- package/dist/codex/rollout-synth.d.ts +0 -42
- package/dist/codex/rollout-synth.js +0 -245
package/dist/runner/manager.js
CHANGED
|
@@ -22,6 +22,7 @@ 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";
|
|
26
27
|
import { StdioRunnerTransport } from "./transport.js";
|
|
27
28
|
/** Routing key for the shared capability runner (slash-command RPCs). */
|
|
@@ -36,6 +37,19 @@ const DEFAULT_SHUTDOWN_KILL_GRACE_MS = 5_000;
|
|
|
36
37
|
const DEFAULT_LIVE_START_TIMEOUT_MS = 10_000;
|
|
37
38
|
/** Thread readiness may legitimately wait through Provider startup. */
|
|
38
39
|
const DEFAULT_LIVE_READY_TIMEOUT_MS = 25_000;
|
|
40
|
+
/** A submitted TUI command should become a mirrored turn or native rotation
|
|
41
|
+
* quickly. If it does not, the runner is fenced by a verified process-tree
|
|
42
|
+
* shutdown before maintenance may treat the submission as settled. */
|
|
43
|
+
const DEFAULT_TERMINAL_INPUT_HANDOFF_TIMEOUT_MS = 30_000;
|
|
44
|
+
/** Retry transient terminal-server cleanup without spinning forever. */
|
|
45
|
+
const DEFAULT_TERMINAL_INPUT_CLEANUP_RETRY_MS = 1_000;
|
|
46
|
+
const MAX_TERMINAL_INPUT_CLEANUP_RETRY_MS = 30_000;
|
|
47
|
+
/** A Provider that never closes its active response must not keep a detached
|
|
48
|
+
* runner + tmux server alive forever. This is intentionally much longer than
|
|
49
|
+
* the normal idle TTL so legitimate long-running turns are not treated as
|
|
50
|
+
* stale. User input/control activity refreshes the deadline; Provider output
|
|
51
|
+
* does not, because a runaway redraw loop is the failure mode this bounds. */
|
|
52
|
+
const DEFAULT_STALE_ACTIVE_TTL_MS = 60 * 60_000;
|
|
39
53
|
/** Keep per-Session spawn context small enough to remain an environment handoff,
|
|
40
54
|
* not an unbounded transport. */
|
|
41
55
|
const MAX_SESSION_CONTEXT_ENV_ENTRIES = 32;
|
|
@@ -136,9 +150,12 @@ export class RunnerManager {
|
|
|
136
150
|
sessionStore;
|
|
137
151
|
runnerEntry;
|
|
138
152
|
idleTtlMs;
|
|
153
|
+
staleActiveTtlMs;
|
|
139
154
|
spawn;
|
|
140
155
|
childEnv;
|
|
141
156
|
sessionContextProvider;
|
|
157
|
+
admissionOpen;
|
|
158
|
+
admissionReserve;
|
|
142
159
|
now;
|
|
143
160
|
defaultRuntime;
|
|
144
161
|
handles = new Map();
|
|
@@ -148,8 +165,13 @@ export class RunnerManager {
|
|
|
148
165
|
reapTimer;
|
|
149
166
|
shutdownGraceMs;
|
|
150
167
|
shutdownKillGraceMs;
|
|
168
|
+
signalChild;
|
|
169
|
+
terminateTerminalServer;
|
|
151
170
|
liveStartTimeoutMs;
|
|
152
171
|
liveReadyTimeoutMs;
|
|
172
|
+
liveInterruptTimeoutMs;
|
|
173
|
+
terminalInputHandoffTimeoutMs;
|
|
174
|
+
terminalInputCleanupRetryMs;
|
|
153
175
|
stopping = false;
|
|
154
176
|
stopPromise;
|
|
155
177
|
/** Sink for mirrored {@link SessionEvent}s from every session's forwarder. */
|
|
@@ -160,20 +182,46 @@ export class RunnerManager {
|
|
|
160
182
|
liveSessionKeys = new Set();
|
|
161
183
|
/** Last live-start error per local session, surfaced by the control API. */
|
|
162
184
|
liveErrors = new Map();
|
|
185
|
+
/** Last immutable launch snapshots seen for a Session. Used only to restore a
|
|
186
|
+
* target request that crossed a fork reservation boundary. */
|
|
187
|
+
liveOptions = new Map();
|
|
188
|
+
/** A fork reserves its target before the first asynchronous store read. This
|
|
189
|
+
* prevents another entry point from starting the target against an
|
|
190
|
+
* uncommitted Provider binding. */
|
|
191
|
+
forkReservations = new Map();
|
|
192
|
+
/** A native fork temporarily makes the source read-only so its canonical
|
|
193
|
+
* snapshot and Provider context are captured at the same boundary. */
|
|
194
|
+
sourceForkReservations = new Map();
|
|
195
|
+
/** Manager-wide fork de-duplication. LocalAgentHost only sees one source
|
|
196
|
+
* runner, so the fence must live here to cover concurrent source runners. */
|
|
197
|
+
forkOperations = new Map();
|
|
198
|
+
forkBufferedMessages = new Map();
|
|
199
|
+
forkBufferedTerminalInputs = new Map();
|
|
200
|
+
/** Owner TUI submissions accepted by the parent but not yet represented by a
|
|
201
|
+
* mirrored response or a durably published native rotation. */
|
|
202
|
+
terminalInputHandoffs = new Map();
|
|
163
203
|
constructor(opts) {
|
|
164
204
|
this.config = opts.config;
|
|
165
205
|
this.sessionStore = opts.sessionStore;
|
|
166
206
|
this.runnerEntry = opts.runnerEntry ?? defaultRunnerEntry();
|
|
167
207
|
this.idleTtlMs = opts.idleTtlMs ?? 300_000;
|
|
208
|
+
this.staleActiveTtlMs = Math.max(this.idleTtlMs, opts.staleActiveTtlMs ?? DEFAULT_STALE_ACTIVE_TTL_MS);
|
|
168
209
|
this.spawn = opts.spawn ?? nodeSpawn;
|
|
169
210
|
this.childEnv = opts.childEnv ?? {};
|
|
170
211
|
this.sessionContextProvider = opts.sessionContextProvider;
|
|
212
|
+
this.admissionOpen = opts.admissionOpen ?? (() => true);
|
|
213
|
+
this.admissionReserve = opts.admissionReserve;
|
|
171
214
|
this.now = opts.now ?? (() => Date.now());
|
|
172
|
-
this.defaultRuntime = opts.config.
|
|
215
|
+
this.defaultRuntime = opts.config.DEFAULT_RUNTIME ?? "codex";
|
|
173
216
|
this.shutdownGraceMs = Math.max(0, opts.shutdownGraceMs ?? DEFAULT_SHUTDOWN_GRACE_MS);
|
|
174
217
|
this.shutdownKillGraceMs = Math.max(0, opts.shutdownKillGraceMs ?? DEFAULT_SHUTDOWN_KILL_GRACE_MS);
|
|
218
|
+
this.signalChild = opts.signalChild ?? signalRunnerChild;
|
|
219
|
+
this.terminateTerminalServer = opts.terminateTerminalServer ?? terminateTmuxServer;
|
|
175
220
|
this.liveStartTimeoutMs = Math.max(1, opts.liveStartTimeoutMs ?? DEFAULT_LIVE_START_TIMEOUT_MS);
|
|
176
221
|
this.liveReadyTimeoutMs = Math.max(1, opts.liveReadyTimeoutMs ?? DEFAULT_LIVE_READY_TIMEOUT_MS);
|
|
222
|
+
this.liveInterruptTimeoutMs = Math.max(1, opts.liveInterruptTimeoutMs ?? 10_000);
|
|
223
|
+
this.terminalInputHandoffTimeoutMs = Math.max(1, opts.terminalInputHandoffTimeoutMs ?? DEFAULT_TERMINAL_INPUT_HANDOFF_TIMEOUT_MS);
|
|
224
|
+
this.terminalInputCleanupRetryMs = Math.max(1, opts.terminalInputCleanupRetryMs ?? DEFAULT_TERMINAL_INPUT_CLEANUP_RETRY_MS);
|
|
177
225
|
const reapIntervalMs = opts.reapIntervalMs ?? 60_000;
|
|
178
226
|
if (reapIntervalMs > 0) {
|
|
179
227
|
this.reapTimer = setInterval(() => this.reapIdle(), reapIntervalMs);
|
|
@@ -201,6 +249,10 @@ export class RunnerManager {
|
|
|
201
249
|
* and attach.
|
|
202
250
|
*/
|
|
203
251
|
openLiveTerminal(localThreadId, opts) {
|
|
252
|
+
this.assertAdmissionOpen();
|
|
253
|
+
if (this.forkReservations.has(localThreadId)) {
|
|
254
|
+
throw new TerminalOpenError("Session fork is still being committed", "terminal_not_live");
|
|
255
|
+
}
|
|
204
256
|
const handle = this.handles.get(localThreadId);
|
|
205
257
|
if (!handle || handle.dead || !this.liveSessionKeys.has(localThreadId)) {
|
|
206
258
|
throw new TerminalOpenError("terminal not live", "terminal_not_live");
|
|
@@ -210,7 +262,52 @@ export class RunnerManager {
|
|
|
210
262
|
openTerminalOnHandle(handle, localThreadId, opts) {
|
|
211
263
|
handle.lastUsedAt = this.now();
|
|
212
264
|
const attachId = randomUUID();
|
|
213
|
-
const
|
|
265
|
+
const inputTracker = {
|
|
266
|
+
buffer: "",
|
|
267
|
+
previousWasCarriageReturn: false,
|
|
268
|
+
bracketedPaste: false,
|
|
269
|
+
pendingEscape: "",
|
|
270
|
+
};
|
|
271
|
+
const terminal = new ManagedTerminal(attachId, (msg) => {
|
|
272
|
+
if (msg.t === "term.input")
|
|
273
|
+
handle.lastUsedAt = this.now();
|
|
274
|
+
const handoffs = msg.t === "term.input" && opts.role === "owner"
|
|
275
|
+
? this.beginTerminalInputHandoffs(handle, this.currentTerminalSessionId(handle, localThreadId), trackedTerminalSubmissions(inputTracker, Buffer.from(msg.dataB64, "base64").toString("utf8")))
|
|
276
|
+
: [];
|
|
277
|
+
const send = () => {
|
|
278
|
+
try {
|
|
279
|
+
handle.transport.send(msg);
|
|
280
|
+
}
|
|
281
|
+
catch (error) {
|
|
282
|
+
for (const handoff of handoffs) {
|
|
283
|
+
this.finishTerminalInputHandoff(handle, handoff);
|
|
284
|
+
}
|
|
285
|
+
throw error;
|
|
286
|
+
}
|
|
287
|
+
};
|
|
288
|
+
const sourceReservation = this.sourceForkReservations.get(localThreadId);
|
|
289
|
+
if (msg.t === "term.input" && sourceReservation) {
|
|
290
|
+
void sourceReservation.then(() => {
|
|
291
|
+
if (!handle.dead)
|
|
292
|
+
send();
|
|
293
|
+
}).catch((error) => {
|
|
294
|
+
void this.terminateHandle(handle, `terminal input delivery failed: ${errorMessage(error)}`).catch((terminationError) => {
|
|
295
|
+
logTerminationFailure(handle, terminationError);
|
|
296
|
+
});
|
|
297
|
+
});
|
|
298
|
+
return;
|
|
299
|
+
}
|
|
300
|
+
// A terminal request may race a fork reservation. Never let later
|
|
301
|
+
// keystrokes reach an unpublished Provider target.
|
|
302
|
+
if (msg.t === "term.input") {
|
|
303
|
+
const target = this.reservedForkTarget(handle, localThreadId);
|
|
304
|
+
if (target) {
|
|
305
|
+
this.forkBufferedTerminalInputs.get(target)?.push({ handle, message: msg });
|
|
306
|
+
return;
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
send();
|
|
310
|
+
}, () => handle.terminals.delete(attachId));
|
|
214
311
|
handle.terminals.set(attachId, terminal);
|
|
215
312
|
handle.transport.send({
|
|
216
313
|
t: "term.open",
|
|
@@ -229,6 +326,8 @@ export class RunnerManager {
|
|
|
229
326
|
}
|
|
230
327
|
/** Whether this process already owns a live runner for the Session. Read-only; never spawns. */
|
|
231
328
|
hasLiveSession(localThreadId) {
|
|
329
|
+
if (this.forkReservations.has(localThreadId))
|
|
330
|
+
return false;
|
|
232
331
|
const handle = this.handles.get(localThreadId);
|
|
233
332
|
return Boolean(handle && !handle.dead && this.liveSessionKeys.has(localThreadId));
|
|
234
333
|
}
|
|
@@ -245,6 +344,152 @@ export class RunnerManager {
|
|
|
245
344
|
onRotate(listener) {
|
|
246
345
|
this.rotateListener = listener;
|
|
247
346
|
}
|
|
347
|
+
/** Accepted TUI submissions that have not crossed into an observable runtime
|
|
348
|
+
* state. The daemon folds this into runningTurns after closing admission. */
|
|
349
|
+
pendingTerminalInputCount() {
|
|
350
|
+
let count = 0;
|
|
351
|
+
for (const handoffs of this.terminalInputHandoffs.values()) {
|
|
352
|
+
count += handoffs.length;
|
|
353
|
+
}
|
|
354
|
+
return count;
|
|
355
|
+
}
|
|
356
|
+
beginTerminalInputHandoffs(handle, sessionId, kinds) {
|
|
357
|
+
if (kinds.length === 0)
|
|
358
|
+
return [];
|
|
359
|
+
const created = [];
|
|
360
|
+
try {
|
|
361
|
+
for (const kind of kinds) {
|
|
362
|
+
const handoff = {
|
|
363
|
+
sessionId,
|
|
364
|
+
kind,
|
|
365
|
+
reservation: this.reserveAdmission(),
|
|
366
|
+
timer: undefined,
|
|
367
|
+
};
|
|
368
|
+
handoff.timer = setTimeout(() => {
|
|
369
|
+
this.expireTerminalInputHandoffs(handle);
|
|
370
|
+
}, this.terminalInputHandoffTimeoutMs);
|
|
371
|
+
handoff.timer.unref?.();
|
|
372
|
+
const handoffs = this.terminalInputHandoffs.get(handle);
|
|
373
|
+
if (handoffs)
|
|
374
|
+
handoffs.push(handoff);
|
|
375
|
+
else
|
|
376
|
+
this.terminalInputHandoffs.set(handle, [handoff]);
|
|
377
|
+
created.push(handoff);
|
|
378
|
+
}
|
|
379
|
+
return created;
|
|
380
|
+
}
|
|
381
|
+
catch (error) {
|
|
382
|
+
for (const handoff of created) {
|
|
383
|
+
this.finishTerminalInputHandoff(handle, handoff);
|
|
384
|
+
}
|
|
385
|
+
throw error;
|
|
386
|
+
}
|
|
387
|
+
}
|
|
388
|
+
settleTerminalInputHandoff(handle, sessionId, kind) {
|
|
389
|
+
const handoff = this.terminalInputHandoffs
|
|
390
|
+
.get(handle)
|
|
391
|
+
?.find((candidate) => candidate.sessionId === sessionId);
|
|
392
|
+
// Runtime observations settle accepted input in submission order. In
|
|
393
|
+
// particular, a response from the source Session while /clear or /fork is
|
|
394
|
+
// still publishing must not skip that rotation and release a later turn
|
|
395
|
+
// which has not yet been rebound or delivered.
|
|
396
|
+
if (handoff?.kind === kind)
|
|
397
|
+
this.finishTerminalInputHandoff(handle, handoff);
|
|
398
|
+
}
|
|
399
|
+
settleTerminalRotationHandoff(handle, sourceSessionId, targetSessionId) {
|
|
400
|
+
const handoffs = this.terminalInputHandoffs.get(handle);
|
|
401
|
+
const rotationIndex = handoffs?.findIndex((handoff) => handoff.sessionId === sourceSessionId && handoff.kind === "rotation") ?? -1;
|
|
402
|
+
if (!handoffs || rotationIndex < 0)
|
|
403
|
+
return;
|
|
404
|
+
// Turns submitted before /clear or /fork are superseded once the rotation
|
|
405
|
+
// is published. Inputs accepted afterwards belong to the transferred pane
|
|
406
|
+
// and must follow it to the target Session.
|
|
407
|
+
const rotation = handoffs[rotationIndex];
|
|
408
|
+
for (const handoff of [...handoffs.slice(0, rotationIndex)]) {
|
|
409
|
+
if (handoff.sessionId === sourceSessionId && handoff.kind === "turn") {
|
|
410
|
+
this.finishTerminalInputHandoff(handle, handoff);
|
|
411
|
+
}
|
|
412
|
+
}
|
|
413
|
+
if (rotation)
|
|
414
|
+
this.finishTerminalInputHandoff(handle, rotation);
|
|
415
|
+
for (const handoff of this.terminalInputHandoffs.get(handle) ?? []) {
|
|
416
|
+
if (handoff.sessionId === sourceSessionId)
|
|
417
|
+
handoff.sessionId = targetSessionId;
|
|
418
|
+
}
|
|
419
|
+
}
|
|
420
|
+
currentTerminalSessionId(handle, fallback) {
|
|
421
|
+
for (const [sessionId, candidate] of this.handles) {
|
|
422
|
+
if (candidate === handle && this.liveSessionKeys.has(sessionId))
|
|
423
|
+
return sessionId;
|
|
424
|
+
}
|
|
425
|
+
return fallback;
|
|
426
|
+
}
|
|
427
|
+
finishTerminalInputHandoff(handle, handoff) {
|
|
428
|
+
if (handoff.timer) {
|
|
429
|
+
clearTimeout(handoff.timer);
|
|
430
|
+
handoff.timer = undefined;
|
|
431
|
+
}
|
|
432
|
+
handoff.reservation?.release();
|
|
433
|
+
handoff.reservation = undefined;
|
|
434
|
+
const handoffs = this.terminalInputHandoffs.get(handle);
|
|
435
|
+
if (!handoffs)
|
|
436
|
+
return;
|
|
437
|
+
const index = handoffs.indexOf(handoff);
|
|
438
|
+
if (index >= 0)
|
|
439
|
+
handoffs.splice(index, 1);
|
|
440
|
+
if (handoffs.length === 0)
|
|
441
|
+
this.terminalInputHandoffs.delete(handle);
|
|
442
|
+
}
|
|
443
|
+
finishAllTerminalInputHandoffs(handle) {
|
|
444
|
+
if (handle.terminalCleanupRetryTimer) {
|
|
445
|
+
clearTimeout(handle.terminalCleanupRetryTimer);
|
|
446
|
+
delete handle.terminalCleanupRetryTimer;
|
|
447
|
+
}
|
|
448
|
+
handle.terminalCleanupRetryFailures = 0;
|
|
449
|
+
for (const handoff of [...(this.terminalInputHandoffs.get(handle) ?? [])]) {
|
|
450
|
+
this.finishTerminalInputHandoff(handle, handoff);
|
|
451
|
+
}
|
|
452
|
+
}
|
|
453
|
+
preserveTerminalInputHandoffsAsActivity(handle) {
|
|
454
|
+
for (const handoff of this.terminalInputHandoffs.get(handle) ?? []) {
|
|
455
|
+
if (handoff.timer) {
|
|
456
|
+
clearTimeout(handoff.timer);
|
|
457
|
+
handoff.timer = undefined;
|
|
458
|
+
}
|
|
459
|
+
handoff.reservation?.release();
|
|
460
|
+
handoff.reservation = undefined;
|
|
461
|
+
}
|
|
462
|
+
}
|
|
463
|
+
scheduleTerminalInputCleanupRetry(handle) {
|
|
464
|
+
if (handle.terminalCleanupRetryTimer ||
|
|
465
|
+
!this.terminalInputHandoffs.has(handle))
|
|
466
|
+
return;
|
|
467
|
+
const exponent = Math.min(handle.terminalCleanupRetryFailures - 1, 10);
|
|
468
|
+
const delay = Math.min(this.terminalInputCleanupRetryMs * (2 ** Math.max(0, exponent)), MAX_TERMINAL_INPUT_CLEANUP_RETRY_MS);
|
|
469
|
+
handle.terminalCleanupRetryTimer = setTimeout(() => {
|
|
470
|
+
delete handle.terminalCleanupRetryTimer;
|
|
471
|
+
if (!this.terminalInputHandoffs.has(handle))
|
|
472
|
+
return;
|
|
473
|
+
void this.terminateHandle(handle, "retrying unproven terminal input cleanup").catch((error) => {
|
|
474
|
+
logTerminationFailure(handle, error);
|
|
475
|
+
});
|
|
476
|
+
}, delay);
|
|
477
|
+
handle.terminalCleanupRetryTimer.unref?.();
|
|
478
|
+
}
|
|
479
|
+
expireTerminalInputHandoffs(handle) {
|
|
480
|
+
const handoffs = this.terminalInputHandoffs.get(handle);
|
|
481
|
+
if (!handoffs?.length)
|
|
482
|
+
return;
|
|
483
|
+
for (const handoff of handoffs) {
|
|
484
|
+
if (handoff.timer) {
|
|
485
|
+
clearTimeout(handoff.timer);
|
|
486
|
+
handoff.timer = undefined;
|
|
487
|
+
}
|
|
488
|
+
}
|
|
489
|
+
void this.terminateHandle(handle, "accepted terminal input did not become observable before maintenance timeout").catch((error) => {
|
|
490
|
+
logTerminationFailure(handle, error);
|
|
491
|
+
});
|
|
492
|
+
}
|
|
248
493
|
/**
|
|
249
494
|
* Eagerly bring up a session's codex-native live view (persistent forwarder +
|
|
250
495
|
* detached `codex --remote` TUI) in its runner child, spawning the runner if
|
|
@@ -252,16 +497,38 @@ export class RunnerManager {
|
|
|
252
497
|
* non-codex / non-live session (the caller then uses the normal run path).
|
|
253
498
|
*/
|
|
254
499
|
ensureLiveSession(localThreadId, opts) {
|
|
255
|
-
|
|
500
|
+
const admission = this.reserveAdmission();
|
|
501
|
+
return this.requestLiveSession(localThreadId, opts, true)
|
|
502
|
+
.finally(() => admission.release());
|
|
256
503
|
}
|
|
257
504
|
/** Start the Provider pane without waiting for login/onboarding to create a
|
|
258
505
|
* native thread. This is the setup-terminal gate; callers may attach as soon
|
|
259
506
|
* as it resolves, while normal message delivery still uses ensureLiveSession. */
|
|
260
507
|
startLiveSession(localThreadId, opts) {
|
|
261
|
-
|
|
508
|
+
const admission = this.reserveAdmission();
|
|
509
|
+
return this.requestLiveSession(localThreadId, opts, false)
|
|
510
|
+
.finally(() => admission.release());
|
|
262
511
|
}
|
|
263
|
-
requestLiveSession(localThreadId, opts, waitForReady) {
|
|
264
|
-
const
|
|
512
|
+
requestLiveSession(localThreadId, opts, waitForReady, allowReservedForkTarget = false) {
|
|
513
|
+
const sourceReservation = allowReservedForkTarget
|
|
514
|
+
? undefined
|
|
515
|
+
: this.sourceForkReservations.get(localThreadId);
|
|
516
|
+
if (sourceReservation) {
|
|
517
|
+
return sourceReservation.then(() => this.requestLiveSession(localThreadId, opts, waitForReady));
|
|
518
|
+
}
|
|
519
|
+
this.liveOptions.set(localThreadId, {
|
|
520
|
+
workspace: structuredClone(opts.workspace),
|
|
521
|
+
execution: structuredClone(opts.execution),
|
|
522
|
+
...(opts.cols ? { cols: opts.cols } : {}),
|
|
523
|
+
...(opts.rows ? { rows: opts.rows } : {}),
|
|
524
|
+
});
|
|
525
|
+
const reservation = allowReservedForkTarget
|
|
526
|
+
? undefined
|
|
527
|
+
: this.forkReservations.get(localThreadId);
|
|
528
|
+
if (reservation) {
|
|
529
|
+
return reservation.then(() => this.requestLiveSession(localThreadId, opts, waitForReady));
|
|
530
|
+
}
|
|
531
|
+
const handle = this.getOrSpawn(localThreadId, allowReservedForkTarget);
|
|
265
532
|
handle.lastUsedAt = this.now();
|
|
266
533
|
this.liveSessionKeys.add(localThreadId);
|
|
267
534
|
const reqId = randomUUID();
|
|
@@ -270,39 +537,60 @@ export class RunnerManager {
|
|
|
270
537
|
const timeout = setTimeout(() => {
|
|
271
538
|
if (!handle.live.delete(reqId))
|
|
272
539
|
return;
|
|
273
|
-
const
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
540
|
+
const finishTimeout = () => {
|
|
541
|
+
const reason = `runner did not acknowledge ${waitForReady ? "live readiness" : "terminal start"} within ${timeoutMs}ms`;
|
|
542
|
+
this.liveErrors.set(localThreadId, reason);
|
|
543
|
+
this.liveSessionKeys.delete(localThreadId);
|
|
544
|
+
// A child that cannot answer a bounded control round-trip is unsafe
|
|
545
|
+
// to reuse. Reap it so the next click gets a fresh runner.
|
|
546
|
+
void this.terminateHandle(handle, reason).catch((error) => {
|
|
547
|
+
logTerminationFailure(handle, error);
|
|
548
|
+
});
|
|
549
|
+
return false;
|
|
550
|
+
};
|
|
551
|
+
const reservation = allowReservedForkTarget
|
|
552
|
+
? undefined
|
|
553
|
+
: this.forkReservations.get(localThreadId);
|
|
554
|
+
if (reservation) {
|
|
555
|
+
void reservation
|
|
556
|
+
.then(() => finishTimeout())
|
|
557
|
+
.then(resolve, () => resolve(false));
|
|
558
|
+
return;
|
|
559
|
+
}
|
|
560
|
+
resolve(finishTimeout());
|
|
282
561
|
}, timeoutMs);
|
|
283
562
|
timeout.unref?.();
|
|
284
563
|
handle.live.set(reqId, (res) => {
|
|
285
564
|
clearTimeout(timeout);
|
|
286
565
|
const ok = res.ok ?? false;
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
566
|
+
const finish = () => {
|
|
567
|
+
if (ok) {
|
|
568
|
+
this.liveErrors.delete(localThreadId);
|
|
569
|
+
}
|
|
570
|
+
else {
|
|
571
|
+
this.liveErrors.set(localThreadId, res.error ?? "live session did not become ready");
|
|
572
|
+
}
|
|
573
|
+
return ok;
|
|
574
|
+
};
|
|
575
|
+
const reservation = allowReservedForkTarget
|
|
576
|
+
? undefined
|
|
577
|
+
: this.forkReservations.get(localThreadId);
|
|
578
|
+
if (reservation) {
|
|
579
|
+
void reservation
|
|
580
|
+
.then(() => finish())
|
|
581
|
+
.then(resolve, () => resolve(false));
|
|
582
|
+
return;
|
|
292
583
|
}
|
|
293
|
-
resolve(
|
|
584
|
+
resolve(finish());
|
|
294
585
|
});
|
|
295
586
|
handle.transport.send({
|
|
296
587
|
t: "live.ensure",
|
|
297
588
|
reqId,
|
|
298
589
|
localThreadId,
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
...(opts
|
|
302
|
-
...(opts
|
|
303
|
-
...(opts?.reasoningEffort ? { reasoningEffort: opts.reasoningEffort } : {}),
|
|
304
|
-
...(opts?.agentName ? { agentName: opts.agentName } : {}),
|
|
305
|
-
...(opts?.agentSpec ? { agentSpec: opts.agentSpec } : {}),
|
|
590
|
+
workspace: opts.workspace,
|
|
591
|
+
execution: opts.execution,
|
|
592
|
+
...(opts.cols ? { cols: opts.cols } : {}),
|
|
593
|
+
...(opts.rows ? { rows: opts.rows } : {}),
|
|
306
594
|
waitForReady,
|
|
307
595
|
});
|
|
308
596
|
});
|
|
@@ -317,11 +605,43 @@ export class RunnerManager {
|
|
|
317
605
|
* the session has no live forwarder (caller falls back to the run path).
|
|
318
606
|
*/
|
|
319
607
|
injectMessage(localThreadId, input) {
|
|
608
|
+
const admission = this.reserveAdmission();
|
|
609
|
+
return this.injectMessageAdmitted(localThreadId, input)
|
|
610
|
+
.finally(() => admission.release());
|
|
611
|
+
}
|
|
612
|
+
injectMessageAdmitted(localThreadId, input) {
|
|
613
|
+
const sourceReservation = this.sourceForkReservations.get(localThreadId);
|
|
614
|
+
if (sourceReservation) {
|
|
615
|
+
return sourceReservation.then(() => this.injectMessageAdmitted(localThreadId, input));
|
|
616
|
+
}
|
|
617
|
+
const reservation = this.forkReservations.get(localThreadId);
|
|
618
|
+
if (reservation) {
|
|
619
|
+
return reservation.then(() => this.injectMessageAdmitted(localThreadId, input));
|
|
620
|
+
}
|
|
320
621
|
const handle = this.getOrSpawn(localThreadId);
|
|
321
622
|
handle.lastUsedAt = this.now();
|
|
322
623
|
const reqId = randomUUID();
|
|
323
624
|
return new Promise((resolve) => {
|
|
324
|
-
handle.live.set(reqId, (res) =>
|
|
625
|
+
handle.live.set(reqId, (res) => {
|
|
626
|
+
const outcome = res.outcome ?? "failed";
|
|
627
|
+
const finish = () => {
|
|
628
|
+
if (outcome === "injected") {
|
|
629
|
+
this.liveErrors.delete(localThreadId);
|
|
630
|
+
}
|
|
631
|
+
else {
|
|
632
|
+
this.liveErrors.set(localThreadId, res.error ?? `live injection ${outcome}`);
|
|
633
|
+
}
|
|
634
|
+
return outcome;
|
|
635
|
+
};
|
|
636
|
+
const reservation = this.forkReservations.get(localThreadId);
|
|
637
|
+
if (reservation) {
|
|
638
|
+
void reservation
|
|
639
|
+
.then(() => finish())
|
|
640
|
+
.then(resolve, () => resolve("failed"));
|
|
641
|
+
return;
|
|
642
|
+
}
|
|
643
|
+
resolve(finish());
|
|
644
|
+
});
|
|
325
645
|
handle.transport.send(typeof input === "string"
|
|
326
646
|
? { t: "inject", reqId, localThreadId, text: input }
|
|
327
647
|
: { t: "inject", reqId, localThreadId, input });
|
|
@@ -339,7 +659,16 @@ export class RunnerManager {
|
|
|
339
659
|
handle.lastUsedAt = this.now();
|
|
340
660
|
const reqId = randomUUID();
|
|
341
661
|
return new Promise((resolve) => {
|
|
342
|
-
|
|
662
|
+
const timeout = setTimeout(() => {
|
|
663
|
+
if (!handle.live.delete(reqId))
|
|
664
|
+
return;
|
|
665
|
+
resolve(false);
|
|
666
|
+
}, this.liveInterruptTimeoutMs);
|
|
667
|
+
timeout.unref?.();
|
|
668
|
+
handle.live.set(reqId, (res) => {
|
|
669
|
+
clearTimeout(timeout);
|
|
670
|
+
resolve(res.ok ?? false);
|
|
671
|
+
});
|
|
343
672
|
handle.transport.send({ t: "live.interrupt", reqId, localThreadId });
|
|
344
673
|
});
|
|
345
674
|
}
|
|
@@ -382,9 +711,83 @@ export class RunnerManager {
|
|
|
382
711
|
clearGoal(localThreadId) {
|
|
383
712
|
return this.forwardCap("clearGoal", [localThreadId], localThreadId);
|
|
384
713
|
}
|
|
385
|
-
forkSession(currentLocalThreadId, newLocalThreadId) {
|
|
386
|
-
|
|
387
|
-
|
|
714
|
+
forkSession(currentLocalThreadId, newLocalThreadId, options) {
|
|
715
|
+
this.assertAdmissionOpen();
|
|
716
|
+
if (currentLocalThreadId === newLocalThreadId) {
|
|
717
|
+
return Promise.resolve({
|
|
718
|
+
ok: false,
|
|
719
|
+
message: "source and target Session must differ",
|
|
720
|
+
});
|
|
721
|
+
}
|
|
722
|
+
const inflight = this.forkOperations.get(newLocalThreadId);
|
|
723
|
+
if (inflight) {
|
|
724
|
+
if (inflight.sourceSessionId === currentLocalThreadId) {
|
|
725
|
+
return inflight.operation.then(capabilityForkResult);
|
|
726
|
+
}
|
|
727
|
+
return Promise.resolve({
|
|
728
|
+
ok: false,
|
|
729
|
+
message: "target Session already has a fork in progress",
|
|
730
|
+
});
|
|
731
|
+
}
|
|
732
|
+
if (this.sourceForkReservations.has(currentLocalThreadId)) {
|
|
733
|
+
return Promise.resolve({
|
|
734
|
+
ok: false,
|
|
735
|
+
message: "source Session already has a fork in progress",
|
|
736
|
+
});
|
|
737
|
+
}
|
|
738
|
+
let releaseReservation;
|
|
739
|
+
const reservation = new Promise((resolve) => {
|
|
740
|
+
releaseReservation = resolve;
|
|
741
|
+
});
|
|
742
|
+
let releaseSource;
|
|
743
|
+
const sourceReservation = new Promise((resolve) => {
|
|
744
|
+
releaseSource = resolve;
|
|
745
|
+
});
|
|
746
|
+
this.forkReservations.set(newLocalThreadId, reservation);
|
|
747
|
+
this.sourceForkReservations.set(currentLocalThreadId, sourceReservation);
|
|
748
|
+
this.forkBufferedMessages.set(newLocalThreadId, []);
|
|
749
|
+
this.forkBufferedTerminalInputs.set(newLocalThreadId, []);
|
|
750
|
+
const retainedLiveOptions = this.liveOptions.get(newLocalThreadId);
|
|
751
|
+
const operation = (async () => {
|
|
752
|
+
await options.beforeProviderFork();
|
|
753
|
+
return this.performManagedFork(currentLocalThreadId, newLocalThreadId, options);
|
|
754
|
+
})()
|
|
755
|
+
.finally(() => {
|
|
756
|
+
const current = this.forkOperations.get(newLocalThreadId);
|
|
757
|
+
if (current?.operation === operation) {
|
|
758
|
+
this.forkOperations.delete(newLocalThreadId);
|
|
759
|
+
}
|
|
760
|
+
if (this.forkReservations.get(newLocalThreadId) === reservation) {
|
|
761
|
+
this.forkReservations.delete(newLocalThreadId);
|
|
762
|
+
}
|
|
763
|
+
if (this.sourceForkReservations.get(currentLocalThreadId) === sourceReservation) {
|
|
764
|
+
this.sourceForkReservations.delete(currentLocalThreadId);
|
|
765
|
+
}
|
|
766
|
+
const buffered = this.forkBufferedMessages.get(newLocalThreadId) ?? [];
|
|
767
|
+
this.forkBufferedMessages.delete(newLocalThreadId);
|
|
768
|
+
const bufferedTerminalInputs = this.forkBufferedTerminalInputs.get(newLocalThreadId) ?? [];
|
|
769
|
+
this.forkBufferedTerminalInputs.delete(newLocalThreadId);
|
|
770
|
+
for (const entry of buffered) {
|
|
771
|
+
this.deliverForkBufferedMessage(entry.handle, entry.message);
|
|
772
|
+
}
|
|
773
|
+
for (const entry of bufferedTerminalInputs) {
|
|
774
|
+
if (!entry.handle.dead)
|
|
775
|
+
entry.handle.transport.send(entry.message);
|
|
776
|
+
}
|
|
777
|
+
releaseReservation();
|
|
778
|
+
releaseSource();
|
|
779
|
+
queueMicrotask(() => {
|
|
780
|
+
if (this.liveOptions.get(newLocalThreadId) === retainedLiveOptions
|
|
781
|
+
&& !this.handles.has(newLocalThreadId)) {
|
|
782
|
+
this.liveOptions.delete(newLocalThreadId);
|
|
783
|
+
}
|
|
784
|
+
});
|
|
785
|
+
});
|
|
786
|
+
this.forkOperations.set(newLocalThreadId, {
|
|
787
|
+
sourceSessionId: currentLocalThreadId,
|
|
788
|
+
operation,
|
|
789
|
+
});
|
|
790
|
+
return operation.then(capabilityForkResult);
|
|
388
791
|
}
|
|
389
792
|
/**
|
|
390
793
|
* Backend-free runtime readiness (not part of `AgentCapabilities`; surfaced for
|
|
@@ -395,7 +798,7 @@ export class RunnerManager {
|
|
|
395
798
|
}
|
|
396
799
|
/** Stop the runner bound to one session (if any), rejecting its in-flight work. */
|
|
397
800
|
stopRunner(localThreadId) {
|
|
398
|
-
const handle = this.
|
|
801
|
+
const handle = this.handleForCleanup(localThreadId);
|
|
399
802
|
if (!handle) {
|
|
400
803
|
return;
|
|
401
804
|
}
|
|
@@ -403,6 +806,15 @@ export class RunnerManager {
|
|
|
403
806
|
logTerminationFailure(handle, error);
|
|
404
807
|
});
|
|
405
808
|
}
|
|
809
|
+
/** Force-stop and join the runner bound to one Session. Unlike stopRunner,
|
|
810
|
+
* completion proves that the child process has exited. */
|
|
811
|
+
async terminateLiveSession(localThreadId) {
|
|
812
|
+
const handle = this.handleForCleanup(localThreadId);
|
|
813
|
+
if (!handle)
|
|
814
|
+
return false;
|
|
815
|
+
await this.terminateHandle(handle, "runner force-stopped");
|
|
816
|
+
return true;
|
|
817
|
+
}
|
|
406
818
|
/** Stop every runner and join all child exits. Idempotent across concurrent calls. */
|
|
407
819
|
stop() {
|
|
408
820
|
if (this.stopPromise)
|
|
@@ -411,12 +823,16 @@ export class RunnerManager {
|
|
|
411
823
|
if (this.reapTimer) {
|
|
412
824
|
clearInterval(this.reapTimer);
|
|
413
825
|
}
|
|
414
|
-
const children = [
|
|
415
|
-
|
|
416
|
-
|
|
826
|
+
const children = new Set([
|
|
827
|
+
...this.childHandles,
|
|
828
|
+
...this.terminalInputHandoffs.keys(),
|
|
829
|
+
]);
|
|
830
|
+
const attempt = (async () => {
|
|
831
|
+
const results = await Promise.allSettled([...children].map((handle) => this.terminateHandle(handle, "runner manager stopped")));
|
|
417
832
|
this.handles.clear();
|
|
418
833
|
this.liveSessionKeys.clear();
|
|
419
834
|
this.liveErrors.clear();
|
|
835
|
+
this.liveOptions.clear();
|
|
420
836
|
this.mirrorListener = null;
|
|
421
837
|
this.rotateListener = null;
|
|
422
838
|
const errors = results
|
|
@@ -427,9 +843,19 @@ export class RunnerManager {
|
|
|
427
843
|
}
|
|
428
844
|
this.childHandles.clear();
|
|
429
845
|
})();
|
|
430
|
-
|
|
846
|
+
this.stopPromise = attempt;
|
|
847
|
+
void attempt.catch(() => {
|
|
848
|
+
if (this.stopPromise === attempt)
|
|
849
|
+
this.stopPromise = undefined;
|
|
850
|
+
});
|
|
851
|
+
return attempt;
|
|
431
852
|
}
|
|
432
853
|
// ── internals ──────────────────────────────────────────────────────────────
|
|
854
|
+
handleForCleanup(localThreadId) {
|
|
855
|
+
return this.handles.get(localThreadId) ??
|
|
856
|
+
[...this.terminalInputHandoffs].find(([handle, handoffs]) => handle.key === localThreadId ||
|
|
857
|
+
handoffs.some((handoff) => handoff.sessionId === localThreadId))?.[0];
|
|
858
|
+
}
|
|
433
859
|
/** Forward a capability to a runner child. Session-less caps (listModels/status)
|
|
434
860
|
* use the shared `CAP_KEY` child; per-thread caps pass the session's key so they
|
|
435
861
|
* run on that session's child (which owns its private CODEX_HOME). */
|
|
@@ -442,19 +868,257 @@ export class RunnerManager {
|
|
|
442
868
|
handle.transport.send({ t: "cap", capId, name, args });
|
|
443
869
|
});
|
|
444
870
|
}
|
|
445
|
-
getOrSpawn(key) {
|
|
446
|
-
|
|
447
|
-
|
|
871
|
+
getOrSpawn(key, allowReservedForkTarget = false) {
|
|
872
|
+
const admission = this.reserveAdmission();
|
|
873
|
+
try {
|
|
874
|
+
if (this.stopping) {
|
|
875
|
+
throw new AgentRuntimeError("runner manager is stopped", 503, "runner_stopped");
|
|
876
|
+
}
|
|
877
|
+
if (key !== CAP_KEY &&
|
|
878
|
+
this.forkReservations.has(key) &&
|
|
879
|
+
!allowReservedForkTarget) {
|
|
880
|
+
throw new AgentRuntimeError("Session fork is still being committed", 409, "session_fork_pending");
|
|
881
|
+
}
|
|
882
|
+
this.reapIdle();
|
|
883
|
+
const existing = this.handles.get(key);
|
|
884
|
+
if (existing && !existing.dead) {
|
|
885
|
+
existing.lastUsedAt = this.now();
|
|
886
|
+
return existing;
|
|
887
|
+
}
|
|
888
|
+
const handle = this.spawnHandle(key);
|
|
889
|
+
this.handles.set(key, handle);
|
|
890
|
+
return handle;
|
|
448
891
|
}
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
if (existing && !existing.dead) {
|
|
452
|
-
existing.lastUsedAt = this.now();
|
|
453
|
-
return existing;
|
|
892
|
+
finally {
|
|
893
|
+
admission.release();
|
|
454
894
|
}
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
895
|
+
}
|
|
896
|
+
assertAdmissionOpen() {
|
|
897
|
+
const reservation = this.admissionReserve?.();
|
|
898
|
+
if (reservation) {
|
|
899
|
+
reservation.release();
|
|
900
|
+
return;
|
|
901
|
+
}
|
|
902
|
+
if (!this.admissionReserve && this.admissionOpen())
|
|
903
|
+
return;
|
|
904
|
+
throw new AgentRuntimeError("daemon maintenance is in progress", 503, "daemon_maintenance");
|
|
905
|
+
}
|
|
906
|
+
reserveAdmission() {
|
|
907
|
+
const reservation = this.admissionReserve?.();
|
|
908
|
+
if (reservation)
|
|
909
|
+
return reservation;
|
|
910
|
+
if (!this.admissionReserve && this.admissionOpen()) {
|
|
911
|
+
return { release() { } };
|
|
912
|
+
}
|
|
913
|
+
throw new AgentRuntimeError("daemon maintenance is in progress", 503, "daemon_maintenance");
|
|
914
|
+
}
|
|
915
|
+
async performManagedFork(currentLocalThreadId, newLocalThreadId, options) {
|
|
916
|
+
const existingTarget = await this.sessionStore.get(newLocalThreadId);
|
|
917
|
+
if (existingTarget?.parentSessionId === currentLocalThreadId) {
|
|
918
|
+
return { ok: true, data: undefined };
|
|
919
|
+
}
|
|
920
|
+
if (existingTarget?.parentSessionId) {
|
|
921
|
+
return {
|
|
922
|
+
ok: false,
|
|
923
|
+
reason: "error",
|
|
924
|
+
message: "target Session already belongs to a different fork",
|
|
925
|
+
};
|
|
926
|
+
}
|
|
927
|
+
const targetHandle = this.handles.get(newLocalThreadId);
|
|
928
|
+
if (existingTarget) {
|
|
929
|
+
return {
|
|
930
|
+
ok: false,
|
|
931
|
+
reason: "error",
|
|
932
|
+
message: "target Session already has a Provider binding",
|
|
933
|
+
};
|
|
934
|
+
}
|
|
935
|
+
if (targetHandle && !targetHandle.dead) {
|
|
936
|
+
return {
|
|
937
|
+
ok: false,
|
|
938
|
+
reason: "error",
|
|
939
|
+
message: "target Session already has a runner",
|
|
940
|
+
};
|
|
941
|
+
}
|
|
942
|
+
if (options.execution.provider === "claude") {
|
|
943
|
+
return this.performClaudeManagedFork(currentLocalThreadId, newLocalThreadId, options);
|
|
944
|
+
}
|
|
945
|
+
// Runs on the SOURCE session's child (its home has the source rollout to
|
|
946
|
+
// fork). The Provider persists the native fork and target binding before
|
|
947
|
+
// returning. Only then is the target reservation released.
|
|
948
|
+
return this.forwardCap("forkSession", [
|
|
949
|
+
currentLocalThreadId,
|
|
950
|
+
newLocalThreadId,
|
|
951
|
+
{
|
|
952
|
+
workspace: structuredClone(options.workspace),
|
|
953
|
+
execution: structuredClone(options.execution),
|
|
954
|
+
},
|
|
955
|
+
], currentLocalThreadId);
|
|
956
|
+
}
|
|
957
|
+
async performClaudeManagedFork(currentLocalThreadId, newLocalThreadId, options) {
|
|
958
|
+
const source = await this.sessionStore.get(currentLocalThreadId);
|
|
959
|
+
const setIntent = this.sessionStore.setClaudeForkIntent?.bind(this.sessionStore);
|
|
960
|
+
const deleteIntent = this.sessionStore.deleteClaudeForkIntent?.bind(this.sessionStore);
|
|
961
|
+
if (!source?.codexSessionId || !setIntent || !deleteIntent) {
|
|
962
|
+
return {
|
|
963
|
+
ok: false,
|
|
964
|
+
reason: "unsupported",
|
|
965
|
+
message: "Claude fork persistence is unavailable",
|
|
966
|
+
};
|
|
967
|
+
}
|
|
968
|
+
const targetClaudeSessionId = randomUUID();
|
|
969
|
+
try {
|
|
970
|
+
await setIntent({
|
|
971
|
+
targetSessionId: newLocalThreadId,
|
|
972
|
+
sourceSessionId: currentLocalThreadId,
|
|
973
|
+
sourceClaudeSessionId: source.codexSessionId,
|
|
974
|
+
targetClaudeSessionId,
|
|
975
|
+
updatedAt: new Date().toISOString(),
|
|
976
|
+
});
|
|
977
|
+
const ready = await this.requestLiveSession(newLocalThreadId, {
|
|
978
|
+
workspace: structuredClone(options.workspace),
|
|
979
|
+
execution: structuredClone(options.execution),
|
|
980
|
+
}, true, true);
|
|
981
|
+
const target = ready ? await this.sessionStore.get(newLocalThreadId) : null;
|
|
982
|
+
if (!target ||
|
|
983
|
+
target.parentSessionId !== currentLocalThreadId ||
|
|
984
|
+
target.codexSessionId !== targetClaudeSessionId) {
|
|
985
|
+
throw new Error(this.lastLiveSessionError(newLocalThreadId) ??
|
|
986
|
+
"Claude did not materialize the requested fork");
|
|
987
|
+
}
|
|
988
|
+
return { ok: true, data: undefined };
|
|
989
|
+
}
|
|
990
|
+
catch (error) {
|
|
991
|
+
await deleteIntent(newLocalThreadId).catch(() => undefined);
|
|
992
|
+
const target = await this.sessionStore.get(newLocalThreadId).catch(() => null);
|
|
993
|
+
if (target?.parentSessionId === currentLocalThreadId) {
|
|
994
|
+
await this.sessionStore.delete(newLocalThreadId).catch(() => undefined);
|
|
995
|
+
}
|
|
996
|
+
const handle = this.handles.get(newLocalThreadId);
|
|
997
|
+
if (handle && !handle.dead) {
|
|
998
|
+
await this.terminateHandle(handle, "Claude fork materialization failed").catch(() => undefined);
|
|
999
|
+
}
|
|
1000
|
+
return {
|
|
1001
|
+
ok: false,
|
|
1002
|
+
reason: "error",
|
|
1003
|
+
message: error instanceof Error ? error.message : String(error),
|
|
1004
|
+
};
|
|
1005
|
+
}
|
|
1006
|
+
}
|
|
1007
|
+
bufferForkTargetMessage(handle, message) {
|
|
1008
|
+
const directTarget = message.t === "mirror" ? message.sessionId : message.from;
|
|
1009
|
+
const target = this.reservedForkTarget(handle, directTarget);
|
|
1010
|
+
if (!target)
|
|
1011
|
+
return false;
|
|
1012
|
+
this.forkBufferedMessages.get(target)?.push({ handle, message });
|
|
1013
|
+
return true;
|
|
1014
|
+
}
|
|
1015
|
+
reservedForkTarget(handle, directTarget) {
|
|
1016
|
+
return this.forkReservations.has(directTarget)
|
|
1017
|
+
? directTarget
|
|
1018
|
+
: [...this.forkReservations.keys()].find((candidate) => this.handles.get(candidate) === handle);
|
|
1019
|
+
}
|
|
1020
|
+
deliverForkBufferedMessage(handle, message) {
|
|
1021
|
+
if (handle.dead)
|
|
1022
|
+
return;
|
|
1023
|
+
if (message.t === "mirror") {
|
|
1024
|
+
this.observeHandleRuntimeEvent(handle, message.event);
|
|
1025
|
+
this.mirrorListener?.(message.sessionId, message.event);
|
|
1026
|
+
if (message.event.type === "response.created") {
|
|
1027
|
+
this.settleTerminalInputHandoff(handle, message.sessionId, "turn");
|
|
1028
|
+
}
|
|
1029
|
+
return;
|
|
1030
|
+
}
|
|
1031
|
+
this.deliverRotateMessage(handle, message);
|
|
1032
|
+
}
|
|
1033
|
+
deliverRotateMessage(handle, message) {
|
|
1034
|
+
if (this.forkReservations.has(message.to) ||
|
|
1035
|
+
this.sourceForkReservations.has(message.from)) {
|
|
1036
|
+
void this.terminateHandle(handle, "conflicting Session rotation").catch((error) => logTerminationFailure(handle, error));
|
|
1037
|
+
return;
|
|
1038
|
+
}
|
|
1039
|
+
try {
|
|
1040
|
+
// Rotate the runtime-local context before publishing the new Session
|
|
1041
|
+
// alias to routing or observers. This prevents the new identity from
|
|
1042
|
+
// briefly inheriting stale context after `/clear` or `/fork`.
|
|
1043
|
+
handle.sessionContext?.rotate(message.to);
|
|
1044
|
+
}
|
|
1045
|
+
catch (error) {
|
|
1046
|
+
const reason = `runner Session context rotation failed: ${errorMessage(error)}`;
|
|
1047
|
+
void this.terminateHandle(handle, reason).catch((terminationError) => {
|
|
1048
|
+
logTerminationFailure(handle, terminationError);
|
|
1049
|
+
});
|
|
1050
|
+
return;
|
|
1051
|
+
}
|
|
1052
|
+
let releaseTarget;
|
|
1053
|
+
const targetReservation = new Promise((resolve) => {
|
|
1054
|
+
releaseTarget = resolve;
|
|
1055
|
+
});
|
|
1056
|
+
let releaseSource;
|
|
1057
|
+
const sourceReservation = new Promise((resolve) => {
|
|
1058
|
+
releaseSource = resolve;
|
|
1059
|
+
});
|
|
1060
|
+
this.forkReservations.set(message.to, targetReservation);
|
|
1061
|
+
this.sourceForkReservations.set(message.from, sourceReservation);
|
|
1062
|
+
this.forkBufferedMessages.set(message.to, []);
|
|
1063
|
+
this.forkBufferedTerminalInputs.set(message.to, []);
|
|
1064
|
+
// The physical pane has already rotated. Remove every old routing alias now:
|
|
1065
|
+
// a later request for the source must spawn a fresh runner that resumes the
|
|
1066
|
+
// source Provider binding, while existing terminal attachments continue to
|
|
1067
|
+
// follow the transferred pane.
|
|
1068
|
+
for (const [key, candidate] of this.handles) {
|
|
1069
|
+
if (candidate !== handle)
|
|
1070
|
+
continue;
|
|
1071
|
+
this.handles.delete(key);
|
|
1072
|
+
this.liveSessionKeys.delete(key);
|
|
1073
|
+
this.liveOptions.delete(key);
|
|
1074
|
+
}
|
|
1075
|
+
const rotation = {
|
|
1076
|
+
from: message.from,
|
|
1077
|
+
to: message.to,
|
|
1078
|
+
kind: message.kind,
|
|
1079
|
+
workspace: message.workspace,
|
|
1080
|
+
execution: message.execution,
|
|
1081
|
+
...(message.parentSessionId ? { parentSessionId: message.parentSessionId } : {}),
|
|
1082
|
+
};
|
|
1083
|
+
void Promise.resolve(this.rotateListener?.(rotation))
|
|
1084
|
+
.then(() => {
|
|
1085
|
+
// The server's rotation listener resolves only after the target Session
|
|
1086
|
+
// is published. Release the terminal admission handoff afterwards so a
|
|
1087
|
+
// maintenance snapshot cannot observe a gap with neither source work
|
|
1088
|
+
// nor the target Session.
|
|
1089
|
+
this.settleTerminalRotationHandoff(handle, message.from, message.to);
|
|
1090
|
+
if (handle.dead)
|
|
1091
|
+
return;
|
|
1092
|
+
handle.activeResponseIds.clear();
|
|
1093
|
+
this.handles.set(message.to, handle);
|
|
1094
|
+
this.liveSessionKeys.add(message.to);
|
|
1095
|
+
this.liveOptions.set(message.to, {
|
|
1096
|
+
workspace: structuredClone(message.workspace),
|
|
1097
|
+
execution: structuredClone(message.execution),
|
|
1098
|
+
});
|
|
1099
|
+
for (const entry of this.forkBufferedMessages.get(message.to) ?? []) {
|
|
1100
|
+
this.deliverForkBufferedMessage(entry.handle, entry.message);
|
|
1101
|
+
}
|
|
1102
|
+
for (const entry of this.forkBufferedTerminalInputs.get(message.to) ?? []) {
|
|
1103
|
+
if (!entry.handle.dead)
|
|
1104
|
+
entry.handle.transport.send(entry.message);
|
|
1105
|
+
}
|
|
1106
|
+
})
|
|
1107
|
+
.catch((error) => {
|
|
1108
|
+
void this.terminateHandle(handle, `Session rotation publication failed: ${errorMessage(error)}`).catch((terminationError) => logTerminationFailure(handle, terminationError));
|
|
1109
|
+
})
|
|
1110
|
+
.finally(() => {
|
|
1111
|
+
if (this.forkReservations.get(message.to) === targetReservation) {
|
|
1112
|
+
this.forkReservations.delete(message.to);
|
|
1113
|
+
}
|
|
1114
|
+
if (this.sourceForkReservations.get(message.from) === sourceReservation) {
|
|
1115
|
+
this.sourceForkReservations.delete(message.from);
|
|
1116
|
+
}
|
|
1117
|
+
this.forkBufferedMessages.delete(message.to);
|
|
1118
|
+
this.forkBufferedTerminalInputs.delete(message.to);
|
|
1119
|
+
releaseTarget();
|
|
1120
|
+
releaseSource();
|
|
1121
|
+
});
|
|
458
1122
|
}
|
|
459
1123
|
spawnHandle(key) {
|
|
460
1124
|
const args = this.runnerEntry.endsWith(".ts")
|
|
@@ -463,10 +1127,12 @@ export class RunnerManager {
|
|
|
463
1127
|
const sessionContext = key === CAP_KEY
|
|
464
1128
|
? undefined
|
|
465
1129
|
: this.openSessionContext(key);
|
|
1130
|
+
const processGroup = process.platform !== "win32";
|
|
466
1131
|
let child;
|
|
467
1132
|
try {
|
|
468
1133
|
child = this.spawn(process.execPath, args, {
|
|
469
1134
|
stdio: ["pipe", "pipe", "pipe"],
|
|
1135
|
+
detached: processGroup,
|
|
470
1136
|
// `RYNX_RUNNER_SESSION` = the child's session key (localThreadId, or `__cap__`
|
|
471
1137
|
// for the shared capability child) so its LocalAgentHost scopes the private
|
|
472
1138
|
// CODEX_HOME to this session. It is deliberately applied last.
|
|
@@ -490,8 +1156,11 @@ export class RunnerManager {
|
|
|
490
1156
|
transport,
|
|
491
1157
|
stderr: [],
|
|
492
1158
|
lastUsedAt: this.now(),
|
|
1159
|
+
activeResponseIds: new Set(),
|
|
493
1160
|
dead: false,
|
|
494
1161
|
completion,
|
|
1162
|
+
terminalCleanupRetryFailures: 0,
|
|
1163
|
+
processGroup,
|
|
495
1164
|
...(sessionContext ? { sessionContext } : {}),
|
|
496
1165
|
caps: new Map(),
|
|
497
1166
|
terminals: new Map(),
|
|
@@ -550,37 +1219,25 @@ export class RunnerManager {
|
|
|
550
1219
|
return;
|
|
551
1220
|
}
|
|
552
1221
|
case "mirror":
|
|
1222
|
+
if (handle.dead)
|
|
1223
|
+
return;
|
|
1224
|
+
if (this.bufferForkTargetMessage(handle, msg))
|
|
1225
|
+
return;
|
|
1226
|
+
this.observeHandleRuntimeEvent(handle, msg.event);
|
|
553
1227
|
this.mirrorListener?.(msg.sessionId, msg.event);
|
|
1228
|
+
if (msg.event.type === "response.created") {
|
|
1229
|
+
// The listener projects response.created into SessionRuntimeIndex
|
|
1230
|
+
// synchronously. Only then may the accepted terminal reservation
|
|
1231
|
+
// drain into a maintenance activity snapshot.
|
|
1232
|
+
this.settleTerminalInputHandoff(handle, msg.sessionId, "turn");
|
|
1233
|
+
}
|
|
554
1234
|
return;
|
|
555
1235
|
case "rotate": {
|
|
556
1236
|
if (handle.dead)
|
|
557
1237
|
return;
|
|
558
|
-
|
|
559
|
-
// Rotate the runtime-local context before publishing the new Session
|
|
560
|
-
// alias to routing or observers. This prevents the new identity from
|
|
561
|
-
// briefly inheriting stale context after `/clear` or `/fork`.
|
|
562
|
-
handle.sessionContext?.rotate(msg.to);
|
|
563
|
-
}
|
|
564
|
-
catch (error) {
|
|
565
|
-
const reason = `runner Session context rotation failed: ${errorMessage(error)}`;
|
|
566
|
-
void this.terminateHandle(handle, reason).catch((terminationError) => {
|
|
567
|
-
logTerminationFailure(handle, terminationError);
|
|
568
|
-
});
|
|
1238
|
+
if (this.bufferForkTargetMessage(handle, msg))
|
|
569
1239
|
return;
|
|
570
|
-
|
|
571
|
-
// Terminal transfer: alias the new session to THIS runner so its injection
|
|
572
|
-
// (and live/approval) route to the same child that still owns the pane.
|
|
573
|
-
this.handles.set(msg.to, handle);
|
|
574
|
-
this.liveSessionKeys.add(msg.to);
|
|
575
|
-
this.rotateListener?.({
|
|
576
|
-
from: msg.from,
|
|
577
|
-
to: msg.to,
|
|
578
|
-
kind: msg.kind,
|
|
579
|
-
...(msg.agent ? { agent: msg.agent } : {}),
|
|
580
|
-
...(msg.model ? { model: msg.model } : {}),
|
|
581
|
-
...(msg.cwd ? { cwd: msg.cwd } : {}),
|
|
582
|
-
...(msg.parentSessionId ? { parentSessionId: msg.parentSessionId } : {}),
|
|
583
|
-
});
|
|
1240
|
+
this.deliverRotateMessage(handle, msg);
|
|
584
1241
|
return;
|
|
585
1242
|
}
|
|
586
1243
|
case "live.ready":
|
|
@@ -614,6 +1271,9 @@ export class RunnerManager {
|
|
|
614
1271
|
if (h === handle) {
|
|
615
1272
|
this.handles.delete(key);
|
|
616
1273
|
this.liveSessionKeys.delete(key);
|
|
1274
|
+
if (!this.forkReservations.has(key)) {
|
|
1275
|
+
this.liveOptions.delete(key);
|
|
1276
|
+
}
|
|
617
1277
|
}
|
|
618
1278
|
}
|
|
619
1279
|
const tail = handle.stderr.join("\n");
|
|
@@ -636,7 +1296,11 @@ export class RunnerManager {
|
|
|
636
1296
|
terminateHandle(handle, reason) {
|
|
637
1297
|
if (handle.termination)
|
|
638
1298
|
return handle.termination;
|
|
639
|
-
handle.
|
|
1299
|
+
if (handle.terminalCleanupRetryTimer) {
|
|
1300
|
+
clearTimeout(handle.terminalCleanupRetryTimer);
|
|
1301
|
+
delete handle.terminalCleanupRetryTimer;
|
|
1302
|
+
}
|
|
1303
|
+
const attempt = (async () => {
|
|
640
1304
|
if (!handle.dead) {
|
|
641
1305
|
try {
|
|
642
1306
|
handle.transport.send({ t: "shutdown" });
|
|
@@ -646,36 +1310,121 @@ export class RunnerManager {
|
|
|
646
1310
|
}
|
|
647
1311
|
this.failHandle(handle, reason);
|
|
648
1312
|
}
|
|
649
|
-
signalChild(handle.child, "SIGTERM");
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
1313
|
+
this.signalChild(handle.child, "SIGTERM", handle.processGroup);
|
|
1314
|
+
let childExited = await waitForChildExit(handle.completion, this.shutdownGraceMs);
|
|
1315
|
+
if (!childExited) {
|
|
1316
|
+
this.signalChild(handle.child, "SIGKILL", handle.processGroup);
|
|
1317
|
+
childExited = await waitForChildExit(handle.completion, this.shutdownKillGraceMs);
|
|
1318
|
+
}
|
|
1319
|
+
const terminalStopped = handle.key === CAP_KEY
|
|
1320
|
+
? true
|
|
1321
|
+
: await Promise.resolve(this.terminateTerminalServer(`${handle.key}-main`)).catch(() => false);
|
|
1322
|
+
if (!childExited) {
|
|
1323
|
+
throw new Error(`runner child ${handle.child.pid ?? handle.key} did not exit after SIGKILL`);
|
|
1324
|
+
}
|
|
1325
|
+
if (!terminalStopped) {
|
|
1326
|
+
throw new Error(`runner terminal ${handle.key}-main remained live after child exit`);
|
|
655
1327
|
}
|
|
656
1328
|
this.childHandles.delete(handle);
|
|
657
1329
|
})();
|
|
658
|
-
|
|
1330
|
+
handle.termination = attempt;
|
|
1331
|
+
void attempt.then(() => {
|
|
1332
|
+
this.finishAllTerminalInputHandoffs(handle);
|
|
1333
|
+
}, () => {
|
|
1334
|
+
if (handle.termination === attempt)
|
|
1335
|
+
delete handle.termination;
|
|
1336
|
+
handle.terminalCleanupRetryFailures += 1;
|
|
1337
|
+
// A failed cleanup must not pin closeAndDrain forever. Keep the
|
|
1338
|
+
// submission in the daemon activity snapshot, but release its gate
|
|
1339
|
+
// reservation so maintenance returns a structured busy result.
|
|
1340
|
+
this.preserveTerminalInputHandoffsAsActivity(handle);
|
|
1341
|
+
this.scheduleTerminalInputCleanupRetry(handle);
|
|
1342
|
+
});
|
|
1343
|
+
return attempt;
|
|
659
1344
|
}
|
|
660
1345
|
reapIdle() {
|
|
661
1346
|
const now = this.now();
|
|
662
1347
|
for (const [key, handle] of this.handles) {
|
|
663
|
-
if (handle.caps.size > 0 || handle.terminals.size > 0) {
|
|
1348
|
+
if (handle.caps.size > 0 || handle.terminals.size > 0 || handle.live.size > 0) {
|
|
664
1349
|
continue;
|
|
665
1350
|
}
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
if (this.liveSessionKeys.has(key)) {
|
|
1351
|
+
const idleForMs = now - handle.lastUsedAt;
|
|
1352
|
+
if (idleForMs < this.idleTtlMs) {
|
|
669
1353
|
continue;
|
|
670
1354
|
}
|
|
671
|
-
|
|
1355
|
+
const hasActiveResponse = handle.activeResponseIds.size > 0;
|
|
1356
|
+
if (hasActiveResponse && idleForMs < this.staleActiveTtlMs) {
|
|
672
1357
|
continue;
|
|
673
1358
|
}
|
|
674
|
-
|
|
1359
|
+
const reason = hasActiveResponse
|
|
1360
|
+
? "stale active runner reaped"
|
|
1361
|
+
: this.liveSessionKeys.has(key)
|
|
1362
|
+
? "idle live runner reaped"
|
|
1363
|
+
: "idle runner reaped";
|
|
1364
|
+
if (hasActiveResponse) {
|
|
1365
|
+
this.failStaleActiveResponses(handle, idleForMs);
|
|
1366
|
+
}
|
|
1367
|
+
void this.terminateHandle(handle, reason).catch((error) => {
|
|
675
1368
|
logTerminationFailure(handle, error);
|
|
676
1369
|
});
|
|
677
1370
|
}
|
|
678
1371
|
}
|
|
1372
|
+
/** Close server-side runtime state before fencing a Provider that stayed
|
|
1373
|
+
* active past its hard inactivity deadline. The child transport is closed by
|
|
1374
|
+
* terminateHandle immediately afterwards, so these are the final events for
|
|
1375
|
+
* the abandoned responses. */
|
|
1376
|
+
failStaleActiveResponses(handle, idleForMs) {
|
|
1377
|
+
const responseIds = [...handle.activeResponseIds];
|
|
1378
|
+
handle.activeResponseIds.clear();
|
|
1379
|
+
const sessionId = this.currentTerminalSessionId(handle, handle.key);
|
|
1380
|
+
for (const responseId of responseIds) {
|
|
1381
|
+
this.mirrorListener?.(sessionId, {
|
|
1382
|
+
type: "response.failed",
|
|
1383
|
+
responseId,
|
|
1384
|
+
error: {
|
|
1385
|
+
source: "execution",
|
|
1386
|
+
code: "stale_runner_reaped",
|
|
1387
|
+
message: `Runner was reaped after ${Math.floor(idleForMs / 1000)}s without user activity`,
|
|
1388
|
+
},
|
|
1389
|
+
});
|
|
1390
|
+
}
|
|
1391
|
+
}
|
|
1392
|
+
/** Track only response lifecycle, not output volume. Output deltas from a
|
|
1393
|
+
* runaway TUI/Provider must not refresh the stale-active deadline. */
|
|
1394
|
+
observeHandleRuntimeEvent(handle, event) {
|
|
1395
|
+
switch (event.type) {
|
|
1396
|
+
case "response.created":
|
|
1397
|
+
case "response.output_text.delta":
|
|
1398
|
+
case "response.reasoning_summary_text.delta":
|
|
1399
|
+
case "response.function_call_output.delta":
|
|
1400
|
+
case "response.output_item.done":
|
|
1401
|
+
handle.activeResponseIds.add(event.responseId);
|
|
1402
|
+
return;
|
|
1403
|
+
case "session.interaction.requested":
|
|
1404
|
+
handle.activeResponseIds.add(event.responseId);
|
|
1405
|
+
return;
|
|
1406
|
+
case "response.completed":
|
|
1407
|
+
case "response.failed":
|
|
1408
|
+
handle.activeResponseIds.delete(event.responseId);
|
|
1409
|
+
return;
|
|
1410
|
+
case "session.status":
|
|
1411
|
+
if (event.status === "running" && event.responseId) {
|
|
1412
|
+
handle.activeResponseIds.add(event.responseId);
|
|
1413
|
+
}
|
|
1414
|
+
else if (event.status !== "running") {
|
|
1415
|
+
if (event.responseId)
|
|
1416
|
+
handle.activeResponseIds.delete(event.responseId);
|
|
1417
|
+
else
|
|
1418
|
+
handle.activeResponseIds.clear();
|
|
1419
|
+
}
|
|
1420
|
+
return;
|
|
1421
|
+
case "session.rotated":
|
|
1422
|
+
handle.activeResponseIds.clear();
|
|
1423
|
+
return;
|
|
1424
|
+
default:
|
|
1425
|
+
return;
|
|
1426
|
+
}
|
|
1427
|
+
}
|
|
679
1428
|
openSessionContext(sessionId) {
|
|
680
1429
|
if (!this.sessionContextProvider)
|
|
681
1430
|
return undefined;
|
|
@@ -694,6 +1443,70 @@ export class RunnerManager {
|
|
|
694
1443
|
}
|
|
695
1444
|
}
|
|
696
1445
|
}
|
|
1446
|
+
function trackedTerminalSubmissions(tracker, data) {
|
|
1447
|
+
const pasteStart = "\u001b[200~";
|
|
1448
|
+
const pasteEnd = "\u001b[201~";
|
|
1449
|
+
const input = tracker.pendingEscape + data;
|
|
1450
|
+
tracker.pendingEscape = "";
|
|
1451
|
+
const submissions = [];
|
|
1452
|
+
for (let index = 0; index < input.length; index += 1) {
|
|
1453
|
+
const character = input[index] ?? "";
|
|
1454
|
+
if (character === "\u001b") {
|
|
1455
|
+
const remaining = input.slice(index);
|
|
1456
|
+
if (remaining.startsWith(pasteStart)) {
|
|
1457
|
+
tracker.bracketedPaste = true;
|
|
1458
|
+
tracker.previousWasCarriageReturn = false;
|
|
1459
|
+
index += pasteStart.length - 1;
|
|
1460
|
+
continue;
|
|
1461
|
+
}
|
|
1462
|
+
if (remaining.startsWith(pasteEnd)) {
|
|
1463
|
+
tracker.bracketedPaste = false;
|
|
1464
|
+
tracker.previousWasCarriageReturn = false;
|
|
1465
|
+
index += pasteEnd.length - 1;
|
|
1466
|
+
continue;
|
|
1467
|
+
}
|
|
1468
|
+
if (pasteStart.startsWith(remaining) || pasteEnd.startsWith(remaining)) {
|
|
1469
|
+
tracker.pendingEscape = remaining;
|
|
1470
|
+
break;
|
|
1471
|
+
}
|
|
1472
|
+
}
|
|
1473
|
+
if (tracker.bracketedPaste) {
|
|
1474
|
+
tracker.buffer += character;
|
|
1475
|
+
tracker.previousWasCarriageReturn = false;
|
|
1476
|
+
continue;
|
|
1477
|
+
}
|
|
1478
|
+
if (character === "\n" && tracker.previousWasCarriageReturn) {
|
|
1479
|
+
tracker.previousWasCarriageReturn = false;
|
|
1480
|
+
continue;
|
|
1481
|
+
}
|
|
1482
|
+
tracker.previousWasCarriageReturn = character === "\r";
|
|
1483
|
+
if (character === "\r" || character === "\n") {
|
|
1484
|
+
const command = tracker.buffer.trim();
|
|
1485
|
+
tracker.buffer = "";
|
|
1486
|
+
if (command.length > 0 &&
|
|
1487
|
+
(command.includes("\u001b") ||
|
|
1488
|
+
!command.startsWith("/") ||
|
|
1489
|
+
/^\/(?:clear|fork)(?:\s|$)/u.test(command))) {
|
|
1490
|
+
submissions.push(/^\/(?:clear|fork)(?:\s|$)/u.test(command) ? "rotation" : "turn");
|
|
1491
|
+
}
|
|
1492
|
+
continue;
|
|
1493
|
+
}
|
|
1494
|
+
if (character === "\b" || character === "\u007f") {
|
|
1495
|
+
tracker.buffer = tracker.buffer.slice(0, -1);
|
|
1496
|
+
continue;
|
|
1497
|
+
}
|
|
1498
|
+
if (character === "\u0015" || character === "\u0003") {
|
|
1499
|
+
tracker.buffer = "";
|
|
1500
|
+
continue;
|
|
1501
|
+
}
|
|
1502
|
+
// Printable text and tabs are enough to distinguish work-producing prompts
|
|
1503
|
+
// from local TUI slash commands. Other control sequences are ignored.
|
|
1504
|
+
if (character === "\u001b" || character === "\t" || character >= " ") {
|
|
1505
|
+
tracker.buffer += character;
|
|
1506
|
+
}
|
|
1507
|
+
}
|
|
1508
|
+
return submissions;
|
|
1509
|
+
}
|
|
697
1510
|
function validateSessionContext(context) {
|
|
698
1511
|
if (!context || typeof context !== "object") {
|
|
699
1512
|
throw new Error("Session context environment provider must return an object");
|
|
@@ -798,9 +1611,18 @@ function observeChildCompletion(child) {
|
|
|
798
1611
|
child.once("exit", onExit);
|
|
799
1612
|
});
|
|
800
1613
|
}
|
|
801
|
-
function
|
|
1614
|
+
function signalRunnerChild(child, signal, processGroup) {
|
|
802
1615
|
if (childHasExited(child))
|
|
803
1616
|
return;
|
|
1617
|
+
if (processGroup && child.pid) {
|
|
1618
|
+
try {
|
|
1619
|
+
process.kill(-child.pid, signal);
|
|
1620
|
+
return;
|
|
1621
|
+
}
|
|
1622
|
+
catch {
|
|
1623
|
+
// The group may have exited between the completion check and the signal.
|
|
1624
|
+
}
|
|
1625
|
+
}
|
|
804
1626
|
try {
|
|
805
1627
|
child.kill(signal);
|
|
806
1628
|
}
|
|
@@ -836,3 +1658,14 @@ function logTerminationFailure(handle, error) {
|
|
|
836
1658
|
error: error instanceof Error ? error.message : String(error),
|
|
837
1659
|
}));
|
|
838
1660
|
}
|
|
1661
|
+
function capabilityForkResult(result) {
|
|
1662
|
+
return result.ok
|
|
1663
|
+
? { ok: true }
|
|
1664
|
+
: {
|
|
1665
|
+
ok: false,
|
|
1666
|
+
message: result.message ??
|
|
1667
|
+
(result.reason === "unsupported"
|
|
1668
|
+
? "Provider does not support Session fork"
|
|
1669
|
+
: "Provider Session is unavailable"),
|
|
1670
|
+
};
|
|
1671
|
+
}
|