@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.
@@ -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
- /** 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. */
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
- /** 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;
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
- staleActiveTtlMs;
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.staleActiveTtlMs = Math.max(this.idleTtlMs, opts.staleActiveTtlMs ?? DEFAULT_STALE_ACTIVE_TTL_MS);
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 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.
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 timeoutMs = !waitForReady
547
- ? this.liveStartTimeoutMs
548
- : opts.execution.provider === "claude"
549
- ? this.nativeLiveStartTimeoutMs
550
- : isManagedNativeProvider(opts.execution.provider)
551
- ? this.liveStartTimeoutMs
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 = setTimeout(() => {
554
- if (!handle.live.delete(reqId))
555
- return;
556
- const finishTimeout = () => {
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`
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
- this.liveErrors.set(localThreadId, reason);
566
- this.liveSessionKeys.delete(localThreadId);
567
- // A child that cannot answer a bounded control round-trip is unsafe
568
- // to reuse. Reap it so the next click gets a fresh runner.
569
- void this.terminateHandle(handle, reason).catch((error) => {
570
- logTerminationFailure(handle, error);
571
- });
572
- return false;
573
- };
574
- const reservation = allowReservedForkTarget
575
- ? undefined
576
- : this.forkReservations.get(localThreadId);
577
- if (reservation) {
578
- void reservation
579
- .then(() => finishTimeout())
580
- .then(resolve, () => resolve(false));
581
- return;
582
- }
583
- resolve(finishTimeout());
584
- }, timeoutMs);
585
- timeout.unref?.();
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
- clearTimeout(timeout);
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 ?? "live session did not become ready");
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 ?? `live injection ${outcome}`);
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({ ok: false, error: message });
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 && idleForMs < this.staleActiveTtlMs) {
1430
+ if (hasActiveResponse)
1383
1431
  continue;
1384
- }
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
- }
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
- /** 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`);
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 only response lifecycle, not output volume. Output deltas from a
1422
- * runaway TUI/Provider must not refresh the stale-active deadline. */
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. For Codex-lineage fresh sessions, `live.ready` acknowledges that
74
- * the pane and observer exist; background thread discovery races the
75
- * executor's bridge wait. */
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 the pane
165
- * and observer started; thread discovery continues in parallel with
166
- * injection. Other Providers retain their own readiness gate. */
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?: string;
167
+ error?: WireError;
173
168
  }
174
- /** Result of an `inject`: `outcome` distinguishes injected / notLive / notReady /
175
- * failed so the caller never falls back to a second output path on a live
176
- * session. Echoes the request `reqId`. */
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?: string;
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?: string;
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
+ }
@@ -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;
@@ -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.20",
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.20"
30
+ "@rynx-ai/core": "0.1.11-beta.22"
31
31
  },
32
32
  "devDependencies": {
33
33
  "@types/ws": "^8.18.1"