@rynx-ai/runtime 0.1.11-beta.22 → 0.1.11-beta.23

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.
@@ -11,7 +11,7 @@ import { type AgentCapabilities, type LiveSessionFailure, type ResolvedExecution
11
11
  import type { SessionEvent } from "@rynx-ai/core";
12
12
  import type { TerminalInjector } from "../claude/native-integration.js";
13
13
  import type { ResolveInteractionResult } from "../interactions.js";
14
- import { type InjectOutcome } from "./protocol.js";
14
+ import { type InjectResult } from "./protocol.js";
15
15
  import type { ChildTransport } from "./transport.js";
16
16
  /** Re-target the mirror to a freshly minted rynx session (claude `/clear`·`/fork`)
17
17
  * + record the terminal transfer with the daemon. The child owns the transport,
@@ -50,7 +50,11 @@ interface LiveCodexProvider {
50
50
  liveSessionFailure?(localThreadId: string): LiveSessionFailure | undefined;
51
51
  failLiveStartup?(localThreadId: string, error: Error): boolean;
52
52
  failLiveSession?(localThreadId: string, error: Error): boolean;
53
- injectMessage?(localThreadId: string, input: RuntimeUserInput | string): Promise<InjectOutcome>;
53
+ /** Codex/Traex auxiliary Terminal lifecycle: settle the current Turn, stop
54
+ * observer/forwarder, and evict the owned app-server while preserving the
55
+ * durable native-session binding for a later cold resume. */
56
+ teardownLiveCodexSession?(localThreadId: string, error?: Error): boolean;
57
+ injectMessage?(localThreadId: string, input: RuntimeUserInput | string): Promise<InjectResult>;
54
58
  interruptLive?(localThreadId: string): Promise<boolean>;
55
59
  stopLiveCodexSession?(localThreadId: string, opts?: {
56
60
  deferClaudeInteractionCleanup?: boolean;
@@ -30,10 +30,15 @@ function nativePhaseError(runtime, code, phase, cause) {
30
30
  return new AgentRuntimeError(`${providerDisplayName(runtime)} ${phase}${detail}`, 503, code);
31
31
  }
32
32
  function terminalFailureDetail(pane) {
33
+ // A captured pane is a screen snapshot, not stderr. Do not present ordinary
34
+ // TUI chrome ("Working", shortcuts, model/status bars) as the process's exit
35
+ // cause. Retain only lines that plausibly carry an actual startup/runtime
36
+ // failure; the structured error still reports the proven pane exit itself.
33
37
  return pane
34
38
  .split("\n")
35
39
  .map((line) => line.trim())
36
40
  .filter(Boolean)
41
+ .filter((line) => /\b(?:error|fatal|failed|failure|panic|refused|denied|unauthorized|invalid)\b/i.test(line))
37
42
  .slice(-6)
38
43
  .join(" ")
39
44
  .slice(-1_000);
@@ -347,7 +352,7 @@ export class RunnerSession {
347
352
  * already-rejected readiness promise. */
348
353
  failCodexThreadStartup(localThreadId, provider, error) {
349
354
  if (!provider.failLiveStartup?.(localThreadId, error))
350
- return;
355
+ return false;
351
356
  const watcher = this.terminalWatchers.get(localThreadId);
352
357
  if (watcher)
353
358
  clearInterval(watcher);
@@ -359,27 +364,34 @@ export class RunnerSession {
359
364
  catch (closeError) {
360
365
  console.warn(`[runner] session=${localThreadId} failed to close native Terminal after startup failure: ${closeError instanceof Error ? closeError.message : String(closeError)}`);
361
366
  }
362
- provider.stopLiveCodexSession?.(localThreadId);
367
+ if (provider.teardownLiveCodexSession) {
368
+ provider.teardownLiveCodexSession(localThreadId);
369
+ }
370
+ else {
371
+ provider.stopLiveCodexSession?.(localThreadId);
372
+ }
363
373
  this.liveIds.delete(localThreadId);
374
+ return true;
364
375
  }
365
376
  async inject(msg) {
366
377
  const provider = this.liveProvider;
367
378
  try {
368
379
  const input = msg.input ?? msg.text;
369
- const outcome = (await provider.injectMessage?.(msg.localThreadId, input)) ?? "notLive";
380
+ const result = (await provider.injectMessage?.(msg.localThreadId, input))
381
+ ?? { outcome: "notLive" };
370
382
  // App-server injection is independent of the Terminal TUI startup, so it
371
383
  // must not cancel prompt handling for the pane that is still starting.
372
- const error = outcome === "injected" || outcome === "steered"
384
+ const error = result.outcome === "injected" || result.outcome === "steered"
373
385
  ? undefined
374
386
  : providerFailure(provider, msg.localThreadId, {
375
- code: outcome === "notReady"
387
+ code: result.outcome === "notReady"
376
388
  ? "native_thread_not_ready"
377
- : outcome === "notLive"
389
+ : result.outcome === "notLive"
378
390
  ? "native_session_not_live"
379
391
  : "native_message_injection_failed",
380
- message: outcome === "notReady"
392
+ message: result.outcome === "notReady"
381
393
  ? "Native session was not ready for message injection"
382
- : outcome === "notLive"
394
+ : result.outcome === "notLive"
383
395
  ? "Native session was not live for message injection"
384
396
  : "Native message injection failed",
385
397
  });
@@ -387,7 +399,7 @@ export class RunnerSession {
387
399
  t: "injected",
388
400
  reqId: msg.reqId,
389
401
  localThreadId: msg.localThreadId,
390
- outcome,
402
+ result,
391
403
  ...(error ? { error } : {}),
392
404
  });
393
405
  }
@@ -396,7 +408,7 @@ export class RunnerSession {
396
408
  t: "injected",
397
409
  reqId: msg.reqId,
398
410
  localThreadId: msg.localThreadId,
399
- outcome: "failed",
411
+ result: { outcome: "failed" },
400
412
  error: toWireError(error),
401
413
  });
402
414
  }
@@ -452,17 +464,50 @@ export class RunnerSession {
452
464
  if (checking || this.shuttingDown || this.terminals.get(terminalId) !== terminal)
453
465
  return;
454
466
  checking = true;
455
- const probe = terminal.isAliveAsync?.() ?? Promise.resolve(terminal.isAlive());
456
- void probe.then((alive) => {
457
- if (alive || this.shuttingDown || this.terminals.get(terminalId) !== terminal)
467
+ const probe = terminal.lifecycleLivenessAsync
468
+ ? terminal.lifecycleLivenessAsync()
469
+ : terminal.livenessAsync
470
+ ? terminal.livenessAsync()
471
+ : terminal.isAliveAsync
472
+ ? terminal.isAliveAsync().then((alive) => alive ? "alive" : "dead")
473
+ : Promise.resolve(terminal.isAlive() ? "alive" : "dead");
474
+ void probe.then((liveness) => {
475
+ // A failed pane probe is inconclusive. Only Omnigent's lifecycle
476
+ // evidence — capture target gone or explicit pane_dead — may fail it.
477
+ if (liveness !== "dead" ||
478
+ this.shuttingDown ||
479
+ this.terminals.get(terminalId) !== terminal)
458
480
  return;
459
481
  clearInterval(timer);
460
482
  this.terminalWatchers.delete(localThreadId);
461
483
  this.cancelTraexStartupWatcher(localThreadId);
462
484
  const runtime = this.liveRuntimes.get(localThreadId);
463
485
  const paneFailure = terminalFailureDetail(terminal.capturePane?.() ?? "");
486
+ const startupFailed = this.failCodexThreadStartup(localThreadId, this.liveProvider, nativePhaseError(runtime, "native_terminal_exited_before_session", "Terminal exited before native session discovery completed", paneFailure || undefined));
487
+ if (isCodexLineageProvider(runtime)) {
488
+ if (!startupFailed) {
489
+ try {
490
+ this.terminals.close(terminalId);
491
+ }
492
+ catch (closeError) {
493
+ console.warn(`[runner] session=${localThreadId} failed to close exited native Terminal: ${closeError instanceof Error ? closeError.message : String(closeError)}`);
494
+ }
495
+ const exitError = nativePhaseError(runtime, "native_terminal_exited", "Terminal exited unexpectedly", paneFailure || undefined);
496
+ if (this.liveProvider.teardownLiveCodexSession) {
497
+ this.liveProvider.teardownLiveCodexSession(localThreadId, exitError);
498
+ }
499
+ else {
500
+ this.liveProvider.failLiveSession?.(localThreadId, exitError);
501
+ this.liveProvider.stopLiveCodexSession?.(localThreadId);
502
+ }
503
+ this.liveIds.delete(localThreadId);
504
+ console.warn(`[runner] session=${localThreadId} ${providerDisplayName(runtime)} auxiliary Terminal exited; native runtime torn down for cold resume`);
505
+ }
506
+ return;
507
+ }
464
508
  this.liveProvider.failLiveSession?.(localThreadId, nativePhaseError(runtime, "native_terminal_exited", "Terminal exited unexpectedly", paneFailure || undefined));
465
- this.failCodexThreadStartup(localThreadId, this.liveProvider, nativePhaseError(runtime, "native_terminal_exited_before_session", "Terminal exited before native session discovery completed", paneFailure || undefined));
509
+ }).catch((error) => {
510
+ console.warn(`[runner] session=${localThreadId} native Terminal liveness probe failed: ${error instanceof Error ? error.message : String(error)}`);
466
511
  }).finally(() => {
467
512
  checking = false;
468
513
  });
@@ -16,10 +16,10 @@
16
16
  * channel.
17
17
  */
18
18
  import { spawn as nodeSpawn, type ChildProcess } from "node:child_process";
19
- import { type AdmissionReservation, type AgentCapabilities, type AgentRuntimeId, type AppConfig, type CapabilityResult, type ModelListResponse, type LiveSessionFailure, type ResolvedExecutionSnapshot, type RuntimeUserInput, type SessionInteractionResolution, type SessionEvent, type SessionWorkspaceSnapshot, type ThreadGoal } from "@rynx-ai/core";
19
+ import { type AdmissionReservation, type AgentCapabilities, type AgentRuntimeId, type AppConfig, type CapabilityResult, type ModelListResponse, type LiveSessionFailure, type InjectResult, type ResolvedExecutionSnapshot, type RuntimeUserInput, type SessionInteractionResolution, type SessionEvent, type SessionWorkspaceSnapshot, type ThreadGoal } from "@rynx-ai/core";
20
20
  import { type CodexSessionStore } from "../host.js";
21
21
  import type { ResolveInteractionResult } from "../interactions.js";
22
- import { type InjectOutcome, type TerminalOpenErrorCode, type TerminalRole } from "./protocol.js";
22
+ import { type TerminalOpenErrorCode, type TerminalRole } from "./protocol.js";
23
23
  /** Runtime-local context attached to one Session runner process. The provider is
24
24
  * intentionally generic: package-specific resources stay in the composition
25
25
  * root that implements this port. */
@@ -67,15 +67,13 @@ export interface RunnerManagerOptions {
67
67
  /** Max wait for an accepted owner TUI submission to become observable as a
68
68
  * mirrored response or a published native rotation. */
69
69
  terminalInputHandoffTimeoutMs?: number;
70
- /** Initial exponential backoff for retrying unproven terminal cleanup. */
71
- terminalInputCleanupRetryMs?: number;
72
70
  /** Injected for tests. */
73
71
  spawn?: typeof nodeSpawn;
74
72
  /** Injected for tests. Defaults to signaling the whole POSIX process group
75
73
  * created for the runner child, with direct-child fallback. */
76
74
  signalChild?: (child: ChildProcess, signal: NodeJS.Signals, processGroup: boolean) => void;
77
- /** Injected for tests. Kills and verifies the deterministic private tmux
78
- * server owned by a Session runner. */
75
+ /** Injected for tests. Best-effort close of the deterministic private tmux
76
+ * server owned by a Session runner; Omnigent does not verify/retry close. */
79
77
  terminateTerminalServer?: (terminalName: string) => boolean | Promise<boolean>;
80
78
  /** Injected tmux `#{window_activity}` reader. Returns epoch seconds. */
81
79
  terminalWindowActivityAt?: (terminalName: string) => number | null | Promise<number | null>;
@@ -176,7 +174,6 @@ export declare class RunnerManager implements AgentCapabilities {
176
174
  private readonly nativeLiveStartTimeoutMs;
177
175
  private readonly liveInterruptTimeoutMs;
178
176
  private readonly terminalInputHandoffTimeoutMs;
179
- private readonly terminalInputCleanupRetryMs;
180
177
  private reapPromise;
181
178
  private stopping;
182
179
  private stopPromise;
@@ -243,7 +240,6 @@ export declare class RunnerManager implements AgentCapabilities {
243
240
  private finishTerminalInputHandoff;
244
241
  private finishAllTerminalInputHandoffs;
245
242
  private preserveTerminalInputHandoffsAsActivity;
246
- private scheduleTerminalInputCleanupRetry;
247
243
  private expireTerminalInputHandoffs;
248
244
  /**
249
245
  * Eagerly bring up a session's codex-native live view (persistent forwarder +
@@ -277,7 +273,7 @@ export declare class RunnerManager implements AgentCapabilities {
277
273
  * all output. Resolves true when the app-server accepted the turn, false when
278
274
  * the session has no live forwarder (caller falls back to the run path).
279
275
  */
280
- injectMessage(localThreadId: string, input: RuntimeUserInput | string): Promise<InjectOutcome>;
276
+ injectMessage(localThreadId: string, input: RuntimeUserInput | string): Promise<InjectResult>;
281
277
  private injectMessageAdmitted;
282
278
  /**
283
279
  * Interrupt a session's active live turn — the web Stop button (codex
@@ -47,9 +47,6 @@ const DEFAULT_NATIVE_LIVE_START_TIMEOUT_MS = 60_000;
47
47
  * quickly. If it does not, the runner is fenced by a verified process-tree
48
48
  * shutdown before maintenance may treat the submission as settled. */
49
49
  const DEFAULT_TERMINAL_INPUT_HANDOFF_TIMEOUT_MS = 30_000;
50
- /** Retry transient terminal-server cleanup without spinning forever. */
51
- const DEFAULT_TERMINAL_INPUT_CLEANUP_RETRY_MS = 1_000;
52
- const MAX_TERMINAL_INPUT_CLEANUP_RETRY_MS = 30_000;
53
50
  /** Full idle window before an inactive native pane becomes reapable. */
54
51
  const DEFAULT_NATIVE_PANE_IDLE_TTL_MS = 60 * 60_000;
55
52
  /** tmux output this recent independently proves that a native pane is busy. */
@@ -180,7 +177,6 @@ export class RunnerManager {
180
177
  nativeLiveStartTimeoutMs;
181
178
  liveInterruptTimeoutMs;
182
179
  terminalInputHandoffTimeoutMs;
183
- terminalInputCleanupRetryMs;
184
180
  reapPromise = null;
185
181
  stopping = false;
186
182
  stopPromise;
@@ -236,7 +232,6 @@ export class RunnerManager {
236
232
  this.nativeLiveStartTimeoutMs = Math.max(1, opts.nativeLiveStartTimeoutMs ?? DEFAULT_NATIVE_LIVE_START_TIMEOUT_MS);
237
233
  this.liveInterruptTimeoutMs = Math.max(1, opts.liveInterruptTimeoutMs ?? 10_000);
238
234
  this.terminalInputHandoffTimeoutMs = Math.max(1, opts.terminalInputHandoffTimeoutMs ?? DEFAULT_TERMINAL_INPUT_HANDOFF_TIMEOUT_MS);
239
- this.terminalInputCleanupRetryMs = Math.max(1, opts.terminalInputCleanupRetryMs ?? DEFAULT_TERMINAL_INPUT_CLEANUP_RETRY_MS);
240
235
  const reapIntervalMs = opts.reapIntervalMs ?? 60_000;
241
236
  if (reapIntervalMs > 0) {
242
237
  this.reapTimer = setInterval(() => void this.reapIdle(), reapIntervalMs);
@@ -456,11 +451,6 @@ export class RunnerManager {
456
451
  this.terminalInputHandoffs.delete(handle);
457
452
  }
458
453
  finishAllTerminalInputHandoffs(handle) {
459
- if (handle.terminalCleanupRetryTimer) {
460
- clearTimeout(handle.terminalCleanupRetryTimer);
461
- delete handle.terminalCleanupRetryTimer;
462
- }
463
- handle.terminalCleanupRetryFailures = 0;
464
454
  for (const handoff of [...(this.terminalInputHandoffs.get(handle) ?? [])]) {
465
455
  this.finishTerminalInputHandoff(handle, handoff);
466
456
  }
@@ -475,22 +465,6 @@ export class RunnerManager {
475
465
  handoff.reservation = undefined;
476
466
  }
477
467
  }
478
- scheduleTerminalInputCleanupRetry(handle) {
479
- if (handle.terminalCleanupRetryTimer ||
480
- !this.terminalInputHandoffs.has(handle))
481
- return;
482
- const exponent = Math.min(handle.terminalCleanupRetryFailures - 1, 10);
483
- const delay = Math.min(this.terminalInputCleanupRetryMs * (2 ** Math.max(0, exponent)), MAX_TERMINAL_INPUT_CLEANUP_RETRY_MS);
484
- handle.terminalCleanupRetryTimer = setTimeout(() => {
485
- delete handle.terminalCleanupRetryTimer;
486
- if (!this.terminalInputHandoffs.has(handle))
487
- return;
488
- void this.terminateHandle(handle, "retrying unproven terminal input cleanup").catch((error) => {
489
- logTerminationFailure(handle, error);
490
- });
491
- }, delay);
492
- handle.terminalCleanupRetryTimer.unref?.();
493
- }
494
468
  expireTerminalInputHandoffs(handle) {
495
469
  const handoffs = this.terminalInputHandoffs.get(handle);
496
470
  if (!handoffs?.length)
@@ -668,29 +642,29 @@ export class RunnerManager {
668
642
  const reqId = randomUUID();
669
643
  return new Promise((resolve) => {
670
644
  handle.live.set(reqId, (res) => {
671
- const outcome = res.outcome ?? "failed";
645
+ const result = res.result ?? { outcome: "failed" };
672
646
  const finish = () => {
673
- if (outcome === "injected" || outcome === "steered") {
647
+ if (result.outcome === "injected" || result.outcome === "steered") {
674
648
  this.liveErrors.delete(localThreadId);
675
649
  }
676
650
  else {
677
651
  this.liveErrors.set(localThreadId, res.error ?? {
678
- message: `live injection ${outcome}`,
679
- code: outcome === "notReady"
652
+ message: `live injection ${result.outcome}`,
653
+ code: result.outcome === "notReady"
680
654
  ? "native_thread_not_ready"
681
- : outcome === "notLive"
655
+ : result.outcome === "notLive"
682
656
  ? "native_session_not_live"
683
657
  : "native_message_injection_failed",
684
658
  statusCode: 503,
685
659
  });
686
660
  }
687
- return outcome;
661
+ return result;
688
662
  };
689
663
  const reservation = this.forkReservations.get(localThreadId);
690
664
  if (reservation) {
691
665
  void reservation
692
666
  .then(() => finish())
693
- .then(resolve, () => resolve("failed"));
667
+ .then(resolve, () => resolve({ outcome: "failed" }));
694
668
  return;
695
669
  }
696
670
  resolve(finish());
@@ -1211,7 +1185,6 @@ export class RunnerManager {
1211
1185
  activeResponseIds: new Set(),
1212
1186
  dead: false,
1213
1187
  completion,
1214
- terminalCleanupRetryFailures: 0,
1215
1188
  processGroup,
1216
1189
  ...(sessionContext ? { sessionContext } : {}),
1217
1190
  caps: new Map(),
@@ -1298,7 +1271,7 @@ export class RunnerManager {
1298
1271
  const resolve = handle.live.get(msg.reqId);
1299
1272
  handle.live.delete(msg.reqId);
1300
1273
  resolve?.(msg.t === "injected"
1301
- ? { outcome: msg.outcome, error: msg.error }
1274
+ ? { result: msg.result, error: msg.error }
1302
1275
  : { ok: msg.ok, error: msg.error });
1303
1276
  return;
1304
1277
  }
@@ -1354,10 +1327,6 @@ export class RunnerManager {
1354
1327
  terminateHandle(handle, reason) {
1355
1328
  if (handle.termination)
1356
1329
  return handle.termination;
1357
- if (handle.terminalCleanupRetryTimer) {
1358
- clearTimeout(handle.terminalCleanupRetryTimer);
1359
- delete handle.terminalCleanupRetryTimer;
1360
- }
1361
1330
  const attempt = (async () => {
1362
1331
  if (!handle.dead) {
1363
1332
  try {
@@ -1374,15 +1343,14 @@ export class RunnerManager {
1374
1343
  this.signalChild(handle.child, "SIGKILL", handle.processGroup);
1375
1344
  childExited = await waitForChildExit(handle.completion, this.shutdownKillGraceMs);
1376
1345
  }
1377
- const terminalStopped = handle.key === CAP_KEY
1378
- ? true
1379
- : await Promise.resolve(this.terminateTerminalServer(`${handle.key}-main`)).catch(() => false);
1346
+ if (handle.key !== CAP_KEY) {
1347
+ await Promise.resolve(this.terminateTerminalServer(`${handle.key}-main`)).catch((error) => {
1348
+ console.warn(`[runner-manager] best-effort terminal close failed for ${handle.key}-main: ${error instanceof Error ? error.message : String(error)}`);
1349
+ });
1350
+ }
1380
1351
  if (!childExited) {
1381
1352
  throw new Error(`runner child ${handle.child.pid ?? handle.key} did not exit after SIGKILL`);
1382
1353
  }
1383
- if (!terminalStopped) {
1384
- throw new Error(`runner terminal ${handle.key}-main remained live after child exit`);
1385
- }
1386
1354
  this.childHandles.delete(handle);
1387
1355
  })();
1388
1356
  handle.termination = attempt;
@@ -1391,12 +1359,9 @@ export class RunnerManager {
1391
1359
  }, () => {
1392
1360
  if (handle.termination === attempt)
1393
1361
  delete handle.termination;
1394
- handle.terminalCleanupRetryFailures += 1;
1395
- // A failed cleanup must not pin closeAndDrain forever. Keep the
1396
- // submission in the daemon activity snapshot, but release its gate
1397
- // reservation so maintenance returns a structured busy result.
1362
+ // A child that survives SIGKILL is still real activity. Release the
1363
+ // gate reservation but retain the handoff until a later explicit stop.
1398
1364
  this.preserveTerminalInputHandoffsAsActivity(handle);
1399
- this.scheduleTerminalInputCleanupRetry(handle);
1400
1365
  });
1401
1366
  return attempt;
1402
1367
  }
@@ -1529,6 +1494,7 @@ export class RunnerManager {
1529
1494
  return;
1530
1495
  case "response.completed":
1531
1496
  case "response.failed":
1497
+ case "session.interrupted":
1532
1498
  handle.activeResponseIds.delete(event.responseId);
1533
1499
  return;
1534
1500
  case "session.status":
@@ -6,8 +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 InjectOutcome, type ResolvedExecutionSnapshot, type RuntimeUserInput, type SessionInteractionResolution, type SessionEvent, type SessionWorkspaceSnapshot } from "@rynx-ai/core";
10
- export type { InjectOutcome } from "@rynx-ai/core";
9
+ import { AgentRuntimeError, type InjectResult, type ResolvedExecutionSnapshot, type RuntimeUserInput, type SessionInteractionResolution, type SessionEvent, type SessionWorkspaceSnapshot } from "@rynx-ai/core";
10
+ export type { InjectOutcome, InjectResult } from "@rynx-ai/core";
11
11
  import type { ResolveInteractionResult } from "../interactions.js";
12
12
  /** A runtime error flattened for the wire; rebuilt parent-side as `AgentRuntimeError`. */
13
13
  export interface WireError {
@@ -166,14 +166,14 @@ export type FromChild = {
166
166
  ok: boolean;
167
167
  error?: WireError;
168
168
  }
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`. */
169
+ /** Result of an `inject`: `result.outcome` distinguishes a new turn, an
170
+ * active-turn steer, and failures. Successful results also carry the
171
+ * runtime-owned canonical Response identity. Echoes the request `reqId`. */
172
172
  | {
173
173
  t: "injected";
174
174
  reqId: string;
175
175
  localThreadId: string;
176
- outcome: InjectOutcome;
176
+ result: InjectResult;
177
177
  error?: WireError;
178
178
  }
179
179
  /** Result of a `live.interrupt`: `ok` when an active turn was interrupted (false
@@ -31,13 +31,13 @@ export class TerminalRegistry {
31
31
  return existing;
32
32
  if (existing) {
33
33
  // Dead pane (its TUI exited) — kill the husk + drop it before relaunching.
34
+ this.terminals.delete(id);
34
35
  try {
35
36
  existing.kill();
36
37
  }
37
38
  catch {
38
39
  // already gone — fine
39
40
  }
40
- this.terminals.delete(id);
41
41
  }
42
42
  const terminal = new TmuxTerminal({ ...opts, name: id });
43
43
  terminal.start();
@@ -59,8 +59,9 @@ export class TerminalRegistry {
59
59
  const terminal = this.terminals.get(id);
60
60
  if (!terminal)
61
61
  return;
62
- terminal.kill();
62
+ // Omnigent removes the resource from its registry before best-effort close.
63
63
  this.terminals.delete(id);
64
+ terminal.kill();
64
65
  }
65
66
  /** Kill every terminal (runner shutdown). */
66
67
  closeAll() {
@@ -11,6 +11,11 @@ export interface TerminalAttachment {
11
11
  /** Detach this client. The tmux server + pane keep running. */
12
12
  kill(): void;
13
13
  }
14
+ /** Result of probing the inner tmux pane. `unknown` is deliberately distinct
15
+ * from `dead`: a timed-out/unexecutable control command is inconclusive;
16
+ * `dead` requires an explicit dead pane. The lifecycle watcher separately
17
+ * mirrors Omnigent's capture failure path for a vanished server/session. */
18
+ export type TerminalLiveness = "alive" | "dead" | "unknown";
14
19
  /** Minimal node-pty surface (kept local so this module has no type dep on it). */
15
20
  interface PtyProcess {
16
21
  onData(cb: (data: string) => void): void;
@@ -62,9 +67,11 @@ export declare function tmuxWindowActivityAt(name: string, tmuxBin?: string): Pr
62
67
  * This remains authoritative if a parent-side attachment bookkeeping edge was
63
68
  * missed during a transport failure. */
64
69
  export declare function tmuxHasAttachedClient(name: string, tmuxBin?: string): Promise<boolean>;
65
- /** Kill one private tmux server and verify it no longer answers. Missing
66
- * sockets are already stopped; an existing socket with an unusable tmux
67
- * command is unproven and returns false. */
70
+ /** Best-effort close of one private tmux server. Mirrors Omnigent's bounded
71
+ * `TerminalInstance.close`: attempt `kill-server`, then retire the private
72
+ * socket regardless of command outcome. The registry has already forgotten
73
+ * the resource, so cleanup failure is diagnostic rather than a second
74
+ * lifecycle state. */
68
75
  export declare function terminateTmuxServer(name: string, tmuxBin?: string): boolean;
69
76
  /** Is a usable `tmux` on PATH? Cheap probe for the capability gate. */
70
77
  export declare function isTmuxAvailable(tmuxBin?: string): boolean;
@@ -80,6 +87,14 @@ export declare class TmuxTerminal {
80
87
  private readonly tmuxBin;
81
88
  private readonly injectedSpawn?;
82
89
  private started;
90
+ private lastPaneSnapshot;
91
+ /** Shared by every attachment watcher and by the lifecycle watcher's
92
+ * pane-dead stage. One Terminal must never fan the same tmux control probe
93
+ * out once per attached client. */
94
+ private paneLivenessFlight?;
95
+ /** Shared by lifecycle callers so capture + pane-dead remains one ordered
96
+ * Omnigent-style observation per Terminal. */
97
+ private lifecycleLivenessFlight?;
83
98
  constructor(opts: TmuxTerminalOptions);
84
99
  /** tmux argv prefix targeting this terminal's private server. */
85
100
  private base;
@@ -123,8 +138,17 @@ export declare class TmuxTerminal {
123
138
  /** Async pane-liveness probe — MUST NOT block the event loop. The attach
124
139
  * pane-death watcher polls this on an interval; a synchronous `execFileSync`
125
140
  * there stalls the runner child's event loop (freezing the PTY stream → the
126
- * terminal appears "stuck"). reference implementation's `_tmux_session_alive` uses an async
127
- * subprocess + timeout for exactly this reason. */
141
+ * terminal appears "stuck"). Exactly like Omnigent's definitive pane probe,
142
+ * every command error is `unknown`; only `#{pane_dead}=1` is `dead`. */
143
+ livenessAsync(): Promise<TerminalLiveness>;
144
+ /** Omnigent's always-on terminal lifecycle watcher first captures the pane:
145
+ * a control command that ran and reports the target missing is terminal exit;
146
+ * a probe that cannot spawn is inconclusive. If capture succeeds, the normal
147
+ * definitive `pane_dead` probe distinguishes live from exited. */
148
+ lifecycleLivenessAsync(): Promise<TerminalLiveness>;
149
+ /** Compatibility boolean for callers that cannot represent an inconclusive
150
+ * probe. Unknown must remain live so a transient tmux failure cannot tear down
151
+ * a healthy native Session. */
128
152
  isAliveAsync(): Promise<boolean>;
129
153
  /** PID of the process currently owning the pane. */
130
154
  panePid(): number | undefined;