@rynx-ai/runtime 0.1.11-beta.32 → 0.1.11-beta.34

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.
@@ -9,9 +9,10 @@
9
9
  */
10
10
  import { randomUUID } from "node:crypto";
11
11
  import { AgentRuntimeError, } from "@rynx-ai/core";
12
- import { TerminalRegistry } from "../terminal/registry.js";
12
+ import { TerminalRegistry, } from "../terminal/registry.js";
13
13
  import { RUNNER_IMAGE_CHUNK_CHARS, RUNNER_IMAGE_MAX_RESULT_CHARS, toWireError, } from "./protocol.js";
14
14
  import { isCodexLineageProvider } from "./startup-policy.js";
15
+ const TERMINAL_PREPARATION_TTL_MS = 30_000;
15
16
  /** Maximum time for a fresh Codex-lineage TUI to publish its native thread. */
16
17
  const CODEX_THREAD_START_TIMEOUT_MS = 30_000;
17
18
  function providerDisplayName(runtime) {
@@ -119,8 +120,10 @@ export class RunnerSession {
119
120
  onShutdown;
120
121
  /** Live terminals hosted by this session, and per-attach client handles. */
121
122
  terminals = new TerminalRegistry();
123
+ preparations = new Map();
122
124
  attachments = new Map();
123
125
  attachmentThreadIds = new Map();
126
+ attachmentRoles = new Map();
124
127
  traexStartupWatchers = new Map();
125
128
  traexStartupGates = new Map();
126
129
  terminalWatchers = new Map();
@@ -133,7 +136,9 @@ export class RunnerSession {
133
136
  mirrorImageAcks = new Map();
134
137
  /** Provider name retained for asynchronous Terminal-exit diagnostics. */
135
138
  liveRuntimes = new Map();
139
+ terminalStatuses = new Map();
136
140
  shuttingDown = false;
141
+ shutdownPromise = null;
137
142
  constructor({ transport, executor, onShutdown }) {
138
143
  this.transport = transport;
139
144
  this.executor = executor;
@@ -177,7 +182,9 @@ export class RunnerSession {
177
182
  void this.runCap(msg.capId, msg.name, msg.args);
178
183
  return;
179
184
  case "term.open":
180
- if (this.pendingTerminalOpens.has(msg.attachId) || this.attachments.has(msg.attachId)) {
185
+ if (this.pendingTerminalOpens.has(msg.attachId) ||
186
+ this.preparations.has(msg.attachId) ||
187
+ this.attachments.has(msg.attachId)) {
181
188
  this.transport.send({
182
189
  t: "term.error",
183
190
  attachId: msg.attachId,
@@ -192,29 +199,35 @@ export class RunnerSession {
192
199
  this.cancelledTerminalOpens.delete(msg.attachId);
193
200
  });
194
201
  return;
202
+ case "term.seed.read":
203
+ void this.readTerminalSeed(msg);
204
+ return;
205
+ case "term.start":
206
+ void this.startTerminal(msg);
207
+ return;
208
+ case "term.read":
209
+ void this.readTerminalOutput(msg);
210
+ return;
195
211
  case "term.input":
196
- {
197
- const localThreadId = this.attachmentThreadIds.get(msg.attachId);
198
- const input = Buffer.from(msg.dataB64, "base64").toString("utf8");
199
- // xterm sends device/focus reports through the same onData channel as
200
- // keystrokes. They must reach the TUI without pretending the user has
201
- // taken over startup prompt handling.
202
- if (!isTerminalProtocolResponse(input)) {
203
- this.cancelTraexStartupWatcher(localThreadId);
204
- }
205
- this.attachments.get(msg.attachId)?.write(input);
206
- }
212
+ void this.writeTerminalInput(msg);
207
213
  return;
208
214
  case "term.resize":
209
- this.attachments.get(msg.attachId)?.resize(msg.cols, msg.rows);
215
+ void this.resizeTerminal(msg);
210
216
  return;
211
217
  case "term.close": {
212
218
  if (this.pendingTerminalOpens.has(msg.attachId)) {
213
219
  this.cancelledTerminalOpens.add(msg.attachId);
214
220
  }
221
+ const pending = this.preparations.get(msg.attachId);
222
+ if (pending) {
223
+ clearTimeout(pending.timer);
224
+ this.preparations.delete(msg.attachId);
225
+ pending.preparation.kill();
226
+ }
215
227
  const attachment = this.attachments.get(msg.attachId);
216
228
  this.attachments.delete(msg.attachId);
217
229
  this.attachmentThreadIds.delete(msg.attachId);
230
+ this.attachmentRoles.delete(msg.attachId);
218
231
  attachment?.kill();
219
232
  return;
220
233
  }
@@ -234,8 +247,11 @@ export class RunnerSession {
234
247
  case "live.interrupt":
235
248
  void this.interruptLive(msg);
236
249
  return;
250
+ case "terminal.reap":
251
+ void this.reapTerminal(msg);
252
+ return;
237
253
  case "shutdown":
238
- this.shutdown();
254
+ void this.shutdown();
239
255
  return;
240
256
  }
241
257
  }
@@ -269,14 +285,19 @@ export class RunnerSession {
269
285
  const target = { id: localThreadId };
270
286
  const emit = (event) => {
271
287
  const sessionId = target.id;
288
+ if (event.type === "session.status") {
289
+ this.terminalStatuses.set(sessionId, event.status);
290
+ }
272
291
  this.enqueueMirror(() => this.sendMirroredEvent(sessionId, event));
273
292
  };
274
293
  const retarget = (newId, meta) => {
294
+ const previousId = target.id;
295
+ this.transferNativeSessionResources(previousId, newId);
275
296
  target.id = newId;
276
297
  this.enqueueMirror(() => {
277
298
  this.transport.send({
278
299
  t: "rotate",
279
- from: localThreadId,
300
+ from: previousId,
280
301
  to: newId,
281
302
  kind: meta.kind,
282
303
  workspace: meta.workspace,
@@ -287,11 +308,41 @@ export class RunnerSession {
287
308
  };
288
309
  return { emit, retarget };
289
310
  }
311
+ transferNativeSessionResources(sourceId, targetId) {
312
+ if (sourceId === targetId)
313
+ return;
314
+ const sourceTerminalId = `${sourceId}-main`;
315
+ const targetTerminalId = `${targetId}-main`;
316
+ this.terminals.transfer(sourceTerminalId, targetTerminalId);
317
+ const watcher = this.terminalWatchers.get(sourceId);
318
+ if (watcher)
319
+ clearInterval(watcher);
320
+ this.terminalWatchers.delete(sourceId);
321
+ const runtime = this.liveRuntimes.get(sourceId);
322
+ if (runtime) {
323
+ this.liveRuntimes.delete(sourceId);
324
+ this.liveRuntimes.set(targetId, runtime);
325
+ }
326
+ const status = this.terminalStatuses.get(sourceId);
327
+ if (status) {
328
+ this.terminalStatuses.delete(sourceId);
329
+ this.terminalStatuses.set(targetId, status);
330
+ }
331
+ if (this.liveIds.delete(sourceId))
332
+ this.liveIds.add(targetId);
333
+ for (const [attachId, sessionId] of this.attachmentThreadIds) {
334
+ if (sessionId === sourceId)
335
+ this.attachmentThreadIds.set(attachId, targetId);
336
+ }
337
+ const terminal = this.terminals.get(targetTerminalId);
338
+ if (terminal)
339
+ this.watchNativeTerminal(targetId, targetTerminalId, terminal);
340
+ }
290
341
  enqueueMirror(operation) {
291
342
  this.mirrorQueue = this.mirrorQueue.then(operation).catch((error) => {
292
343
  if (!this.shuttingDown) {
293
344
  this.rejectMirrorImageAcks(error instanceof Error ? error : new Error(String(error)));
294
- this.shutdown();
345
+ void this.shutdown();
295
346
  }
296
347
  });
297
348
  }
@@ -614,6 +665,43 @@ export class RunnerSession {
614
665
  ...(error ? { error } : {}),
615
666
  });
616
667
  }
668
+ async reapTerminal(msg) {
669
+ let error;
670
+ try {
671
+ const lifecycle = this.terminals.lifecycle(msg.terminalId);
672
+ const watcher = this.terminalWatchers.get(msg.localThreadId);
673
+ if (watcher)
674
+ clearInterval(watcher);
675
+ this.terminalWatchers.delete(msg.localThreadId);
676
+ this.cancelTraexStartupWatcher(msg.localThreadId);
677
+ for (const [attachId, sessionId] of this.attachmentThreadIds) {
678
+ if (sessionId !== msg.localThreadId)
679
+ continue;
680
+ this.attachments.get(attachId)?.kill();
681
+ this.attachments.delete(attachId);
682
+ this.attachmentThreadIds.delete(attachId);
683
+ this.attachmentRoles.delete(attachId);
684
+ }
685
+ this.terminals.close(msg.terminalId);
686
+ if (lifecycle === "auxiliary") {
687
+ await this.liveProvider.teardownAuxiliaryTerminalRuntime?.(msg.localThreadId);
688
+ this.liveIds.delete(msg.localThreadId);
689
+ this.liveRuntimes.delete(msg.localThreadId);
690
+ this.terminalStatuses.delete(msg.localThreadId);
691
+ }
692
+ }
693
+ catch (cause) {
694
+ error = toWireError(cause);
695
+ }
696
+ this.transport.send({
697
+ t: "terminal.reaped",
698
+ reqId: msg.reqId,
699
+ localThreadId: msg.localThreadId,
700
+ terminalId: msg.terminalId,
701
+ ok: error === undefined,
702
+ ...(error ? { error } : {}),
703
+ });
704
+ }
617
705
  /** Launch (idempotently) the session's codex TUI pane from the executor's
618
706
  * `codexTerminalSpec`. Shares the `${id}-main` terminal id with `term.open`,
619
707
  * so the web attach reuses the same detached pane. */
@@ -623,12 +711,23 @@ export class RunnerSession {
623
711
  return;
624
712
  const terminalId = `${localThreadId}-main`;
625
713
  const term = this.terminals.getOrCreate(terminalId, {
714
+ lifecycle: spec.lifecycle,
626
715
  cwd: spec.cwd,
627
716
  command: spec.command,
628
717
  args: spec.args,
629
718
  cols: cols ?? 120,
630
719
  rows: rows ?? 40,
631
720
  ...(spec.env ? { env: spec.env } : {}),
721
+ ...(spec.scrollback === undefined ? {} : { scrollback: spec.scrollback }),
722
+ ...(spec.tmuxAllowPassthrough === undefined
723
+ ? {}
724
+ : { tmuxAllowPassthrough: spec.tmuxAllowPassthrough }),
725
+ ...(spec.tmuxStartOnAttach === undefined
726
+ ? {}
727
+ : { tmuxStartOnAttach: spec.tmuxStartOnAttach }),
728
+ ...(spec.keepAliveAfterExit === undefined
729
+ ? {}
730
+ : { keepAliveAfterExit: spec.keepAliveAfterExit }),
632
731
  });
633
732
  if (spec.skipTraexStartupPrompts) {
634
733
  const watcher = Symbol(localThreadId);
@@ -671,30 +770,39 @@ export class RunnerSession {
671
770
  this.terminalWatchers.delete(localThreadId);
672
771
  this.cancelTraexStartupWatcher(localThreadId);
673
772
  const runtime = this.liveRuntimes.get(localThreadId);
773
+ const lifecycle = this.terminals.lifecycle(terminalId);
674
774
  const paneFailure = terminalFailureDetail(terminal.capturePane?.() ?? "");
675
775
  const startupFailed = this.failCodexThreadStartup(localThreadId, this.liveProvider, nativePhaseError(runtime, "native_terminal_exited_before_session", "Terminal exited before native session discovery completed", paneFailure || undefined));
676
- if (isCodexLineageProvider(runtime)) {
677
- if (!startupFailed) {
678
- try {
679
- this.terminals.close(terminalId);
680
- }
681
- catch (closeError) {
682
- console.warn(`[runner] session=${localThreadId} failed to close exited native Terminal: ${closeError instanceof Error ? closeError.message : String(closeError)}`);
683
- }
684
- const exitError = nativePhaseError(runtime, "native_terminal_exited", "Terminal exited unexpectedly", paneFailure || undefined);
685
- if (this.liveProvider.teardownLiveCodexSession) {
686
- this.liveProvider.teardownLiveCodexSession(localThreadId, exitError);
687
- }
688
- else {
689
- this.liveProvider.failLiveSession?.(localThreadId, exitError);
690
- this.liveProvider.stopLiveCodexSession?.(localThreadId);
691
- }
692
- this.liveIds.delete(localThreadId);
693
- console.warn(`[runner] session=${localThreadId} ${providerDisplayName(runtime)} auxiliary Terminal exited; native runtime torn down for cold resume`);
694
- }
776
+ if (startupFailed || lifecycle === undefined)
695
777
  return;
778
+ try {
779
+ this.terminals.close(terminalId);
696
780
  }
697
- this.liveProvider.failLiveSession?.(localThreadId, nativePhaseError(runtime, "native_terminal_exited", "Terminal exited unexpectedly", paneFailure || undefined));
781
+ catch (closeError) {
782
+ console.warn(`[runner] session=${localThreadId} failed to close exited native Terminal: ${closeError instanceof Error ? closeError.message : String(closeError)}`);
783
+ }
784
+ const status = this.terminalStatuses.get(localThreadId) ?? "unknown";
785
+ if (lifecycle === "auxiliary") {
786
+ console.warn(`[runner] session=${localThreadId} ${providerDisplayName(runtime)} auxiliary Terminal exited`);
787
+ }
788
+ else {
789
+ if (status !== "idle") {
790
+ this.liveProvider.failLiveSession?.(localThreadId, nativePhaseError(runtime, "required_terminal_exited", "Required Terminal exited unexpectedly", paneFailure || undefined));
791
+ }
792
+ this.liveProvider.stopLiveCodexSession?.(localThreadId);
793
+ this.liveIds.delete(localThreadId);
794
+ this.liveRuntimes.delete(localThreadId);
795
+ this.terminalStatuses.delete(localThreadId);
796
+ }
797
+ this.enqueueMirror(() => {
798
+ this.transport.send({
799
+ t: "terminal.lifecycle.ended",
800
+ localThreadId,
801
+ terminalId,
802
+ lifecycle,
803
+ status,
804
+ });
805
+ });
698
806
  }).catch((error) => {
699
807
  console.warn(`[runner] session=${localThreadId} native Terminal liveness probe failed: ${error instanceof Error ? error.message : String(error)}`);
700
808
  }).finally(() => {
@@ -846,19 +954,31 @@ export class RunnerSession {
846
954
  /** Stop event forwarding, kill native terminals/hooks, then synchronously
847
955
  * scrub provider handoff files before the child process is allowed to exit. */
848
956
  shutdown() {
849
- if (this.shuttingDown)
850
- return;
957
+ if (this.shutdownPromise)
958
+ return this.shutdownPromise;
851
959
  this.shuttingDown = true;
852
- this.rejectMirrorImageAcks(new Error("runner is shutting down"));
853
- this.stopLive();
854
- this.terminals.closeAll();
855
- this.liveProvider.finalizeStoppedLiveSessions?.();
856
- this.onShutdown();
960
+ this.shutdownPromise = (async () => {
961
+ this.rejectMirrorImageAcks(new Error("runner is shutting down"));
962
+ this.stopLive();
963
+ for (const pending of this.preparations.values()) {
964
+ clearTimeout(pending.timer);
965
+ pending.preparation.kill();
966
+ }
967
+ this.preparations.clear();
968
+ for (const attachment of this.attachments.values())
969
+ attachment.kill();
970
+ this.terminals.closeAll();
971
+ this.attachments.clear();
972
+ this.attachmentThreadIds.clear();
973
+ this.attachmentRoles.clear();
974
+ await this.liveProvider.finalizeStoppedLiveSessions?.();
975
+ this.onShutdown();
976
+ })();
977
+ return this.shutdownPromise;
857
978
  }
858
979
  async openTerminal(msg) {
859
980
  try {
860
- // codex/claude-native session (no explicit command): DUMB ATTACH — reference implementation's
861
- // reattach (codex_native.py:905-942, `app_server=None`). A tab switch ONLY
981
+ // codex/claude-native session (no explicit command): DUMB ATTACH. A tab switch ONLY
862
982
  // attaches an already-live pane; it NEVER ensures the forwarder or relaunches a
863
983
  // dead pane. Creation/relaunch happens on message-send (`live.ensure`) or an
864
984
  // explicit restart. A missing/dead pane → `term.error`, so the web shows the
@@ -880,6 +1000,7 @@ export class RunnerSession {
880
1000
  }
881
1001
  // Login-shell terminal (explicit command): create-or-reuse as before.
882
1002
  this.terminals.getOrCreate(msg.terminalId, {
1003
+ lifecycle: "auxiliary",
883
1004
  cwd: msg.cwd,
884
1005
  command: msg.command,
885
1006
  args: msg.args,
@@ -897,14 +1018,14 @@ export class RunnerSession {
897
1018
  });
898
1019
  }
899
1020
  }
900
- /** Attach an already-created terminal and forward its data/exit to the parent. */
1021
+ /** Prepare captured history without starting the live control client. */
901
1022
  async attachExisting(msg) {
902
- const { attachment, role } = await this.terminals.attach(msg.terminalId, msg.role, {
1023
+ const { preparation, role } = await this.terminals.prepare(msg.terminalId, msg.role, {
903
1024
  cols: msg.cols,
904
1025
  rows: msg.rows,
905
1026
  });
906
1027
  if (this.cancelledTerminalOpens.has(msg.attachId)) {
907
- attachment.kill();
1028
+ preparation.kill();
908
1029
  this.transport.send({
909
1030
  t: "term.error",
910
1031
  attachId: msg.attachId,
@@ -913,20 +1034,168 @@ export class RunnerSession {
913
1034
  });
914
1035
  return;
915
1036
  }
916
- this.attachments.set(msg.attachId, attachment);
1037
+ const timer = setTimeout(() => {
1038
+ const pending = this.preparations.get(msg.attachId);
1039
+ if (!pending || pending.preparation !== preparation)
1040
+ return;
1041
+ this.preparations.delete(msg.attachId);
1042
+ this.attachmentThreadIds.delete(msg.attachId);
1043
+ this.attachmentRoles.delete(msg.attachId);
1044
+ preparation.kill();
1045
+ }, TERMINAL_PREPARATION_TTL_MS);
1046
+ timer.unref?.();
1047
+ this.preparations.set(msg.attachId, { preparation, timer });
1048
+ this.attachmentRoles.set(msg.attachId, role);
917
1049
  if (msg.localThreadId)
918
1050
  this.attachmentThreadIds.set(msg.attachId, msg.localThreadId);
919
- attachment.onData((chunk) => this.transport.send({
920
- t: "term.data",
1051
+ this.transport.send({
1052
+ t: "term.prepared",
921
1053
  attachId: msg.attachId,
922
- dataB64: Buffer.from(chunk, "utf8").toString("base64"),
923
- }));
924
- attachment.onExit((info) => {
925
- this.attachments.delete(msg.attachId);
1054
+ role,
1055
+ seedBytes: preparation.seedBytes,
1056
+ ...(preparation.dimensions ?? {}),
1057
+ });
1058
+ }
1059
+ async readTerminalSeed(msg) {
1060
+ const pending = this.preparations.get(msg.attachId);
1061
+ if (!pending) {
1062
+ this.sendTerminalOperationError(msg.attachId, msg.reqId, "terminal is not prepared");
1063
+ return;
1064
+ }
1065
+ try {
1066
+ const result = await pending.preparation.readSeed(msg.offset, msg.maxBytes);
1067
+ await this.transport.sendAndDrain({
1068
+ t: "term.seed.chunk",
1069
+ attachId: msg.attachId,
1070
+ reqId: msg.reqId,
1071
+ dataB64: Buffer.from(result.data).toString("base64"),
1072
+ nextOffset: result.nextOffset,
1073
+ done: result.done,
1074
+ ...(result.finalOffset === undefined ? {} : { finalOffset: result.finalOffset }),
1075
+ });
1076
+ }
1077
+ catch (error) {
1078
+ this.sendTerminalOperationError(msg.attachId, msg.reqId, error);
1079
+ }
1080
+ }
1081
+ async startTerminal(msg) {
1082
+ const pending = this.preparations.get(msg.attachId);
1083
+ if (!pending) {
1084
+ this.sendTerminalOperationError(msg.attachId, msg.reqId, "terminal is not prepared");
1085
+ return;
1086
+ }
1087
+ clearTimeout(pending.timer);
1088
+ this.preparations.delete(msg.attachId);
1089
+ try {
1090
+ const attachment = await pending.preparation.start();
1091
+ this.attachments.set(msg.attachId, attachment);
1092
+ attachment.onResize(({ cols, rows }) => {
1093
+ this.transport.send({ t: "term.dimensions", attachId: msg.attachId, cols, rows });
1094
+ });
1095
+ await this.transport.sendAndDrain({
1096
+ t: "term.started",
1097
+ attachId: msg.attachId,
1098
+ reqId: msg.reqId,
1099
+ });
1100
+ void attachment.readerDone.then(async (info) => {
1101
+ await this.transport.sendAndDrain({
1102
+ t: "term.reader.done",
1103
+ attachId: msg.attachId,
1104
+ finalOffset: info.finalOffset,
1105
+ reason: info.reason,
1106
+ exitCode: info.exitCode,
1107
+ }).catch(() => undefined);
1108
+ });
1109
+ }
1110
+ catch (error) {
1111
+ pending.preparation.kill();
926
1112
  this.attachmentThreadIds.delete(msg.attachId);
927
- this.transport.send({ t: "term.exit", attachId: msg.attachId, exitCode: info.exitCode });
1113
+ this.attachmentRoles.delete(msg.attachId);
1114
+ this.sendTerminalOperationError(msg.attachId, msg.reqId, error);
1115
+ }
1116
+ }
1117
+ async readTerminalOutput(msg) {
1118
+ const attachment = this.attachments.get(msg.attachId);
1119
+ if (!attachment) {
1120
+ this.sendTerminalOperationError(msg.attachId, msg.reqId, "terminal has not started");
1121
+ return;
1122
+ }
1123
+ try {
1124
+ const result = await attachment.read(msg.offset, msg.maxBytes);
1125
+ await this.transport.sendAndDrain({
1126
+ t: "term.chunk",
1127
+ attachId: msg.attachId,
1128
+ reqId: msg.reqId,
1129
+ dataB64: Buffer.from(result.data).toString("base64"),
1130
+ nextOffset: result.nextOffset,
1131
+ done: result.done,
1132
+ ...(result.finalOffset === undefined ? {} : { finalOffset: result.finalOffset }),
1133
+ });
1134
+ }
1135
+ catch (error) {
1136
+ this.sendTerminalOperationError(msg.attachId, msg.reqId, error);
1137
+ }
1138
+ }
1139
+ async writeTerminalInput(msg) {
1140
+ if (this.attachmentRoles.get(msg.attachId) !== "owner") {
1141
+ await this.transport.sendAndDrain({
1142
+ t: "term.ack",
1143
+ attachId: msg.attachId,
1144
+ reqId: msg.reqId,
1145
+ operation: "input",
1146
+ });
1147
+ return;
1148
+ }
1149
+ const attachment = this.attachments.get(msg.attachId);
1150
+ if (!attachment) {
1151
+ this.sendTerminalOperationError(msg.attachId, msg.reqId, "terminal has not started");
1152
+ return;
1153
+ }
1154
+ const localThreadId = this.attachmentThreadIds.get(msg.attachId);
1155
+ const input = Buffer.from(msg.dataB64, "base64");
1156
+ if (!isTerminalProtocolResponse(input.toString("utf8"))) {
1157
+ this.cancelTraexStartupWatcher(localThreadId);
1158
+ }
1159
+ try {
1160
+ await attachment.write(input);
1161
+ await this.transport.sendAndDrain({
1162
+ t: "term.ack",
1163
+ attachId: msg.attachId,
1164
+ reqId: msg.reqId,
1165
+ operation: "input",
1166
+ });
1167
+ }
1168
+ catch (error) {
1169
+ this.sendTerminalOperationError(msg.attachId, msg.reqId, error);
1170
+ }
1171
+ }
1172
+ async resizeTerminal(msg) {
1173
+ const attachment = this.attachments.get(msg.attachId);
1174
+ if (!attachment) {
1175
+ this.sendTerminalOperationError(msg.attachId, msg.reqId, "terminal has not started");
1176
+ return;
1177
+ }
1178
+ try {
1179
+ await attachment.resize(msg.cols, msg.rows);
1180
+ await this.transport.sendAndDrain({
1181
+ t: "term.ack",
1182
+ attachId: msg.attachId,
1183
+ reqId: msg.reqId,
1184
+ operation: "resize",
1185
+ });
1186
+ }
1187
+ catch (error) {
1188
+ this.sendTerminalOperationError(msg.attachId, msg.reqId, error);
1189
+ }
1190
+ }
1191
+ sendTerminalOperationError(attachId, reqId, error) {
1192
+ this.transport.send({
1193
+ t: "term.error",
1194
+ attachId,
1195
+ reqId,
1196
+ code: "terminal_open_failed",
1197
+ message: error instanceof Error ? error.message : String(error),
928
1198
  });
929
- this.transport.send({ t: "term.opened", attachId: msg.attachId, role });
930
1199
  }
931
1200
  async runCap(capId, name, args) {
932
1201
  try {
@@ -74,11 +74,11 @@ export interface RunnerManagerOptions {
74
74
  signalChild?: (child: ChildProcess, signal: NodeJS.Signals, processGroup: boolean) => void;
75
75
  /** Injected for tests. Best-effort close of the deterministic private tmux
76
76
  * server owned by a Session runner; reference implementation does not verify/retry close. */
77
- terminateTerminalServer?: (terminalName: string) => boolean | Promise<boolean>;
77
+ terminateTerminalServer?: (terminalName: string, ownerPid?: number) => boolean | Promise<boolean>;
78
78
  /** Injected tmux `#{window_activity}` reader. Returns epoch seconds. */
79
- terminalWindowActivityAt?: (terminalName: string) => number | null | Promise<number | null>;
79
+ terminalWindowActivityAt?: (terminalName: string, ownerPid?: number) => number | null | Promise<number | null>;
80
80
  /** Injected tmux attached-client probe. */
81
- terminalHasAttachedClient?: (terminalName: string) => boolean | Promise<boolean>;
81
+ terminalHasAttachedClient?: (terminalName: string, ownerPid?: number) => boolean | Promise<boolean>;
82
82
  now?: () => number;
83
83
  /** Wall clock used to compare tmux's epoch timestamp. */
84
84
  wallNow?: () => number;
@@ -116,24 +116,39 @@ export interface OpenTerminalOptions {
116
116
  command?: string;
117
117
  args?: string[];
118
118
  }
119
- /**
120
- * Parent-side handle to a live terminal on a runner child. Bytes flow through
121
- * {@link onData} / {@link write} (decoded from the base64 wire frames); resize
122
- * and close are forwarded as control messages. {@link ready} resolves with the
123
- * granted role once the child attaches (an `owner` request is downgraded to
124
- * read-only when the owner slot is taken).
125
- */
119
+ export interface ParentTerminalRead {
120
+ data: Uint8Array;
121
+ nextOffset: number;
122
+ done: boolean;
123
+ finalOffset?: number;
124
+ }
125
+ export interface ParentTerminalReaderDone {
126
+ finalOffset: number;
127
+ reason: "terminal_exited" | "client_closed" | "backpressure" | "internal";
128
+ exitCode: number;
129
+ }
130
+ /** Parent-side demand-driven terminal handle. `ready` publishes only captured
131
+ * history metadata; the caller must pull every seed byte before `start`. */
126
132
  export interface ParentTerminal {
127
133
  readonly attachId: string;
128
134
  readonly ready: Promise<{
129
135
  role: TerminalRole;
136
+ seedBytes: number;
137
+ dimensions?: {
138
+ cols: number;
139
+ rows: number;
140
+ };
130
141
  }>;
131
- onData(listener: (chunk: string) => void): void;
132
- onExit(listener: (info: {
133
- exitCode: number;
142
+ readonly readerDone: Promise<ParentTerminalReaderDone>;
143
+ readSeed(offset: number, maxBytes: number): Promise<ParentTerminalRead>;
144
+ start(): Promise<void>;
145
+ read(offset: number, maxBytes: number): Promise<ParentTerminalRead>;
146
+ onResize?(listener: (dimensions: {
147
+ cols: number;
148
+ rows: number;
134
149
  }) => void): void;
135
- write(data: string): void;
136
- resize(cols: number, rows: number): void;
150
+ write(data: string | Uint8Array): Promise<void>;
151
+ resize(cols: number, rows: number): Promise<void>;
137
152
  close(): void;
138
153
  }
139
154
  /** A typed attach failure so transport adapters can distinguish an expected
@@ -210,9 +225,8 @@ export declare class RunnerManager implements AgentCapabilities {
210
225
  /**
211
226
  * Open a live terminal on the session's runner child (spawning it if needed).
212
227
  * Phase C hosts one terminal ("main") per session; the returned handle is a
213
- * single attach client — `owner` (read-write) or a downgraded `read-only`
214
- * viewer per the child's ownership rule. Not part of `AgentExecutor`; the WS
215
- * bridge calls it directly.
228
+ * single attach client with the requested `owner` (read-write) or `read-only`
229
+ * role. Not part of `AgentExecutor`; the WS bridge calls it directly.
216
230
  */
217
231
  openTerminal(localThreadId: string, opts: OpenTerminalOptions): ParentTerminal;
218
232
  /**
@@ -345,6 +359,7 @@ export declare class RunnerManager implements AgentCapabilities {
345
359
  * and tmux's own activity clock are independent busy evidence. There is no
346
360
  * "user was inactive for an hour" override for an active turn. */
347
361
  private reapNativePane;
362
+ private reapNativeTerminal;
348
363
  private isNativePaneBusy;
349
364
  private isManagedNativeHandle;
350
365
  private failActiveResponses;