@rynx-ai/runtime 0.1.11-beta.20 → 0.1.11-beta.22
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/native-integration.d.ts +23 -1
- package/dist/claude/native-integration.js +69 -6
- package/dist/claude/session-status.d.ts +39 -0
- package/dist/claude/session-status.js +163 -0
- package/dist/codex-app-server/client.d.ts +4 -3
- package/dist/codex-app-server/client.js +35 -12
- package/dist/codex-app-server/forwarder.d.ts +29 -5
- package/dist/codex-app-server/forwarder.js +140 -29
- package/dist/codex-app-server/mapping.d.ts +2 -0
- package/dist/codex-app-server/mapping.js +97 -23
- package/dist/codex-app-server/protocol.d.ts +2 -3
- package/dist/host.d.ts +27 -20
- package/dist/host.js +337 -455
- package/dist/runner/child.d.ts +12 -5
- package/dist/runner/child.js +116 -37
- package/dist/runner/manager.d.ts +36 -18
- package/dist/runner/manager.js +176 -81
- package/dist/runner/protocol.d.ts +15 -20
- package/dist/runner/startup-policy.d.ts +3 -0
- package/dist/runner/startup-policy.js +5 -0
- package/dist/terminal/tmux.d.ts +15 -0
- package/dist/terminal/tmux.js +50 -0
- package/package.json +2 -2
package/dist/runner/manager.js
CHANGED
|
@@ -22,9 +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
|
+
import { terminateTmuxServer, tmuxHasAttachedClient, tmuxWindowActivityAt, } from "../terminal/tmux.js";
|
|
26
26
|
import { fromWireError, } from "./protocol.js";
|
|
27
|
-
import { isManagedNativeProvider } from "./startup-policy.js";
|
|
27
|
+
import { isCodexLineageProvider, isManagedNativeProvider } from "./startup-policy.js";
|
|
28
28
|
import { StdioRunnerTransport } from "./transport.js";
|
|
29
29
|
/** Routing key for the shared capability runner (slash-command RPCs). */
|
|
30
30
|
const CAP_KEY = "__cap__";
|
|
@@ -34,9 +34,8 @@ const STDERR_TAIL_LINES = 40;
|
|
|
34
34
|
const DEFAULT_SHUTDOWN_GRACE_MS = 5_000;
|
|
35
35
|
/** Time allowed for exit after SIGKILL before shutdown reports failure. */
|
|
36
36
|
const DEFAULT_SHUTDOWN_KILL_GRACE_MS = 5_000;
|
|
37
|
-
/**
|
|
38
|
-
*
|
|
39
|
-
* own 10s gate so scheduling/IPC overhead cannot win the same deadline. */
|
|
37
|
+
/** Legacy setup-pane acknowledgement deadline for providers that do not own
|
|
38
|
+
* phase-specific startup errors. Codex/Traex are deliberately excluded. */
|
|
40
39
|
const DEFAULT_LIVE_START_TIMEOUT_MS = 30_000;
|
|
41
40
|
/** Legacy fallback for a provider without a native startup policy. */
|
|
42
41
|
const DEFAULT_LIVE_READY_TIMEOUT_MS = 25_000;
|
|
@@ -51,12 +50,10 @@ const DEFAULT_TERMINAL_INPUT_HANDOFF_TIMEOUT_MS = 30_000;
|
|
|
51
50
|
/** Retry transient terminal-server cleanup without spinning forever. */
|
|
52
51
|
const DEFAULT_TERMINAL_INPUT_CLEANUP_RETRY_MS = 1_000;
|
|
53
52
|
const MAX_TERMINAL_INPUT_CLEANUP_RETRY_MS = 30_000;
|
|
54
|
-
/**
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
* does not, because a runaway redraw loop is the failure mode this bounds. */
|
|
59
|
-
const DEFAULT_STALE_ACTIVE_TTL_MS = 60 * 60_000;
|
|
53
|
+
/** Full idle window before an inactive native pane becomes reapable. */
|
|
54
|
+
const DEFAULT_NATIVE_PANE_IDLE_TTL_MS = 60 * 60_000;
|
|
55
|
+
/** tmux output this recent independently proves that a native pane is busy. */
|
|
56
|
+
const DEFAULT_NATIVE_PANE_OUTPUT_BUSY_WINDOW_MS = 120_000;
|
|
60
57
|
/** Keep per-Session spawn context small enough to remain an environment handoff,
|
|
61
58
|
* not an unbounded transport. */
|
|
62
59
|
const MAX_SESSION_CONTEXT_ENV_ENTRIES = 32;
|
|
@@ -157,7 +154,8 @@ export class RunnerManager {
|
|
|
157
154
|
sessionStore;
|
|
158
155
|
runnerEntry;
|
|
159
156
|
idleTtlMs;
|
|
160
|
-
|
|
157
|
+
nativePaneIdleTtlMs;
|
|
158
|
+
nativePaneOutputBusyWindowMs;
|
|
161
159
|
spawn;
|
|
162
160
|
childEnv;
|
|
163
161
|
sessionContextProvider;
|
|
@@ -174,12 +172,16 @@ export class RunnerManager {
|
|
|
174
172
|
shutdownKillGraceMs;
|
|
175
173
|
signalChild;
|
|
176
174
|
terminateTerminalServer;
|
|
175
|
+
terminalWindowActivityAt;
|
|
176
|
+
terminalHasAttachedClient;
|
|
177
|
+
wallNow;
|
|
177
178
|
liveStartTimeoutMs;
|
|
178
179
|
liveReadyTimeoutMs;
|
|
179
180
|
nativeLiveStartTimeoutMs;
|
|
180
181
|
liveInterruptTimeoutMs;
|
|
181
182
|
terminalInputHandoffTimeoutMs;
|
|
182
183
|
terminalInputCleanupRetryMs;
|
|
184
|
+
reapPromise = null;
|
|
183
185
|
stopping = false;
|
|
184
186
|
stopPromise;
|
|
185
187
|
/** Sink for mirrored {@link SessionEvent}s from every session's forwarder. */
|
|
@@ -213,18 +215,22 @@ export class RunnerManager {
|
|
|
213
215
|
this.sessionStore = opts.sessionStore;
|
|
214
216
|
this.runnerEntry = opts.runnerEntry ?? defaultRunnerEntry();
|
|
215
217
|
this.idleTtlMs = opts.idleTtlMs ?? 300_000;
|
|
216
|
-
this.
|
|
218
|
+
this.nativePaneIdleTtlMs = opts.nativePaneIdleTtlMs ?? DEFAULT_NATIVE_PANE_IDLE_TTL_MS;
|
|
219
|
+
this.nativePaneOutputBusyWindowMs = Math.max(0, opts.nativePaneOutputBusyWindowMs ?? DEFAULT_NATIVE_PANE_OUTPUT_BUSY_WINDOW_MS);
|
|
217
220
|
this.spawn = opts.spawn ?? nodeSpawn;
|
|
218
221
|
this.childEnv = opts.childEnv ?? {};
|
|
219
222
|
this.sessionContextProvider = opts.sessionContextProvider;
|
|
220
223
|
this.admissionOpen = opts.admissionOpen ?? (() => true);
|
|
221
224
|
this.admissionReserve = opts.admissionReserve;
|
|
222
225
|
this.now = opts.now ?? (() => Date.now());
|
|
226
|
+
this.wallNow = opts.wallNow ?? (() => Date.now());
|
|
223
227
|
this.defaultRuntime = opts.config.DEFAULT_RUNTIME ?? "codex";
|
|
224
228
|
this.shutdownGraceMs = Math.max(0, opts.shutdownGraceMs ?? DEFAULT_SHUTDOWN_GRACE_MS);
|
|
225
229
|
this.shutdownKillGraceMs = Math.max(0, opts.shutdownKillGraceMs ?? DEFAULT_SHUTDOWN_KILL_GRACE_MS);
|
|
226
230
|
this.signalChild = opts.signalChild ?? signalRunnerChild;
|
|
227
231
|
this.terminateTerminalServer = opts.terminateTerminalServer ?? terminateTmuxServer;
|
|
232
|
+
this.terminalWindowActivityAt = opts.terminalWindowActivityAt ?? tmuxWindowActivityAt;
|
|
233
|
+
this.terminalHasAttachedClient = opts.terminalHasAttachedClient ?? tmuxHasAttachedClient;
|
|
228
234
|
this.liveStartTimeoutMs = Math.max(1, opts.liveStartTimeoutMs ?? DEFAULT_LIVE_START_TIMEOUT_MS);
|
|
229
235
|
this.liveReadyTimeoutMs = Math.max(1, opts.liveReadyTimeoutMs ?? DEFAULT_LIVE_READY_TIMEOUT_MS);
|
|
230
236
|
this.nativeLiveStartTimeoutMs = Math.max(1, opts.nativeLiveStartTimeoutMs ?? DEFAULT_NATIVE_LIVE_START_TIMEOUT_MS);
|
|
@@ -233,7 +239,7 @@ export class RunnerManager {
|
|
|
233
239
|
this.terminalInputCleanupRetryMs = Math.max(1, opts.terminalInputCleanupRetryMs ?? DEFAULT_TERMINAL_INPUT_CLEANUP_RETRY_MS);
|
|
234
240
|
const reapIntervalMs = opts.reapIntervalMs ?? 60_000;
|
|
235
241
|
if (reapIntervalMs > 0) {
|
|
236
|
-
this.reapTimer = setInterval(() => this.reapIdle(), reapIntervalMs);
|
|
242
|
+
this.reapTimer = setInterval(() => void this.reapIdle(), reapIntervalMs);
|
|
237
243
|
this.reapTimer.unref?.();
|
|
238
244
|
}
|
|
239
245
|
else {
|
|
@@ -502,9 +508,10 @@ export class RunnerManager {
|
|
|
502
508
|
/**
|
|
503
509
|
* Eagerly bring up a session's codex-native live view (persistent forwarder +
|
|
504
510
|
* detached `codex --remote` TUI) in its runner child, spawning the runner if
|
|
505
|
-
* needed. Idempotent. For Codex-lineage
|
|
506
|
-
*
|
|
507
|
-
*
|
|
511
|
+
* needed. Idempotent. For Codex-lineage sessions, resolves after backend
|
|
512
|
+
* preload and pane launch; known-thread observer attach and fresh-thread
|
|
513
|
+
* discovery continue in the background. Other providers retain their
|
|
514
|
+
* readiness gate.
|
|
508
515
|
*/
|
|
509
516
|
ensureLiveSession(localThreadId, opts) {
|
|
510
517
|
const admission = this.reserveAdmission();
|
|
@@ -543,55 +550,66 @@ export class RunnerManager {
|
|
|
543
550
|
this.liveSessionKeys.add(localThreadId);
|
|
544
551
|
const reqId = randomUUID();
|
|
545
552
|
return new Promise((resolve) => {
|
|
546
|
-
const
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
553
|
+
const provider = opts.execution.provider;
|
|
554
|
+
const timeoutMs = isCodexLineageProvider(provider)
|
|
555
|
+
? null
|
|
556
|
+
: !waitForReady
|
|
557
|
+
? this.liveStartTimeoutMs
|
|
558
|
+
: provider === "claude"
|
|
559
|
+
? this.nativeLiveStartTimeoutMs
|
|
552
560
|
: this.liveReadyTimeoutMs;
|
|
553
|
-
const timeout =
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
const
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
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`
|
|
561
|
+
const timeout = timeoutMs === null
|
|
562
|
+
? undefined
|
|
563
|
+
: setTimeout(() => {
|
|
564
|
+
if (!handle.live.delete(reqId))
|
|
565
|
+
return;
|
|
566
|
+
const finishTimeout = () => {
|
|
567
|
+
const reason = !waitForReady
|
|
568
|
+
? `runner did not acknowledge terminal start within ${timeoutMs}ms`
|
|
569
|
+
: provider === "claude"
|
|
570
|
+
? `Claude SessionStart was not observed within ${Math.round(timeoutMs / 1_000)}s; the runner process was reset before retry`
|
|
564
571
|
: `runner did not acknowledge live readiness within ${timeoutMs}ms`;
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
572
|
+
this.liveErrors.set(localThreadId, {
|
|
573
|
+
message: reason,
|
|
574
|
+
code: provider === "claude"
|
|
575
|
+
? "claude_session_start_timeout"
|
|
576
|
+
: "native_runner_ack_timeout",
|
|
577
|
+
statusCode: 503,
|
|
578
|
+
});
|
|
579
|
+
this.liveSessionKeys.delete(localThreadId);
|
|
580
|
+
// Legacy providers without phase-owned startup errors retain the
|
|
581
|
+
// bounded control guard. Codex/Traex never enter this branch.
|
|
582
|
+
void this.terminateHandle(handle, reason).catch((error) => {
|
|
583
|
+
logTerminationFailure(handle, error);
|
|
584
|
+
});
|
|
585
|
+
return false;
|
|
586
|
+
};
|
|
587
|
+
const reservation = allowReservedForkTarget
|
|
588
|
+
? undefined
|
|
589
|
+
: this.forkReservations.get(localThreadId);
|
|
590
|
+
if (reservation) {
|
|
591
|
+
void reservation
|
|
592
|
+
.then(() => finishTimeout())
|
|
593
|
+
.then(resolve, () => resolve(false));
|
|
594
|
+
return;
|
|
595
|
+
}
|
|
596
|
+
resolve(finishTimeout());
|
|
597
|
+
}, timeoutMs);
|
|
598
|
+
timeout?.unref?.();
|
|
586
599
|
handle.live.set(reqId, (res) => {
|
|
587
|
-
|
|
600
|
+
if (timeout)
|
|
601
|
+
clearTimeout(timeout);
|
|
588
602
|
const ok = res.ok ?? false;
|
|
589
603
|
const finish = () => {
|
|
590
604
|
if (ok) {
|
|
591
605
|
this.liveErrors.delete(localThreadId);
|
|
592
606
|
}
|
|
593
607
|
else {
|
|
594
|
-
this.liveErrors.set(localThreadId, res.error ??
|
|
608
|
+
this.liveErrors.set(localThreadId, res.error ?? {
|
|
609
|
+
message: "live session did not become ready",
|
|
610
|
+
code: "native_readiness_failed",
|
|
611
|
+
statusCode: 503,
|
|
612
|
+
});
|
|
595
613
|
}
|
|
596
614
|
return ok;
|
|
597
615
|
};
|
|
@@ -619,7 +637,11 @@ export class RunnerManager {
|
|
|
619
637
|
});
|
|
620
638
|
}
|
|
621
639
|
lastLiveSessionError(localThreadId) {
|
|
622
|
-
return this.liveErrors.get(localThreadId);
|
|
640
|
+
return this.liveErrors.get(localThreadId)?.message;
|
|
641
|
+
}
|
|
642
|
+
lastLiveSessionFailure(localThreadId) {
|
|
643
|
+
const failure = this.liveErrors.get(localThreadId);
|
|
644
|
+
return failure ? { ...failure } : undefined;
|
|
623
645
|
}
|
|
624
646
|
/**
|
|
625
647
|
* Inject a user turn into a session's live codex thread — reference implementation's
|
|
@@ -648,11 +670,19 @@ export class RunnerManager {
|
|
|
648
670
|
handle.live.set(reqId, (res) => {
|
|
649
671
|
const outcome = res.outcome ?? "failed";
|
|
650
672
|
const finish = () => {
|
|
651
|
-
if (outcome === "injected") {
|
|
673
|
+
if (outcome === "injected" || outcome === "steered") {
|
|
652
674
|
this.liveErrors.delete(localThreadId);
|
|
653
675
|
}
|
|
654
676
|
else {
|
|
655
|
-
this.liveErrors.set(localThreadId, res.error ??
|
|
677
|
+
this.liveErrors.set(localThreadId, res.error ?? {
|
|
678
|
+
message: `live injection ${outcome}`,
|
|
679
|
+
code: outcome === "notReady"
|
|
680
|
+
? "native_thread_not_ready"
|
|
681
|
+
: outcome === "notLive"
|
|
682
|
+
? "native_session_not_live"
|
|
683
|
+
: "native_message_injection_failed",
|
|
684
|
+
statusCode: 503,
|
|
685
|
+
});
|
|
656
686
|
}
|
|
657
687
|
return outcome;
|
|
658
688
|
};
|
|
@@ -707,7 +737,7 @@ export class RunnerManager {
|
|
|
707
737
|
handle.live.set(reqId, (reply) => {
|
|
708
738
|
resolve(reply.interactionResult ?? {
|
|
709
739
|
disposition: "invalid",
|
|
710
|
-
message: reply.error ?? "runner did not return an interaction result",
|
|
740
|
+
message: reply.error?.message ?? "runner did not return an interaction result",
|
|
711
741
|
});
|
|
712
742
|
});
|
|
713
743
|
handle.transport.send({
|
|
@@ -902,7 +932,6 @@ export class RunnerManager {
|
|
|
902
932
|
!allowReservedForkTarget) {
|
|
903
933
|
throw new AgentRuntimeError("Session fork is still being committed", 409, "session_fork_pending");
|
|
904
934
|
}
|
|
905
|
-
this.reapIdle();
|
|
906
935
|
const existing = this.handles.get(key);
|
|
907
936
|
if (existing && !existing.dead) {
|
|
908
937
|
existing.lastUsedAt = this.now();
|
|
@@ -1312,7 +1341,10 @@ export class RunnerManager {
|
|
|
1312
1341
|
terminal._fail(message);
|
|
1313
1342
|
}
|
|
1314
1343
|
for (const resolve of handle.live.values()) {
|
|
1315
|
-
resolve({
|
|
1344
|
+
resolve({
|
|
1345
|
+
ok: false,
|
|
1346
|
+
error: { message, code: "runner_crashed", statusCode: 500 },
|
|
1347
|
+
});
|
|
1316
1348
|
}
|
|
1317
1349
|
handle.caps.clear();
|
|
1318
1350
|
handle.terminals.clear();
|
|
@@ -1369,8 +1401,24 @@ export class RunnerManager {
|
|
|
1369
1401
|
return attempt;
|
|
1370
1402
|
}
|
|
1371
1403
|
reapIdle() {
|
|
1404
|
+
if (this.reapPromise)
|
|
1405
|
+
return this.reapPromise;
|
|
1406
|
+
const attempt = this.reapIdleOnce();
|
|
1407
|
+
this.reapPromise = attempt;
|
|
1408
|
+
const clear = () => {
|
|
1409
|
+
if (this.reapPromise === attempt)
|
|
1410
|
+
this.reapPromise = null;
|
|
1411
|
+
};
|
|
1412
|
+
void attempt.then(clear, clear);
|
|
1413
|
+
return attempt;
|
|
1414
|
+
}
|
|
1415
|
+
async reapIdleOnce() {
|
|
1372
1416
|
const now = this.now();
|
|
1373
1417
|
for (const [key, handle] of this.handles) {
|
|
1418
|
+
if (this.isManagedNativeHandle(handle)) {
|
|
1419
|
+
await this.reapNativePane(handle, now);
|
|
1420
|
+
continue;
|
|
1421
|
+
}
|
|
1374
1422
|
if (handle.caps.size > 0 || handle.terminals.size > 0 || handle.live.size > 0) {
|
|
1375
1423
|
continue;
|
|
1376
1424
|
}
|
|
@@ -1379,28 +1427,74 @@ export class RunnerManager {
|
|
|
1379
1427
|
continue;
|
|
1380
1428
|
}
|
|
1381
1429
|
const hasActiveResponse = handle.activeResponseIds.size > 0;
|
|
1382
|
-
if (hasActiveResponse
|
|
1430
|
+
if (hasActiveResponse)
|
|
1383
1431
|
continue;
|
|
1384
|
-
|
|
1385
|
-
|
|
1386
|
-
|
|
1387
|
-
: this.liveSessionKeys.has(key)
|
|
1388
|
-
? "idle live runner reaped"
|
|
1389
|
-
: "idle runner reaped";
|
|
1390
|
-
if (hasActiveResponse) {
|
|
1391
|
-
this.failStaleActiveResponses(handle, idleForMs);
|
|
1392
|
-
}
|
|
1432
|
+
const reason = this.liveSessionKeys.has(key)
|
|
1433
|
+
? "idle live runner reaped"
|
|
1434
|
+
: "idle runner reaped";
|
|
1393
1435
|
void this.terminateHandle(handle, reason).catch((error) => {
|
|
1394
1436
|
logTerminationFailure(handle, error);
|
|
1395
1437
|
});
|
|
1396
1438
|
}
|
|
1397
1439
|
}
|
|
1398
|
-
/**
|
|
1399
|
-
*
|
|
1400
|
-
*
|
|
1401
|
-
|
|
1402
|
-
|
|
1403
|
-
|
|
1440
|
+
/** Provider-authoritative turn state is the lifecycle truth; attached clients
|
|
1441
|
+
* and tmux's own activity clock are independent busy evidence. There is no
|
|
1442
|
+
* "user was inactive for an hour" override for an active turn. */
|
|
1443
|
+
async reapNativePane(handle, now) {
|
|
1444
|
+
if (this.nativePaneIdleTtlMs <= 0 || handle.dead)
|
|
1445
|
+
return;
|
|
1446
|
+
if (await this.isNativePaneBusy(handle)) {
|
|
1447
|
+
handle.nativePaneLastBusyAt = now;
|
|
1448
|
+
return;
|
|
1449
|
+
}
|
|
1450
|
+
const lastBusyAt = handle.nativePaneLastBusyAt;
|
|
1451
|
+
if (lastBusyAt === undefined) {
|
|
1452
|
+
// First idle observation starts a full grace window.
|
|
1453
|
+
handle.nativePaneLastBusyAt = now;
|
|
1454
|
+
return;
|
|
1455
|
+
}
|
|
1456
|
+
if (now - lastBusyAt < this.nativePaneIdleTtlMs)
|
|
1457
|
+
return;
|
|
1458
|
+
// Close the select→reap race: activity may begin after classification but
|
|
1459
|
+
// before teardown.
|
|
1460
|
+
if (await this.isNativePaneBusy(handle)) {
|
|
1461
|
+
handle.nativePaneLastBusyAt = this.now();
|
|
1462
|
+
return;
|
|
1463
|
+
}
|
|
1464
|
+
// A failed teardown must re-arm on the next scan instead of retrying on
|
|
1465
|
+
// every sweep forever.
|
|
1466
|
+
delete handle.nativePaneLastBusyAt;
|
|
1467
|
+
await this.terminateHandle(handle, "idle native pane reaped").catch((error) => logTerminationFailure(handle, error));
|
|
1468
|
+
}
|
|
1469
|
+
async isNativePaneBusy(handle) {
|
|
1470
|
+
if (handle.activeResponseIds.size > 0 ||
|
|
1471
|
+
handle.caps.size > 0 ||
|
|
1472
|
+
handle.live.size > 0 ||
|
|
1473
|
+
handle.terminals.size > 0) {
|
|
1474
|
+
return true;
|
|
1475
|
+
}
|
|
1476
|
+
const terminalName = `${handle.key}-main`;
|
|
1477
|
+
try {
|
|
1478
|
+
if (await this.terminalHasAttachedClient(terminalName))
|
|
1479
|
+
return true;
|
|
1480
|
+
}
|
|
1481
|
+
catch {
|
|
1482
|
+
// A failed probe contributes no busy evidence; check activity next.
|
|
1483
|
+
}
|
|
1484
|
+
let activityAt = null;
|
|
1485
|
+
try {
|
|
1486
|
+
activityAt = await this.terminalWindowActivityAt(terminalName);
|
|
1487
|
+
}
|
|
1488
|
+
catch {
|
|
1489
|
+
// A failed probe contributes no busy evidence.
|
|
1490
|
+
}
|
|
1491
|
+
return activityAt !== null &&
|
|
1492
|
+
this.wallNow() - activityAt * 1_000 < this.nativePaneOutputBusyWindowMs;
|
|
1493
|
+
}
|
|
1494
|
+
isManagedNativeHandle(handle) {
|
|
1495
|
+
const sessionId = this.currentTerminalSessionId(handle, handle.key);
|
|
1496
|
+
const provider = this.liveOptions.get(sessionId)?.execution.provider;
|
|
1497
|
+
return isManagedNativeProvider(provider);
|
|
1404
1498
|
}
|
|
1405
1499
|
failActiveResponses(handle, code, message) {
|
|
1406
1500
|
const responseIds = [...handle.activeResponseIds];
|
|
@@ -1418,8 +1512,9 @@ export class RunnerManager {
|
|
|
1418
1512
|
});
|
|
1419
1513
|
}
|
|
1420
1514
|
}
|
|
1421
|
-
/** Track
|
|
1422
|
-
*
|
|
1515
|
+
/** Track the provider-authoritative response lifecycle. Native-pane busy
|
|
1516
|
+
* classification consumes this level directly; output volume is separately
|
|
1517
|
+
* grounded in tmux's own activity clock. */
|
|
1423
1518
|
observeHandleRuntimeEvent(handle, event) {
|
|
1424
1519
|
switch (event.type) {
|
|
1425
1520
|
case "response.created":
|
|
@@ -6,7 +6,8 @@
|
|
|
6
6
|
* (`live.ensure` / `inject` / `live.interrupt`) plus per-thread capabilities and
|
|
7
7
|
* terminal attachment; the reply channels mirror each request's `reqId`.
|
|
8
8
|
*/
|
|
9
|
-
import { AgentRuntimeError, type ResolvedExecutionSnapshot, type RuntimeUserInput, type SessionInteractionResolution, type SessionEvent, type SessionWorkspaceSnapshot } from "@rynx-ai/core";
|
|
9
|
+
import { AgentRuntimeError, type InjectOutcome, type ResolvedExecutionSnapshot, type RuntimeUserInput, type SessionInteractionResolution, type SessionEvent, type SessionWorkspaceSnapshot } from "@rynx-ai/core";
|
|
10
|
+
export type { InjectOutcome } from "@rynx-ai/core";
|
|
10
11
|
import type { ResolveInteractionResult } from "../interactions.js";
|
|
11
12
|
/** A runtime error flattened for the wire; rebuilt parent-side as `AgentRuntimeError`. */
|
|
12
13
|
export interface WireError {
|
|
@@ -22,13 +23,6 @@ export type TerminalRole = "owner" | "read-only";
|
|
|
22
23
|
/** Stable reason for a terminal attach failure. `terminal_not_live` is an
|
|
23
24
|
* expected, retryable absence; `terminal_open_failed` is an infrastructure error. */
|
|
24
25
|
export type TerminalOpenErrorCode = "terminal_not_live" | "terminal_open_failed";
|
|
25
|
-
/** Outcome of an `inject`. `injected` = the app-server / tmux accepted the turn.
|
|
26
|
-
* `notLive` = no live forwarder for this session (caller may use the run path).
|
|
27
|
-
* `notReady` = live but not ready within the park window; `failed` = injection
|
|
28
|
-
* threw. For BOTH notReady and failed the caller reports an error and MUST NOT
|
|
29
|
-
* re-run the turn (re-running would double-write alongside the forwarder).
|
|
30
|
-
* Mirrors reference implementation's "inject failure ⇒ response.failed, never re-run locally". */
|
|
31
|
-
export type InjectOutcome = "injected" | "notLive" | "notReady" | "failed";
|
|
32
26
|
/** Parent → child. Terminal PTY bytes ride `term.input` base64-encoded so the
|
|
33
27
|
* NDJSON line framing stays intact (raw bytes contain newlines). */
|
|
34
28
|
export type ToChild = {
|
|
@@ -70,9 +64,9 @@ export type ToChild = {
|
|
|
70
64
|
}
|
|
71
65
|
/** Eagerly bring up a session's live codex TUI + forwarder (codex-native), so
|
|
72
66
|
* its turns mirror to the bus regardless of whether the web has attached the
|
|
73
|
-
* terminal.
|
|
74
|
-
*
|
|
75
|
-
*
|
|
67
|
+
* terminal. Fresh sessions connect the discovery listener before TUI launch;
|
|
68
|
+
* known-thread resumes launch the TUI after backend preload and attach their
|
|
69
|
+
* observer in the background. */
|
|
76
70
|
| {
|
|
77
71
|
t: "live.ensure";
|
|
78
72
|
reqId: string;
|
|
@@ -161,25 +155,26 @@ export type FromChild = {
|
|
|
161
155
|
execution: ResolvedExecutionSnapshot;
|
|
162
156
|
parentSessionId?: string;
|
|
163
157
|
}
|
|
164
|
-
/** Result of a `live.ensure`: for Codex-lineage sessions `ok` means
|
|
165
|
-
* and
|
|
166
|
-
*
|
|
158
|
+
/** Result of a `live.ensure`: for Codex-lineage sessions `ok` means backend
|
|
159
|
+
* preload and pane launch completed. A resume observer and fresh-thread
|
|
160
|
+
* discovery may continue in the background. Other Providers retain their
|
|
161
|
+
* own readiness gate. */
|
|
167
162
|
| {
|
|
168
163
|
t: "live.ready";
|
|
169
164
|
reqId: string;
|
|
170
165
|
localThreadId: string;
|
|
171
166
|
ok: boolean;
|
|
172
|
-
error?:
|
|
167
|
+
error?: WireError;
|
|
173
168
|
}
|
|
174
|
-
/** Result of an `inject`: `outcome` distinguishes
|
|
175
|
-
*
|
|
176
|
-
*
|
|
169
|
+
/** Result of an `inject`: `outcome` distinguishes a new turn, an active-turn
|
|
170
|
+
* steer, and failures so the caller never falls back to a second output path.
|
|
171
|
+
* Echoes the request `reqId`. */
|
|
177
172
|
| {
|
|
178
173
|
t: "injected";
|
|
179
174
|
reqId: string;
|
|
180
175
|
localThreadId: string;
|
|
181
176
|
outcome: InjectOutcome;
|
|
182
|
-
error?:
|
|
177
|
+
error?: WireError;
|
|
183
178
|
}
|
|
184
179
|
/** Result of a `live.interrupt`: `ok` when an active turn was interrupted (false
|
|
185
180
|
* when there was none). Echoes the request `reqId`. */
|
|
@@ -188,7 +183,7 @@ export type FromChild = {
|
|
|
188
183
|
reqId: string;
|
|
189
184
|
localThreadId: string;
|
|
190
185
|
ok: boolean;
|
|
191
|
-
error?:
|
|
186
|
+
error?: WireError;
|
|
192
187
|
} | {
|
|
193
188
|
t: "interaction.resolved";
|
|
194
189
|
reqId: string;
|
|
@@ -2,3 +2,6 @@ import type { AgentRuntimeId } from "@rynx-ai/core";
|
|
|
2
2
|
/** Current live providers are native CLI sessions. Keep this explicit so a
|
|
3
3
|
* future non-native provider does not silently inherit their startup budget. */
|
|
4
4
|
export declare function isManagedNativeProvider(provider: AgentRuntimeId | undefined): provider is AgentRuntimeId;
|
|
5
|
+
/** Codex-lineage providers own app-server, thread-discovery, and injection
|
|
6
|
+
* phase errors. Claude instead retains its parent-owned SessionStart gate. */
|
|
7
|
+
export declare function isCodexLineageProvider(provider: AgentRuntimeId | undefined): provider is Exclude<AgentRuntimeId, "claude">;
|
|
@@ -3,3 +3,8 @@
|
|
|
3
3
|
export function isManagedNativeProvider(provider) {
|
|
4
4
|
return provider === "codex" || provider === "traex" || provider === "claude";
|
|
5
5
|
}
|
|
6
|
+
/** Codex-lineage providers own app-server, thread-discovery, and injection
|
|
7
|
+
* phase errors. Claude instead retains its parent-owned SessionStart gate. */
|
|
8
|
+
export function isCodexLineageProvider(provider) {
|
|
9
|
+
return provider === "codex" || provider === "traex";
|
|
10
|
+
}
|
package/dist/terminal/tmux.d.ts
CHANGED
|
@@ -49,6 +49,19 @@ export declare function resolveTmuxBin(explicit?: string, env?: NodeJS.ProcessEn
|
|
|
49
49
|
* shutdown uses the same mapping when the runner child is too wedged to clean
|
|
50
50
|
* up its own tmux server. */
|
|
51
51
|
export declare function tmuxSocketPath(name: string): string;
|
|
52
|
+
/** Read tmux's own output-activity clock for a private terminal window.
|
|
53
|
+
*
|
|
54
|
+
* `#{window_activity}` is an epoch timestamp updated by tmux whenever the pane
|
|
55
|
+
* emits bytes. It is deliberately queried out-of-process rather than inferred
|
|
56
|
+
* from Rynx's forwarded PTY stream: the forwarder/status path is exactly what
|
|
57
|
+
* may have stalled when the idle reaper needs an independent liveness signal.
|
|
58
|
+
* Missing servers, command failures, timeouts, and unparseable output are all
|
|
59
|
+
* treated as unknown (`null`). */
|
|
60
|
+
export declare function tmuxWindowActivityAt(name: string, tmuxBin?: string): Promise<number | null>;
|
|
61
|
+
/** Whether tmux itself reports an attached client for this private terminal.
|
|
62
|
+
* This remains authoritative if a parent-side attachment bookkeeping edge was
|
|
63
|
+
* missed during a transport failure. */
|
|
64
|
+
export declare function tmuxHasAttachedClient(name: string, tmuxBin?: string): Promise<boolean>;
|
|
52
65
|
/** Kill one private tmux server and verify it no longer answers. Missing
|
|
53
66
|
* sockets are already stopped; an existing socket with an unusable tmux
|
|
54
67
|
* command is unproven and returns false. */
|
|
@@ -113,6 +126,8 @@ export declare class TmuxTerminal {
|
|
|
113
126
|
* terminal appears "stuck"). reference implementation's `_tmux_session_alive` uses an async
|
|
114
127
|
* subprocess + timeout for exactly this reason. */
|
|
115
128
|
isAliveAsync(): Promise<boolean>;
|
|
129
|
+
/** PID of the process currently owning the pane. */
|
|
130
|
+
panePid(): number | undefined;
|
|
116
131
|
/** Type literal text into the pane (agent injection / co-drive from a
|
|
117
132
|
* non-PTY caller). `-l` sends the text literally rather than as key names. */
|
|
118
133
|
sendKeys(text: string): void;
|
package/dist/terminal/tmux.js
CHANGED
|
@@ -42,6 +42,43 @@ export function tmuxSocketPath(name) {
|
|
|
42
42
|
const key = createHash("sha256").update(name).digest("hex").slice(0, 16);
|
|
43
43
|
return join(tmpdir(), "rynx-term", `${key}.sock`);
|
|
44
44
|
}
|
|
45
|
+
/** Read tmux's own output-activity clock for a private terminal window.
|
|
46
|
+
*
|
|
47
|
+
* `#{window_activity}` is an epoch timestamp updated by tmux whenever the pane
|
|
48
|
+
* emits bytes. It is deliberately queried out-of-process rather than inferred
|
|
49
|
+
* from Rynx's forwarded PTY stream: the forwarder/status path is exactly what
|
|
50
|
+
* may have stalled when the idle reaper needs an independent liveness signal.
|
|
51
|
+
* Missing servers, command failures, timeouts, and unparseable output are all
|
|
52
|
+
* treated as unknown (`null`). */
|
|
53
|
+
export function tmuxWindowActivityAt(name, tmuxBin = resolveTmuxBin()) {
|
|
54
|
+
const socketPath = tmuxSocketPath(name);
|
|
55
|
+
return new Promise((resolve) => {
|
|
56
|
+
execFile(tmuxBin, ["-S", socketPath, "display-message", "-p", "-t", TMUX_TARGET, "#{window_activity}"], { timeout: 2_000 }, (error, stdout) => {
|
|
57
|
+
if (error) {
|
|
58
|
+
resolve(null);
|
|
59
|
+
return;
|
|
60
|
+
}
|
|
61
|
+
const raw = stdout.toString().trim();
|
|
62
|
+
if (!raw) {
|
|
63
|
+
resolve(null);
|
|
64
|
+
return;
|
|
65
|
+
}
|
|
66
|
+
const value = Number(raw);
|
|
67
|
+
resolve(Number.isFinite(value) ? value : null);
|
|
68
|
+
});
|
|
69
|
+
});
|
|
70
|
+
}
|
|
71
|
+
/** Whether tmux itself reports an attached client for this private terminal.
|
|
72
|
+
* This remains authoritative if a parent-side attachment bookkeeping edge was
|
|
73
|
+
* missed during a transport failure. */
|
|
74
|
+
export function tmuxHasAttachedClient(name, tmuxBin = resolveTmuxBin()) {
|
|
75
|
+
const socketPath = tmuxSocketPath(name);
|
|
76
|
+
return new Promise((resolve) => {
|
|
77
|
+
execFile(tmuxBin, ["-S", socketPath, "list-clients", "-t", TMUX_TARGET, "-F", "#{client_name}"], { timeout: 2_000 }, (error, stdout) => {
|
|
78
|
+
resolve(!error && stdout.toString().split("\n").some((line) => line.trim().length > 0));
|
|
79
|
+
});
|
|
80
|
+
});
|
|
81
|
+
}
|
|
45
82
|
/** Kill one private tmux server and verify it no longer answers. Missing
|
|
46
83
|
* sockets are already stopped; an existing socket with an unusable tmux
|
|
47
84
|
* command is unproven and returns false. */
|
|
@@ -281,6 +318,19 @@ export class TmuxTerminal {
|
|
|
281
318
|
});
|
|
282
319
|
});
|
|
283
320
|
}
|
|
321
|
+
/** PID of the process currently owning the pane. */
|
|
322
|
+
panePid() {
|
|
323
|
+
if (!this.started)
|
|
324
|
+
return undefined;
|
|
325
|
+
try {
|
|
326
|
+
const value = execFileSync(this.tmuxBin, [...this.base(), "list-panes", "-t", TMUX_TARGET, "-F", "#{pane_pid}"], { stdio: ["ignore", "pipe", "ignore"] }).toString().trim().split(/\s+/)[0];
|
|
327
|
+
const pid = Number(value);
|
|
328
|
+
return Number.isSafeInteger(pid) && pid > 0 ? pid : undefined;
|
|
329
|
+
}
|
|
330
|
+
catch {
|
|
331
|
+
return undefined;
|
|
332
|
+
}
|
|
333
|
+
}
|
|
284
334
|
/** Type literal text into the pane (agent injection / co-drive from a
|
|
285
335
|
* non-PTY caller). `-l` sends the text literally rather than as key names. */
|
|
286
336
|
sendKeys(text) {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@rynx-ai/runtime",
|
|
3
|
-
"version": "0.1.11-beta.
|
|
3
|
+
"version": "0.1.11-beta.22",
|
|
4
4
|
"repository": {
|
|
5
5
|
"type": "git",
|
|
6
6
|
"url": "git+https://github.com/rynx-ai/rynx.git",
|
|
@@ -27,7 +27,7 @@
|
|
|
27
27
|
"node-pty": "1.2.0-beta.15",
|
|
28
28
|
"smol-toml": "1.7.1",
|
|
29
29
|
"ws": "^8.21.0",
|
|
30
|
-
"@rynx-ai/core": "0.1.11-beta.
|
|
30
|
+
"@rynx-ai/core": "0.1.11-beta.22"
|
|
31
31
|
},
|
|
32
32
|
"devDependencies": {
|
|
33
33
|
"@types/ws": "^8.18.1"
|