@rynx-ai/runtime 0.1.11-beta.20 → 0.1.11-beta.21

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/host.js CHANGED
@@ -955,8 +955,59 @@ export class LocalAgentHost {
955
955
  }
956
956
  live.canonicalInteractions.clear();
957
957
  };
958
+ const completeCurrentTurn = (usage, reason) => {
959
+ closeCanonicalInteractions();
960
+ if (!normalizer)
961
+ return;
962
+ const completedResponseId = currentResponseId;
963
+ if (usage) {
964
+ for (const se of normalizer.next({ type: "turn_completed", usage }))
965
+ emitCurrent(se);
966
+ }
967
+ for (const se of normalizer.next({ type: "done" })) {
968
+ if (reason === "superseded" && se.type === "session.status")
969
+ continue;
970
+ emitCurrent(se);
971
+ }
972
+ clearPendingInputsForResponse(completedResponseId);
973
+ normalizer = null;
974
+ currentResponseId = null;
975
+ };
976
+ const failCurrentTurn = (error) => {
977
+ closeCanonicalInteractions();
978
+ if (!normalizer)
979
+ return;
980
+ const failedResponseId = currentResponseId;
981
+ const responseStopped = error.message === "Codex turn was interrupted";
982
+ const providerName = runtime === "traex" ? "Traex" : "Codex";
983
+ const message = responseStopped
984
+ ? "Response stopped"
985
+ : runtime === "traex"
986
+ ? error.message.replace(/^Codex\b/, providerName)
987
+ : error.message;
988
+ for (const se of normalizer.fail({
989
+ code: responseStopped
990
+ ? "response_stopped"
991
+ : runtime === "traex" ? "traex_error" : "codex_error",
992
+ message,
993
+ source: "execution",
994
+ })) {
995
+ emitCurrent(se.type === "session.status"
996
+ ? { ...se, status: "failed", note: message }
997
+ : se);
998
+ }
999
+ clearPendingInputsForResponse(failedResponseId);
1000
+ normalizer = null;
1001
+ currentResponseId = null;
1002
+ };
958
1003
  const sink = {
959
1004
  onTurnStart: (turnId) => startNormalizer(turnId),
1005
+ onTurnObserved: (turnId) => {
1006
+ const n = startNormalizer(turnId);
1007
+ for (const event of n.next({ type: "turn_started", ...(turnId ? { turnId } : {}) })) {
1008
+ emitCurrent(event);
1009
+ }
1010
+ },
960
1011
  onUserMessage: (content) => {
961
1012
  const normalizedContent = typeof content === "string"
962
1013
  ? [{ type: "input_text", text: content }]
@@ -990,45 +1041,28 @@ export class LocalAgentHost {
990
1041
  ...(statusKind ? { statusKind } : {}),
991
1042
  });
992
1043
  },
993
- onTurnEnd: (usage) => {
994
- closeCanonicalInteractions();
995
- if (!normalizer)
1044
+ // A newer authoritative turn/started completes the old canonical
1045
+ // response without publishing a false idle edge in a running→running
1046
+ // transition.
1047
+ onTurnEnd: completeCurrentTurn,
1048
+ onRecoveredTurnStatus: (status, turnId, error) => {
1049
+ const responseId = turnId ? `resp_codex_${turnId}` : undefined;
1050
+ if (normalizer && (!responseId || currentResponseId === responseId)) {
1051
+ if (status === "failed")
1052
+ failCurrentTurn(error ?? new Error("Codex turn failed"));
1053
+ else
1054
+ completeCurrentTurn();
996
1055
  return;
997
- const completedResponseId = currentResponseId;
998
- if (usage) {
999
- for (const se of normalizer.next({ type: "turn_completed", usage }))
1000
- emitCurrent(se);
1001
1056
  }
1002
- for (const se of normalizer.next({ type: "done" }))
1003
- emitCurrent(se);
1004
- clearPendingInputsForResponse(completedResponseId);
1005
- normalizer = null;
1006
- currentResponseId = null;
1007
- },
1008
- onTurnError: (error) => {
1009
- closeCanonicalInteractions();
1010
- if (!normalizer)
1011
- return;
1012
- const failedResponseId = currentResponseId;
1013
- const responseStopped = error.message === "Codex turn was interrupted";
1014
- const providerName = runtime === "traex" ? "Traex" : "Codex";
1015
- const message = responseStopped
1016
- ? "Response stopped"
1017
- : runtime === "traex"
1018
- ? error.message.replace(/^Codex\b/, providerName)
1019
- : error.message;
1020
- for (const se of normalizer.fail({
1021
- code: responseStopped
1022
- ? "response_stopped"
1023
- : runtime === "traex" ? "traex_error" : "codex_error",
1024
- message,
1025
- source: "execution",
1026
- }))
1027
- emitCurrent(se);
1028
- clearPendingInputsForResponse(failedResponseId);
1029
- normalizer = null;
1030
- currentResponseId = null;
1057
+ emitCurrent({
1058
+ type: "session.status",
1059
+ sessionId: currentSessionId,
1060
+ ...(responseId ? { responseId } : {}),
1061
+ status,
1062
+ ...(error ? { note: error.message } : {}),
1063
+ });
1031
1064
  },
1065
+ onTurnError: failCurrentTurn,
1032
1066
  // A resumed TUI may rebroadcast `thread/started`; binding is idempotent.
1033
1067
  shouldIgnoreThreadStarted: (threadId, forkedFromId) => this.shouldIgnoreManagedForkThreadStarted(live, threadId, forkedFromId),
1034
1068
  onThreadStarted: (threadId, forkedFromId) => this.onLiveThreadStarted(live, localThreadId, threadId, forkedFromId),
@@ -1140,6 +1174,7 @@ export class LocalAgentHost {
1140
1174
  }
1141
1175
  }
1142
1176
  if (resumedThreadId) {
1177
+ forwarder.noteThreadBound(resumedThreadId);
1143
1178
  this.onLiveThreadStarted(live, localThreadId, resumedThreadId);
1144
1179
  }
1145
1180
  else if (resumeError &&
@@ -1238,8 +1273,9 @@ export class LocalAgentHost {
1238
1273
  * `_subscribe_until_ready`). A fresh TUI thread has no rollout until its first
1239
1274
  * turn, so `thread/resume` is retried: park until the forwarder observes the
1240
1275
  * thread active, then retry. Resume only fetches the newest summarized Turn:
1241
- * recovery reconciles the exact active Turn's terminal status and never replays
1242
- * historical items. Once resume succeeds, subsequent turns arrive live.
1276
+ * recovery reconciles the exact active Turn or publishes the newest explicit
1277
+ * terminal status without replaying historical items. Once resume succeeds,
1278
+ * subsequent turns arrive live.
1243
1279
  */
1244
1280
  async subscribeUntilReady(live, threadId) {
1245
1281
  while (!live.stopped) {
@@ -1255,13 +1291,12 @@ export class LocalAgentHost {
1255
1291
  },
1256
1292
  });
1257
1293
  // Codex 0.146.1 accepts initialTurnsPage but can still return the entire
1258
- // rollout in chronological order. Never infer newest from array position:
1259
- // recovery may settle only the exact active turn id already owned here.
1260
- const activeTurnId = live.forwarder.currentTurnId();
1261
- const activeTurn = activeTurnId && Array.isArray(resp.thread.turns)
1262
- ? resp.thread.turns.find((turn) => (turn.id ?? turn.turnId) === activeTurnId)
1263
- : undefined;
1264
- live.forwarder.reconcileActiveTurn(activeTurn);
1294
+ // rollout in chronological order. An identified active turn is matched
1295
+ // by id; the no-active fallback reads only the final explicit status.
1296
+ const turns = Array.isArray(resp.thread.turns)
1297
+ ? resp.thread.turns
1298
+ : [];
1299
+ live.forwarder.reconcileResumeTurns(turns);
1265
1300
  return true; // subscribed — live item/turn notifications now flow to the forwarder
1266
1301
  }
1267
1302
  catch (error) {
@@ -1476,30 +1511,11 @@ export class LocalAgentHost {
1476
1511
  const turnId = live.forwarder.currentTurnId();
1477
1512
  if (turnId) {
1478
1513
  injectionMethod = "turn/steer";
1479
- let steered;
1480
- let retryIndex = 0;
1481
- while (!steered) {
1482
- try {
1483
- steered = await live.injectClient.turnSteer({
1484
- threadId,
1485
- expectedTurnId: turnId,
1486
- input: nativeInput,
1487
- });
1488
- }
1489
- catch (error) {
1490
- const delayMs = TURN_STEER_ACTIVATION_RETRY_DELAYS_MS[retryIndex];
1491
- if (delayMs === undefined ||
1492
- !isTransientTurnSteerActivationRace(error) ||
1493
- live.stopped ||
1494
- live.rotationPending ||
1495
- !live.forwarder.isTurnOpen() ||
1496
- live.forwarder.currentTurnId() !== turnId) {
1497
- throw error;
1498
- }
1499
- retryIndex += 1;
1500
- await new Promise((resolve) => setTimeout(resolve, delayMs));
1501
- }
1502
- }
1514
+ const steered = await live.injectClient.turnSteer({
1515
+ threadId,
1516
+ expectedTurnId: turnId,
1517
+ input: nativeInput,
1518
+ });
1503
1519
  if (pendingInput.state === "prepublished") {
1504
1520
  // The caller already persisted and published this user input
1505
1521
  // before waiting for the native Terminal to become ready.
@@ -1515,7 +1531,7 @@ export class LocalAgentHost {
1515
1531
  if (!live.observerAvailable) {
1516
1532
  this.armObserverReconnectDeadline(live);
1517
1533
  }
1518
- return "injected";
1534
+ return "steered";
1519
1535
  }
1520
1536
  }
1521
1537
  // Carry the agent-spec model on the turn so a web-injected turn runs the
@@ -1988,6 +2004,17 @@ export class LocalAgentHost {
1988
2004
  emitCurrent(se);
1989
2005
  },
1990
2006
  onInteraction: forwardInteraction,
2007
+ onStatus: (status, blockedOn) => {
2008
+ const responseId = currentResponseId ??
2009
+ live.pendingInjectedInputs.find((entry) => entry.responseId)?.responseId;
2010
+ emitCurrent({
2011
+ type: "session.status",
2012
+ sessionId: currentSessionId,
2013
+ ...(responseId ? { responseId } : {}),
2014
+ status,
2015
+ ...(blockedOn ? { note: blockedOn } : {}),
2016
+ });
2017
+ },
1991
2018
  onTurnEnd: (usage) => {
1992
2019
  if (!normalizer)
1993
2020
  return;
@@ -2028,16 +2055,20 @@ export class LocalAgentHost {
2028
2055
  });
2029
2056
  }
2030
2057
  },
2031
- onTurnError: () => {
2058
+ onTurnError: (error) => {
2032
2059
  if (!normalizer)
2033
2060
  return;
2034
2061
  const rid = currentResponseId;
2062
+ const message = error.message || "Agent turn failed";
2035
2063
  for (const se of normalizer.fail({
2036
2064
  code: "agent_error",
2037
- message: "Agent turn failed",
2065
+ message,
2038
2066
  source: "execution",
2039
- }))
2040
- emitCurrent(se);
2067
+ })) {
2068
+ emitCurrent(se.type === "session.status"
2069
+ ? { ...se, status: "failed", note: message }
2070
+ : se);
2071
+ }
2041
2072
  live.pendingInjectedInputs = live.pendingInjectedInputs.filter((entry) => entry.responseId !== rid);
2042
2073
  normalizer = null;
2043
2074
  currentResponseId = undefined;
@@ -2196,14 +2227,18 @@ export class LocalAgentHost {
2196
2227
  * (they inject via the app-server). Idempotent. */
2197
2228
  attachTerminalInjector(localThreadId, injector) {
2198
2229
  const live = this.liveClaudeSessions.get(localThreadId);
2199
- if (live)
2230
+ if (live) {
2231
+ if (live.injector === injector)
2232
+ return;
2200
2233
  live.injector = injector;
2234
+ live.forwarder.attachStatusSource({ panePid: () => injector.panePid?.() });
2235
+ }
2201
2236
  }
2202
2237
  /** Close an active native response when its terminal or runner disappears. */
2203
2238
  failLiveSession(localThreadId, error) {
2204
2239
  const claude = this.liveClaudeSessions.get(localThreadId);
2205
2240
  if (claude)
2206
- return claude.forwarder.failOpenTurn(error);
2241
+ return claude.forwarder.noteTerminalExit(error);
2207
2242
  const codex = this.liveSessions.get(localThreadId);
2208
2243
  if (!codex)
2209
2244
  return false;
@@ -2387,20 +2422,6 @@ function codexRpcError(error, method) {
2387
2422
  }
2388
2423
  return `Codex ${method} failed: ${error instanceof Error ? error.message : String(error)}`;
2389
2424
  }
2390
- /**
2391
- * Codex 0.146 can acknowledge `turn/start` a few milliseconds before its
2392
- * internal active-turn pointer becomes visible to `turn/steer`. The injection
2393
- * lock still prevents double-starts, but the immediately following steer must
2394
- * briefly retry the same expected turn id. Keep this narrow: all other RPC
2395
- * errors remain terminal and visible to the caller.
2396
- */
2397
- function isTransientTurnSteerActivationRace(error) {
2398
- return error instanceof CodexTransportError &&
2399
- error.code === -32600 &&
2400
- (/no active turn to steer/i.test(error.message) ||
2401
- /expected active turn id\b.+\bfound\b/i.test(error.message));
2402
- }
2403
- const TURN_STEER_ACTIVATION_RETRY_DELAYS_MS = [10, 20, 40, 80, 160, 250, 440];
2404
2425
  export function parseCodexLoginStatus(exitCode, output) {
2405
2426
  const normalized = output.trim();
2406
2427
  const match = normalized.match(/Logged in using (.+)$/im);
@@ -304,7 +304,7 @@ export class RunnerSession {
304
304
  const outcome = (await provider.injectMessage?.(msg.localThreadId, input)) ?? "notLive";
305
305
  // App-server injection is independent of the Terminal TUI startup, so it
306
306
  // must not cancel prompt handling for the pane that is still starting.
307
- const error = outcome === "injected"
307
+ const error = outcome === "injected" || outcome === "steered"
308
308
  ? undefined
309
309
  : provider.liveSessionError?.(msg.localThreadId);
310
310
  this.transport.send({
@@ -43,9 +43,10 @@ export interface RunnerManagerOptions {
43
43
  runnerEntry?: string;
44
44
  /** Idle TTL (ms) after which an unused runner is reaped. */
45
45
  idleTtlMs?: number;
46
- /** Maximum time without user/control activity that an active response may
47
- * protect a detached runner from idle reaping. */
48
- staleActiveTtlMs?: number;
46
+ /** Native pane idle window. `<= 0` disables native-pane idle reaping. */
47
+ nativePaneIdleTtlMs?: number;
48
+ /** Recent tmux-output window used as independent native-pane busy evidence. */
49
+ nativePaneOutputBusyWindowMs?: number;
49
50
  /** Background reap sweep interval (ms); `0` disables the timer (tests). */
50
51
  reapIntervalMs?: number;
51
52
  /** Grace period between SIGTERM and SIGKILL during runner shutdown. */
@@ -75,7 +76,13 @@ export interface RunnerManagerOptions {
75
76
  /** Injected for tests. Kills and verifies the deterministic private tmux
76
77
  * server owned by a Session runner. */
77
78
  terminateTerminalServer?: (terminalName: string) => boolean | Promise<boolean>;
79
+ /** Injected tmux `#{window_activity}` reader. Returns epoch seconds. */
80
+ terminalWindowActivityAt?: (terminalName: string) => number | null | Promise<number | null>;
81
+ /** Injected tmux attached-client probe. */
82
+ terminalHasAttachedClient?: (terminalName: string) => boolean | Promise<boolean>;
78
83
  now?: () => number;
84
+ /** Wall clock used to compare tmux's epoch timestamp. */
85
+ wallNow?: () => number;
79
86
  /** Extra env merged into every spawned runner child — e.g. the control-plane
80
87
  * URL + token so a claude-native PermissionRequest hook can POST back. */
81
88
  childEnv?: Record<string, string>;
@@ -142,7 +149,8 @@ export declare class RunnerManager implements AgentCapabilities {
142
149
  private readonly sessionStore;
143
150
  private readonly runnerEntry;
144
151
  private readonly idleTtlMs;
145
- private readonly staleActiveTtlMs;
152
+ private readonly nativePaneIdleTtlMs;
153
+ private readonly nativePaneOutputBusyWindowMs;
146
154
  private readonly spawn;
147
155
  private readonly childEnv;
148
156
  private readonly sessionContextProvider;
@@ -159,12 +167,16 @@ export declare class RunnerManager implements AgentCapabilities {
159
167
  private readonly shutdownKillGraceMs;
160
168
  private readonly signalChild;
161
169
  private readonly terminateTerminalServer;
170
+ private readonly terminalWindowActivityAt;
171
+ private readonly terminalHasAttachedClient;
172
+ private readonly wallNow;
162
173
  private readonly liveStartTimeoutMs;
163
174
  private readonly liveReadyTimeoutMs;
164
175
  private readonly nativeLiveStartTimeoutMs;
165
176
  private readonly liveInterruptTimeoutMs;
166
177
  private readonly terminalInputHandoffTimeoutMs;
167
178
  private readonly terminalInputCleanupRetryMs;
179
+ private reapPromise;
168
180
  private stopping;
169
181
  private stopPromise;
170
182
  /** Sink for mirrored {@link SessionEvent}s from every session's forwarder. */
@@ -321,14 +333,17 @@ export declare class RunnerManager implements AgentCapabilities {
321
333
  private failHandle;
322
334
  private terminateHandle;
323
335
  private reapIdle;
324
- /** Close server-side runtime state before fencing a Provider that stayed
325
- * active past its hard inactivity deadline. The child transport is closed by
326
- * terminateHandle immediately afterwards, so these are the final events for
327
- * the abandoned responses. */
328
- private failStaleActiveResponses;
336
+ private reapIdleOnce;
337
+ /** Provider-authoritative turn state is the lifecycle truth; attached clients
338
+ * and tmux's own activity clock are independent busy evidence. There is no
339
+ * "user was inactive for an hour" override for an active turn. */
340
+ private reapNativePane;
341
+ private isNativePaneBusy;
342
+ private isManagedNativeHandle;
329
343
  private failActiveResponses;
330
- /** Track only response lifecycle, not output volume. Output deltas from a
331
- * runaway TUI/Provider must not refresh the stale-active deadline. */
344
+ /** Track the provider-authoritative response lifecycle. Native-pane busy
345
+ * classification consumes this level directly; output volume is separately
346
+ * grounded in tmux's own activity clock. */
332
347
  private observeHandleRuntimeEvent;
333
348
  private openSessionContext;
334
349
  }
@@ -22,7 +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
+ import { terminateTmuxServer, tmuxHasAttachedClient, tmuxWindowActivityAt, } from "../terminal/tmux.js";
26
26
  import { fromWireError, } from "./protocol.js";
27
27
  import { isManagedNativeProvider } from "./startup-policy.js";
28
28
  import { StdioRunnerTransport } from "./transport.js";
@@ -51,12 +51,10 @@ const DEFAULT_TERMINAL_INPUT_HANDOFF_TIMEOUT_MS = 30_000;
51
51
  /** Retry transient terminal-server cleanup without spinning forever. */
52
52
  const DEFAULT_TERMINAL_INPUT_CLEANUP_RETRY_MS = 1_000;
53
53
  const MAX_TERMINAL_INPUT_CLEANUP_RETRY_MS = 30_000;
54
- /** A Provider that never closes its active response must not keep a detached
55
- * runner + tmux server alive forever. This is intentionally much longer than
56
- * the normal idle TTL so legitimate long-running turns are not treated as
57
- * stale. User input/control activity refreshes the deadline; Provider output
58
- * does not, because a runaway redraw loop is the failure mode this bounds. */
59
- const DEFAULT_STALE_ACTIVE_TTL_MS = 60 * 60_000;
54
+ /** Full idle window before an inactive native pane becomes reapable. */
55
+ const DEFAULT_NATIVE_PANE_IDLE_TTL_MS = 60 * 60_000;
56
+ /** tmux output this recent independently proves that a native pane is busy. */
57
+ const DEFAULT_NATIVE_PANE_OUTPUT_BUSY_WINDOW_MS = 120_000;
60
58
  /** Keep per-Session spawn context small enough to remain an environment handoff,
61
59
  * not an unbounded transport. */
62
60
  const MAX_SESSION_CONTEXT_ENV_ENTRIES = 32;
@@ -157,7 +155,8 @@ export class RunnerManager {
157
155
  sessionStore;
158
156
  runnerEntry;
159
157
  idleTtlMs;
160
- staleActiveTtlMs;
158
+ nativePaneIdleTtlMs;
159
+ nativePaneOutputBusyWindowMs;
161
160
  spawn;
162
161
  childEnv;
163
162
  sessionContextProvider;
@@ -174,12 +173,16 @@ export class RunnerManager {
174
173
  shutdownKillGraceMs;
175
174
  signalChild;
176
175
  terminateTerminalServer;
176
+ terminalWindowActivityAt;
177
+ terminalHasAttachedClient;
178
+ wallNow;
177
179
  liveStartTimeoutMs;
178
180
  liveReadyTimeoutMs;
179
181
  nativeLiveStartTimeoutMs;
180
182
  liveInterruptTimeoutMs;
181
183
  terminalInputHandoffTimeoutMs;
182
184
  terminalInputCleanupRetryMs;
185
+ reapPromise = null;
183
186
  stopping = false;
184
187
  stopPromise;
185
188
  /** Sink for mirrored {@link SessionEvent}s from every session's forwarder. */
@@ -213,18 +216,22 @@ export class RunnerManager {
213
216
  this.sessionStore = opts.sessionStore;
214
217
  this.runnerEntry = opts.runnerEntry ?? defaultRunnerEntry();
215
218
  this.idleTtlMs = opts.idleTtlMs ?? 300_000;
216
- this.staleActiveTtlMs = Math.max(this.idleTtlMs, opts.staleActiveTtlMs ?? DEFAULT_STALE_ACTIVE_TTL_MS);
219
+ this.nativePaneIdleTtlMs = opts.nativePaneIdleTtlMs ?? DEFAULT_NATIVE_PANE_IDLE_TTL_MS;
220
+ this.nativePaneOutputBusyWindowMs = Math.max(0, opts.nativePaneOutputBusyWindowMs ?? DEFAULT_NATIVE_PANE_OUTPUT_BUSY_WINDOW_MS);
217
221
  this.spawn = opts.spawn ?? nodeSpawn;
218
222
  this.childEnv = opts.childEnv ?? {};
219
223
  this.sessionContextProvider = opts.sessionContextProvider;
220
224
  this.admissionOpen = opts.admissionOpen ?? (() => true);
221
225
  this.admissionReserve = opts.admissionReserve;
222
226
  this.now = opts.now ?? (() => Date.now());
227
+ this.wallNow = opts.wallNow ?? (() => Date.now());
223
228
  this.defaultRuntime = opts.config.DEFAULT_RUNTIME ?? "codex";
224
229
  this.shutdownGraceMs = Math.max(0, opts.shutdownGraceMs ?? DEFAULT_SHUTDOWN_GRACE_MS);
225
230
  this.shutdownKillGraceMs = Math.max(0, opts.shutdownKillGraceMs ?? DEFAULT_SHUTDOWN_KILL_GRACE_MS);
226
231
  this.signalChild = opts.signalChild ?? signalRunnerChild;
227
232
  this.terminateTerminalServer = opts.terminateTerminalServer ?? terminateTmuxServer;
233
+ this.terminalWindowActivityAt = opts.terminalWindowActivityAt ?? tmuxWindowActivityAt;
234
+ this.terminalHasAttachedClient = opts.terminalHasAttachedClient ?? tmuxHasAttachedClient;
228
235
  this.liveStartTimeoutMs = Math.max(1, opts.liveStartTimeoutMs ?? DEFAULT_LIVE_START_TIMEOUT_MS);
229
236
  this.liveReadyTimeoutMs = Math.max(1, opts.liveReadyTimeoutMs ?? DEFAULT_LIVE_READY_TIMEOUT_MS);
230
237
  this.nativeLiveStartTimeoutMs = Math.max(1, opts.nativeLiveStartTimeoutMs ?? DEFAULT_NATIVE_LIVE_START_TIMEOUT_MS);
@@ -233,7 +240,7 @@ export class RunnerManager {
233
240
  this.terminalInputCleanupRetryMs = Math.max(1, opts.terminalInputCleanupRetryMs ?? DEFAULT_TERMINAL_INPUT_CLEANUP_RETRY_MS);
234
241
  const reapIntervalMs = opts.reapIntervalMs ?? 60_000;
235
242
  if (reapIntervalMs > 0) {
236
- this.reapTimer = setInterval(() => this.reapIdle(), reapIntervalMs);
243
+ this.reapTimer = setInterval(() => void this.reapIdle(), reapIntervalMs);
237
244
  this.reapTimer.unref?.();
238
245
  }
239
246
  else {
@@ -648,7 +655,7 @@ export class RunnerManager {
648
655
  handle.live.set(reqId, (res) => {
649
656
  const outcome = res.outcome ?? "failed";
650
657
  const finish = () => {
651
- if (outcome === "injected") {
658
+ if (outcome === "injected" || outcome === "steered") {
652
659
  this.liveErrors.delete(localThreadId);
653
660
  }
654
661
  else {
@@ -902,7 +909,6 @@ export class RunnerManager {
902
909
  !allowReservedForkTarget) {
903
910
  throw new AgentRuntimeError("Session fork is still being committed", 409, "session_fork_pending");
904
911
  }
905
- this.reapIdle();
906
912
  const existing = this.handles.get(key);
907
913
  if (existing && !existing.dead) {
908
914
  existing.lastUsedAt = this.now();
@@ -1369,8 +1375,24 @@ export class RunnerManager {
1369
1375
  return attempt;
1370
1376
  }
1371
1377
  reapIdle() {
1378
+ if (this.reapPromise)
1379
+ return this.reapPromise;
1380
+ const attempt = this.reapIdleOnce();
1381
+ this.reapPromise = attempt;
1382
+ const clear = () => {
1383
+ if (this.reapPromise === attempt)
1384
+ this.reapPromise = null;
1385
+ };
1386
+ void attempt.then(clear, clear);
1387
+ return attempt;
1388
+ }
1389
+ async reapIdleOnce() {
1372
1390
  const now = this.now();
1373
1391
  for (const [key, handle] of this.handles) {
1392
+ if (this.isManagedNativeHandle(handle)) {
1393
+ await this.reapNativePane(handle, now);
1394
+ continue;
1395
+ }
1374
1396
  if (handle.caps.size > 0 || handle.terminals.size > 0 || handle.live.size > 0) {
1375
1397
  continue;
1376
1398
  }
@@ -1379,28 +1401,74 @@ export class RunnerManager {
1379
1401
  continue;
1380
1402
  }
1381
1403
  const hasActiveResponse = handle.activeResponseIds.size > 0;
1382
- if (hasActiveResponse && idleForMs < this.staleActiveTtlMs) {
1404
+ if (hasActiveResponse)
1383
1405
  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
- }
1406
+ const reason = this.liveSessionKeys.has(key)
1407
+ ? "idle live runner reaped"
1408
+ : "idle runner reaped";
1393
1409
  void this.terminateHandle(handle, reason).catch((error) => {
1394
1410
  logTerminationFailure(handle, error);
1395
1411
  });
1396
1412
  }
1397
1413
  }
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`);
1414
+ /** Provider-authoritative turn state is the lifecycle truth; attached clients
1415
+ * and tmux's own activity clock are independent busy evidence. There is no
1416
+ * "user was inactive for an hour" override for an active turn. */
1417
+ async reapNativePane(handle, now) {
1418
+ if (this.nativePaneIdleTtlMs <= 0 || handle.dead)
1419
+ return;
1420
+ if (await this.isNativePaneBusy(handle)) {
1421
+ handle.nativePaneLastBusyAt = now;
1422
+ return;
1423
+ }
1424
+ const lastBusyAt = handle.nativePaneLastBusyAt;
1425
+ if (lastBusyAt === undefined) {
1426
+ // First idle observation starts a full grace window.
1427
+ handle.nativePaneLastBusyAt = now;
1428
+ return;
1429
+ }
1430
+ if (now - lastBusyAt < this.nativePaneIdleTtlMs)
1431
+ return;
1432
+ // Close the select→reap race: activity may begin after classification but
1433
+ // before teardown.
1434
+ if (await this.isNativePaneBusy(handle)) {
1435
+ handle.nativePaneLastBusyAt = this.now();
1436
+ return;
1437
+ }
1438
+ // A failed teardown must re-arm on the next scan instead of retrying on
1439
+ // every sweep forever.
1440
+ delete handle.nativePaneLastBusyAt;
1441
+ await this.terminateHandle(handle, "idle native pane reaped").catch((error) => logTerminationFailure(handle, error));
1442
+ }
1443
+ async isNativePaneBusy(handle) {
1444
+ if (handle.activeResponseIds.size > 0 ||
1445
+ handle.caps.size > 0 ||
1446
+ handle.live.size > 0 ||
1447
+ handle.terminals.size > 0) {
1448
+ return true;
1449
+ }
1450
+ const terminalName = `${handle.key}-main`;
1451
+ try {
1452
+ if (await this.terminalHasAttachedClient(terminalName))
1453
+ return true;
1454
+ }
1455
+ catch {
1456
+ // A failed probe contributes no busy evidence; check activity next.
1457
+ }
1458
+ let activityAt = null;
1459
+ try {
1460
+ activityAt = await this.terminalWindowActivityAt(terminalName);
1461
+ }
1462
+ catch {
1463
+ // A failed probe contributes no busy evidence.
1464
+ }
1465
+ return activityAt !== null &&
1466
+ this.wallNow() - activityAt * 1_000 < this.nativePaneOutputBusyWindowMs;
1467
+ }
1468
+ isManagedNativeHandle(handle) {
1469
+ const sessionId = this.currentTerminalSessionId(handle, handle.key);
1470
+ const provider = this.liveOptions.get(sessionId)?.execution.provider;
1471
+ return isManagedNativeProvider(provider);
1404
1472
  }
1405
1473
  failActiveResponses(handle, code, message) {
1406
1474
  const responseIds = [...handle.activeResponseIds];
@@ -1418,8 +1486,9 @@ export class RunnerManager {
1418
1486
  });
1419
1487
  }
1420
1488
  }
1421
- /** Track only response lifecycle, not output volume. Output deltas from a
1422
- * runaway TUI/Provider must not refresh the stale-active deadline. */
1489
+ /** Track the provider-authoritative response lifecycle. Native-pane busy
1490
+ * classification consumes this level directly; output volume is separately
1491
+ * grounded in tmux's own activity clock. */
1423
1492
  observeHandleRuntimeEvent(handle, event) {
1424
1493
  switch (event.type) {
1425
1494
  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 = {
@@ -171,9 +165,9 @@ export type FromChild = {
171
165
  ok: boolean;
172
166
  error?: string;
173
167
  }
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`. */
168
+ /** Result of an `inject`: `outcome` distinguishes a new turn, an active-turn
169
+ * steer, and failures so the caller never falls back to a second output path.
170
+ * Echoes the request `reqId`. */
177
171
  | {
178
172
  t: "injected";
179
173
  reqId: string;