@rynx-ai/runtime 0.1.11-beta.33 → 0.1.11-beta.35

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.
Files changed (36) hide show
  1. package/dist/claude/native-bridge.d.ts +16 -1
  2. package/dist/claude/native-bridge.js +91 -17
  3. package/dist/claude/native-hook-main.js +1 -0
  4. package/dist/claude/native-integration.d.ts +10 -0
  5. package/dist/claude/native-integration.js +100 -6
  6. package/dist/codex-app-server/forwarder.js +23 -1
  7. package/dist/codex-app-server/mapping.js +34 -10
  8. package/dist/codex-app-server/process-registry.d.ts +36 -0
  9. package/dist/codex-app-server/process-registry.js +320 -0
  10. package/dist/codex-app-server/ws-channel.d.ts +7 -0
  11. package/dist/codex-app-server/ws-channel.js +85 -9
  12. package/dist/codex-home.d.ts +35 -3
  13. package/dist/codex-home.js +321 -14
  14. package/dist/host.d.ts +11 -25
  15. package/dist/host.js +147 -132
  16. package/dist/index.d.ts +2 -2
  17. package/dist/index.js +1 -1
  18. package/dist/runner/child.d.ts +25 -3
  19. package/dist/runner/child.js +359 -64
  20. package/dist/runner/manager.d.ts +33 -18
  21. package/dist/runner/manager.js +229 -49
  22. package/dist/runner/protocol.d.ts +79 -7
  23. package/dist/runner/transport.d.ts +9 -2
  24. package/dist/runner/transport.js +55 -3
  25. package/dist/runner-main.js +8 -3
  26. package/dist/terminal/codex-tui.d.ts +4 -0
  27. package/dist/terminal/codex-tui.js +5 -0
  28. package/dist/terminal/control-parser.d.ts +39 -0
  29. package/dist/terminal/control-parser.js +172 -0
  30. package/dist/terminal/registry.d.ts +18 -15
  31. package/dist/terminal/registry.js +43 -23
  32. package/dist/terminal/spool.d.ts +47 -0
  33. package/dist/terminal/spool.js +231 -0
  34. package/dist/terminal/tmux.d.ts +93 -84
  35. package/dist/terminal/tmux.js +675 -200
  36. package/package.json +3 -4
@@ -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,10 +120,13 @@ 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();
129
+ codexStartupMonitors = new Map();
126
130
  terminalWatchers = new Map();
127
131
  /** Opens are async; a close received before attach resolves tombstones the id. */
128
132
  pendingTerminalOpens = new Set();
@@ -133,7 +137,9 @@ export class RunnerSession {
133
137
  mirrorImageAcks = new Map();
134
138
  /** Provider name retained for asynchronous Terminal-exit diagnostics. */
135
139
  liveRuntimes = new Map();
140
+ terminalStatuses = new Map();
136
141
  shuttingDown = false;
142
+ shutdownPromise = null;
137
143
  constructor({ transport, executor, onShutdown }) {
138
144
  this.transport = transport;
139
145
  this.executor = executor;
@@ -177,7 +183,9 @@ export class RunnerSession {
177
183
  void this.runCap(msg.capId, msg.name, msg.args);
178
184
  return;
179
185
  case "term.open":
180
- if (this.pendingTerminalOpens.has(msg.attachId) || this.attachments.has(msg.attachId)) {
186
+ if (this.pendingTerminalOpens.has(msg.attachId) ||
187
+ this.preparations.has(msg.attachId) ||
188
+ this.attachments.has(msg.attachId)) {
181
189
  this.transport.send({
182
190
  t: "term.error",
183
191
  attachId: msg.attachId,
@@ -192,29 +200,35 @@ export class RunnerSession {
192
200
  this.cancelledTerminalOpens.delete(msg.attachId);
193
201
  });
194
202
  return;
203
+ case "term.seed.read":
204
+ void this.readTerminalSeed(msg);
205
+ return;
206
+ case "term.start":
207
+ void this.startTerminal(msg);
208
+ return;
209
+ case "term.read":
210
+ void this.readTerminalOutput(msg);
211
+ return;
195
212
  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
- }
213
+ void this.writeTerminalInput(msg);
207
214
  return;
208
215
  case "term.resize":
209
- this.attachments.get(msg.attachId)?.resize(msg.cols, msg.rows);
216
+ void this.resizeTerminal(msg);
210
217
  return;
211
218
  case "term.close": {
212
219
  if (this.pendingTerminalOpens.has(msg.attachId)) {
213
220
  this.cancelledTerminalOpens.add(msg.attachId);
214
221
  }
222
+ const pending = this.preparations.get(msg.attachId);
223
+ if (pending) {
224
+ clearTimeout(pending.timer);
225
+ this.preparations.delete(msg.attachId);
226
+ pending.preparation.kill();
227
+ }
215
228
  const attachment = this.attachments.get(msg.attachId);
216
229
  this.attachments.delete(msg.attachId);
217
230
  this.attachmentThreadIds.delete(msg.attachId);
231
+ this.attachmentRoles.delete(msg.attachId);
218
232
  attachment?.kill();
219
233
  return;
220
234
  }
@@ -234,8 +248,11 @@ export class RunnerSession {
234
248
  case "live.interrupt":
235
249
  void this.interruptLive(msg);
236
250
  return;
251
+ case "terminal.reap":
252
+ void this.reapTerminal(msg);
253
+ return;
237
254
  case "shutdown":
238
- this.shutdown();
255
+ void this.shutdown();
239
256
  return;
240
257
  }
241
258
  }
@@ -269,14 +286,19 @@ export class RunnerSession {
269
286
  const target = { id: localThreadId };
270
287
  const emit = (event) => {
271
288
  const sessionId = target.id;
289
+ if (event.type === "session.status") {
290
+ this.terminalStatuses.set(sessionId, event.status);
291
+ }
272
292
  this.enqueueMirror(() => this.sendMirroredEvent(sessionId, event));
273
293
  };
274
294
  const retarget = (newId, meta) => {
295
+ const previousId = target.id;
296
+ this.transferNativeSessionResources(previousId, newId);
275
297
  target.id = newId;
276
298
  this.enqueueMirror(() => {
277
299
  this.transport.send({
278
300
  t: "rotate",
279
- from: localThreadId,
301
+ from: previousId,
280
302
  to: newId,
281
303
  kind: meta.kind,
282
304
  workspace: meta.workspace,
@@ -287,11 +309,41 @@ export class RunnerSession {
287
309
  };
288
310
  return { emit, retarget };
289
311
  }
312
+ transferNativeSessionResources(sourceId, targetId) {
313
+ if (sourceId === targetId)
314
+ return;
315
+ const sourceTerminalId = `${sourceId}-main`;
316
+ const targetTerminalId = `${targetId}-main`;
317
+ this.terminals.transfer(sourceTerminalId, targetTerminalId);
318
+ const watcher = this.terminalWatchers.get(sourceId);
319
+ if (watcher)
320
+ clearInterval(watcher);
321
+ this.terminalWatchers.delete(sourceId);
322
+ const runtime = this.liveRuntimes.get(sourceId);
323
+ if (runtime) {
324
+ this.liveRuntimes.delete(sourceId);
325
+ this.liveRuntimes.set(targetId, runtime);
326
+ }
327
+ const status = this.terminalStatuses.get(sourceId);
328
+ if (status) {
329
+ this.terminalStatuses.delete(sourceId);
330
+ this.terminalStatuses.set(targetId, status);
331
+ }
332
+ if (this.liveIds.delete(sourceId))
333
+ this.liveIds.add(targetId);
334
+ for (const [attachId, sessionId] of this.attachmentThreadIds) {
335
+ if (sessionId === sourceId)
336
+ this.attachmentThreadIds.set(attachId, targetId);
337
+ }
338
+ const terminal = this.terminals.get(targetTerminalId);
339
+ if (terminal)
340
+ this.watchNativeTerminal(targetId, targetTerminalId, terminal);
341
+ }
290
342
  enqueueMirror(operation) {
291
343
  this.mirrorQueue = this.mirrorQueue.then(operation).catch((error) => {
292
344
  if (!this.shuttingDown) {
293
345
  this.rejectMirrorImageAcks(error instanceof Error ? error : new Error(String(error)));
294
- this.shutdown();
346
+ void this.shutdown();
295
347
  }
296
348
  });
297
349
  }
@@ -477,10 +529,31 @@ export class RunnerSession {
477
529
  }
478
530
  }
479
531
  monitorCodexThreadStartup(localThreadId, runtime, provider) {
532
+ // Starting (or declining to start) a new monitor invalidates every callback
533
+ // owned by the previous launch before it can touch the replacement state.
534
+ this.codexStartupMonitors.delete(localThreadId);
480
535
  if (!provider.waitLiveReady)
481
536
  return;
537
+ if (provider.liveSessionFailure?.(localThreadId)?.code === "login_required")
538
+ return;
539
+ const expectedTerminal = this.terminals.get(`${localThreadId}-main`);
540
+ const monitor = Symbol(localThreadId);
541
+ this.codexStartupMonitors.set(localThreadId, monitor);
542
+ const claimCurrentLaunch = () => {
543
+ if (this.codexStartupMonitors.get(localThreadId) !== monitor ||
544
+ this.terminals.get(`${localThreadId}-main`) !== expectedTerminal)
545
+ return false;
546
+ this.codexStartupMonitors.delete(localThreadId);
547
+ return true;
548
+ };
482
549
  void provider.waitLiveReady(localThreadId, CODEX_THREAD_START_TIMEOUT_MS).then((ready) => {
483
- if (ready)
550
+ if (ready) {
551
+ if (this.codexStartupMonitors.get(localThreadId) === monitor) {
552
+ this.codexStartupMonitors.delete(localThreadId);
553
+ }
554
+ return;
555
+ }
556
+ if (!claimCurrentLaunch())
484
557
  return;
485
558
  const terminal = this.terminals.get(`${localThreadId}-main`);
486
559
  const paneFailure = terminal && !terminal.isAlive()
@@ -499,27 +572,30 @@ export class RunnerSession {
499
572
  : `${runtime === "traex" ? "Traex" : "Codex"} TUI did not publish thread/started within ${CODEX_THREAD_START_TIMEOUT_MS / 1_000}s after app-server and observer readiness`);
500
573
  this.failCodexThreadStartup(localThreadId, provider, new AgentRuntimeError(detail, 503, paneFailure
501
574
  ? "native_terminal_exited_before_session"
502
- : "native_thread_discovery_timeout"));
575
+ : "native_thread_discovery_timeout"), expectedTerminal);
503
576
  }).catch((error) => {
577
+ if (!claimCurrentLaunch())
578
+ return;
504
579
  this.failCodexThreadStartup(localThreadId, provider, error instanceof AgentRuntimeError
505
580
  ? error
506
- : nativePhaseError(runtime, "native_thread_discovery_failed", "native session discovery failed", error));
581
+ : nativePhaseError(runtime, "native_thread_discovery_failed", "native session discovery failed", error), expectedTerminal);
507
582
  });
508
583
  }
509
584
  /** Discovery failure is terminal for this partial native launch. Publish the
510
585
  * exact cause first so an in-flight injection wakes, then drop the observer
511
586
  * and TUI so a later task retry creates a clean launch instead of reusing an
512
587
  * already-rejected readiness promise. */
513
- failCodexThreadStartup(localThreadId, provider, error) {
588
+ failCodexThreadStartup(localThreadId, provider, error, expectedTerminal) {
514
589
  if (!provider.failLiveStartup?.(localThreadId, error))
515
590
  return false;
591
+ this.codexStartupMonitors.delete(localThreadId);
516
592
  const watcher = this.terminalWatchers.get(localThreadId);
517
593
  if (watcher)
518
594
  clearInterval(watcher);
519
595
  this.terminalWatchers.delete(localThreadId);
520
596
  this.cancelTraexStartupWatcher(localThreadId);
521
597
  try {
522
- this.terminals.close(`${localThreadId}-main`);
598
+ this.terminals.close(`${localThreadId}-main`, expectedTerminal);
523
599
  }
524
600
  catch (closeError) {
525
601
  console.warn(`[runner] session=${localThreadId} failed to close native Terminal after startup failure: ${closeError instanceof Error ? closeError.message : String(closeError)}`);
@@ -614,6 +690,43 @@ export class RunnerSession {
614
690
  ...(error ? { error } : {}),
615
691
  });
616
692
  }
693
+ async reapTerminal(msg) {
694
+ let error;
695
+ try {
696
+ const lifecycle = this.terminals.lifecycle(msg.terminalId);
697
+ const watcher = this.terminalWatchers.get(msg.localThreadId);
698
+ if (watcher)
699
+ clearInterval(watcher);
700
+ this.terminalWatchers.delete(msg.localThreadId);
701
+ this.cancelTraexStartupWatcher(msg.localThreadId);
702
+ for (const [attachId, sessionId] of this.attachmentThreadIds) {
703
+ if (sessionId !== msg.localThreadId)
704
+ continue;
705
+ this.attachments.get(attachId)?.kill();
706
+ this.attachments.delete(attachId);
707
+ this.attachmentThreadIds.delete(attachId);
708
+ this.attachmentRoles.delete(attachId);
709
+ }
710
+ this.terminals.close(msg.terminalId);
711
+ if (lifecycle === "auxiliary") {
712
+ await this.liveProvider.teardownAuxiliaryTerminalRuntime?.(msg.localThreadId);
713
+ this.liveIds.delete(msg.localThreadId);
714
+ this.liveRuntimes.delete(msg.localThreadId);
715
+ this.terminalStatuses.delete(msg.localThreadId);
716
+ }
717
+ }
718
+ catch (cause) {
719
+ error = toWireError(cause);
720
+ }
721
+ this.transport.send({
722
+ t: "terminal.reaped",
723
+ reqId: msg.reqId,
724
+ localThreadId: msg.localThreadId,
725
+ terminalId: msg.terminalId,
726
+ ok: error === undefined,
727
+ ...(error ? { error } : {}),
728
+ });
729
+ }
617
730
  /** Launch (idempotently) the session's codex TUI pane from the executor's
618
731
  * `codexTerminalSpec`. Shares the `${id}-main` terminal id with `term.open`,
619
732
  * so the web attach reuses the same detached pane. */
@@ -623,12 +736,23 @@ export class RunnerSession {
623
736
  return;
624
737
  const terminalId = `${localThreadId}-main`;
625
738
  const term = this.terminals.getOrCreate(terminalId, {
739
+ lifecycle: spec.lifecycle,
626
740
  cwd: spec.cwd,
627
741
  command: spec.command,
628
742
  args: spec.args,
629
743
  cols: cols ?? 120,
630
744
  rows: rows ?? 40,
631
745
  ...(spec.env ? { env: spec.env } : {}),
746
+ ...(spec.scrollback === undefined ? {} : { scrollback: spec.scrollback }),
747
+ ...(spec.tmuxAllowPassthrough === undefined
748
+ ? {}
749
+ : { tmuxAllowPassthrough: spec.tmuxAllowPassthrough }),
750
+ ...(spec.tmuxStartOnAttach === undefined
751
+ ? {}
752
+ : { tmuxStartOnAttach: spec.tmuxStartOnAttach }),
753
+ ...(spec.keepAliveAfterExit === undefined
754
+ ? {}
755
+ : { keepAliveAfterExit: spec.keepAliveAfterExit }),
632
756
  });
633
757
  if (spec.skipTraexStartupPrompts) {
634
758
  const watcher = Symbol(localThreadId);
@@ -671,30 +795,39 @@ export class RunnerSession {
671
795
  this.terminalWatchers.delete(localThreadId);
672
796
  this.cancelTraexStartupWatcher(localThreadId);
673
797
  const runtime = this.liveRuntimes.get(localThreadId);
798
+ const lifecycle = this.terminals.lifecycle(terminalId);
674
799
  const paneFailure = terminalFailureDetail(terminal.capturePane?.() ?? "");
675
- 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
- }
800
+ const startupFailed = this.failCodexThreadStartup(localThreadId, this.liveProvider, nativePhaseError(runtime, "native_terminal_exited_before_session", "Terminal exited before native session discovery completed", paneFailure || undefined), terminal);
801
+ if (startupFailed || lifecycle === undefined)
695
802
  return;
803
+ try {
804
+ this.terminals.close(terminalId, terminal);
805
+ }
806
+ catch (closeError) {
807
+ console.warn(`[runner] session=${localThreadId} failed to close exited native Terminal: ${closeError instanceof Error ? closeError.message : String(closeError)}`);
808
+ }
809
+ const status = this.terminalStatuses.get(localThreadId) ?? "unknown";
810
+ if (lifecycle === "auxiliary") {
811
+ console.warn(`[runner] session=${localThreadId} ${providerDisplayName(runtime)} auxiliary Terminal exited`);
696
812
  }
697
- this.liveProvider.failLiveSession?.(localThreadId, nativePhaseError(runtime, "native_terminal_exited", "Terminal exited unexpectedly", paneFailure || undefined));
813
+ else {
814
+ if (status !== "idle") {
815
+ this.liveProvider.failLiveSession?.(localThreadId, nativePhaseError(runtime, "required_terminal_exited", "Required Terminal exited unexpectedly", paneFailure || undefined));
816
+ }
817
+ this.liveProvider.stopLiveCodexSession?.(localThreadId);
818
+ this.liveIds.delete(localThreadId);
819
+ this.liveRuntimes.delete(localThreadId);
820
+ this.terminalStatuses.delete(localThreadId);
821
+ }
822
+ this.enqueueMirror(() => {
823
+ this.transport.send({
824
+ t: "terminal.lifecycle.ended",
825
+ localThreadId,
826
+ terminalId,
827
+ lifecycle,
828
+ status,
829
+ });
830
+ });
698
831
  }).catch((error) => {
699
832
  console.warn(`[runner] session=${localThreadId} native Terminal liveness probe failed: ${error instanceof Error ? error.message : String(error)}`);
700
833
  }).finally(() => {
@@ -835,6 +968,7 @@ export class RunnerSession {
835
968
  for (const timer of this.terminalWatchers.values())
836
969
  clearInterval(timer);
837
970
  this.terminalWatchers.clear();
971
+ this.codexStartupMonitors.clear();
838
972
  for (const id of this.liveIds) {
839
973
  this.liveProvider.stopLiveCodexSession?.(id, {
840
974
  deferClaudeInteractionCleanup: true,
@@ -846,19 +980,31 @@ export class RunnerSession {
846
980
  /** Stop event forwarding, kill native terminals/hooks, then synchronously
847
981
  * scrub provider handoff files before the child process is allowed to exit. */
848
982
  shutdown() {
849
- if (this.shuttingDown)
850
- return;
983
+ if (this.shutdownPromise)
984
+ return this.shutdownPromise;
851
985
  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();
986
+ this.shutdownPromise = (async () => {
987
+ this.rejectMirrorImageAcks(new Error("runner is shutting down"));
988
+ this.stopLive();
989
+ for (const pending of this.preparations.values()) {
990
+ clearTimeout(pending.timer);
991
+ pending.preparation.kill();
992
+ }
993
+ this.preparations.clear();
994
+ for (const attachment of this.attachments.values())
995
+ attachment.kill();
996
+ this.terminals.closeAll();
997
+ this.attachments.clear();
998
+ this.attachmentThreadIds.clear();
999
+ this.attachmentRoles.clear();
1000
+ await this.liveProvider.finalizeStoppedLiveSessions?.();
1001
+ this.onShutdown();
1002
+ })();
1003
+ return this.shutdownPromise;
857
1004
  }
858
1005
  async openTerminal(msg) {
859
1006
  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
1007
+ // codex/claude-native session (no explicit command): DUMB ATTACH. A tab switch ONLY
862
1008
  // attaches an already-live pane; it NEVER ensures the forwarder or relaunches a
863
1009
  // dead pane. Creation/relaunch happens on message-send (`live.ensure`) or an
864
1010
  // explicit restart. A missing/dead pane → `term.error`, so the web shows the
@@ -880,6 +1026,7 @@ export class RunnerSession {
880
1026
  }
881
1027
  // Login-shell terminal (explicit command): create-or-reuse as before.
882
1028
  this.terminals.getOrCreate(msg.terminalId, {
1029
+ lifecycle: "auxiliary",
883
1030
  cwd: msg.cwd,
884
1031
  command: msg.command,
885
1032
  args: msg.args,
@@ -897,14 +1044,14 @@ export class RunnerSession {
897
1044
  });
898
1045
  }
899
1046
  }
900
- /** Attach an already-created terminal and forward its data/exit to the parent. */
1047
+ /** Prepare captured history without starting the live control client. */
901
1048
  async attachExisting(msg) {
902
- const { attachment, role } = await this.terminals.attach(msg.terminalId, msg.role, {
1049
+ const { preparation, role } = await this.terminals.prepare(msg.terminalId, msg.role, {
903
1050
  cols: msg.cols,
904
1051
  rows: msg.rows,
905
1052
  });
906
1053
  if (this.cancelledTerminalOpens.has(msg.attachId)) {
907
- attachment.kill();
1054
+ preparation.kill();
908
1055
  this.transport.send({
909
1056
  t: "term.error",
910
1057
  attachId: msg.attachId,
@@ -913,20 +1060,168 @@ export class RunnerSession {
913
1060
  });
914
1061
  return;
915
1062
  }
916
- this.attachments.set(msg.attachId, attachment);
1063
+ const timer = setTimeout(() => {
1064
+ const pending = this.preparations.get(msg.attachId);
1065
+ if (!pending || pending.preparation !== preparation)
1066
+ return;
1067
+ this.preparations.delete(msg.attachId);
1068
+ this.attachmentThreadIds.delete(msg.attachId);
1069
+ this.attachmentRoles.delete(msg.attachId);
1070
+ preparation.kill();
1071
+ }, TERMINAL_PREPARATION_TTL_MS);
1072
+ timer.unref?.();
1073
+ this.preparations.set(msg.attachId, { preparation, timer });
1074
+ this.attachmentRoles.set(msg.attachId, role);
917
1075
  if (msg.localThreadId)
918
1076
  this.attachmentThreadIds.set(msg.attachId, msg.localThreadId);
919
- attachment.onData((chunk) => this.transport.send({
920
- t: "term.data",
1077
+ this.transport.send({
1078
+ t: "term.prepared",
921
1079
  attachId: msg.attachId,
922
- dataB64: Buffer.from(chunk, "utf8").toString("base64"),
923
- }));
924
- attachment.onExit((info) => {
925
- this.attachments.delete(msg.attachId);
1080
+ role,
1081
+ seedBytes: preparation.seedBytes,
1082
+ ...(preparation.dimensions ?? {}),
1083
+ });
1084
+ }
1085
+ async readTerminalSeed(msg) {
1086
+ const pending = this.preparations.get(msg.attachId);
1087
+ if (!pending) {
1088
+ this.sendTerminalOperationError(msg.attachId, msg.reqId, "terminal is not prepared");
1089
+ return;
1090
+ }
1091
+ try {
1092
+ const result = await pending.preparation.readSeed(msg.offset, msg.maxBytes);
1093
+ await this.transport.sendAndDrain({
1094
+ t: "term.seed.chunk",
1095
+ attachId: msg.attachId,
1096
+ reqId: msg.reqId,
1097
+ dataB64: Buffer.from(result.data).toString("base64"),
1098
+ nextOffset: result.nextOffset,
1099
+ done: result.done,
1100
+ ...(result.finalOffset === undefined ? {} : { finalOffset: result.finalOffset }),
1101
+ });
1102
+ }
1103
+ catch (error) {
1104
+ this.sendTerminalOperationError(msg.attachId, msg.reqId, error);
1105
+ }
1106
+ }
1107
+ async startTerminal(msg) {
1108
+ const pending = this.preparations.get(msg.attachId);
1109
+ if (!pending) {
1110
+ this.sendTerminalOperationError(msg.attachId, msg.reqId, "terminal is not prepared");
1111
+ return;
1112
+ }
1113
+ clearTimeout(pending.timer);
1114
+ this.preparations.delete(msg.attachId);
1115
+ try {
1116
+ const attachment = await pending.preparation.start();
1117
+ this.attachments.set(msg.attachId, attachment);
1118
+ attachment.onResize(({ cols, rows }) => {
1119
+ this.transport.send({ t: "term.dimensions", attachId: msg.attachId, cols, rows });
1120
+ });
1121
+ await this.transport.sendAndDrain({
1122
+ t: "term.started",
1123
+ attachId: msg.attachId,
1124
+ reqId: msg.reqId,
1125
+ });
1126
+ void attachment.readerDone.then(async (info) => {
1127
+ await this.transport.sendAndDrain({
1128
+ t: "term.reader.done",
1129
+ attachId: msg.attachId,
1130
+ finalOffset: info.finalOffset,
1131
+ reason: info.reason,
1132
+ exitCode: info.exitCode,
1133
+ }).catch(() => undefined);
1134
+ });
1135
+ }
1136
+ catch (error) {
1137
+ pending.preparation.kill();
926
1138
  this.attachmentThreadIds.delete(msg.attachId);
927
- this.transport.send({ t: "term.exit", attachId: msg.attachId, exitCode: info.exitCode });
1139
+ this.attachmentRoles.delete(msg.attachId);
1140
+ this.sendTerminalOperationError(msg.attachId, msg.reqId, error);
1141
+ }
1142
+ }
1143
+ async readTerminalOutput(msg) {
1144
+ const attachment = this.attachments.get(msg.attachId);
1145
+ if (!attachment) {
1146
+ this.sendTerminalOperationError(msg.attachId, msg.reqId, "terminal has not started");
1147
+ return;
1148
+ }
1149
+ try {
1150
+ const result = await attachment.read(msg.offset, msg.maxBytes);
1151
+ await this.transport.sendAndDrain({
1152
+ t: "term.chunk",
1153
+ attachId: msg.attachId,
1154
+ reqId: msg.reqId,
1155
+ dataB64: Buffer.from(result.data).toString("base64"),
1156
+ nextOffset: result.nextOffset,
1157
+ done: result.done,
1158
+ ...(result.finalOffset === undefined ? {} : { finalOffset: result.finalOffset }),
1159
+ });
1160
+ }
1161
+ catch (error) {
1162
+ this.sendTerminalOperationError(msg.attachId, msg.reqId, error);
1163
+ }
1164
+ }
1165
+ async writeTerminalInput(msg) {
1166
+ if (this.attachmentRoles.get(msg.attachId) !== "owner") {
1167
+ await this.transport.sendAndDrain({
1168
+ t: "term.ack",
1169
+ attachId: msg.attachId,
1170
+ reqId: msg.reqId,
1171
+ operation: "input",
1172
+ });
1173
+ return;
1174
+ }
1175
+ const attachment = this.attachments.get(msg.attachId);
1176
+ if (!attachment) {
1177
+ this.sendTerminalOperationError(msg.attachId, msg.reqId, "terminal has not started");
1178
+ return;
1179
+ }
1180
+ const localThreadId = this.attachmentThreadIds.get(msg.attachId);
1181
+ const input = Buffer.from(msg.dataB64, "base64");
1182
+ if (!isTerminalProtocolResponse(input.toString("utf8"))) {
1183
+ this.cancelTraexStartupWatcher(localThreadId);
1184
+ }
1185
+ try {
1186
+ await attachment.write(input);
1187
+ await this.transport.sendAndDrain({
1188
+ t: "term.ack",
1189
+ attachId: msg.attachId,
1190
+ reqId: msg.reqId,
1191
+ operation: "input",
1192
+ });
1193
+ }
1194
+ catch (error) {
1195
+ this.sendTerminalOperationError(msg.attachId, msg.reqId, error);
1196
+ }
1197
+ }
1198
+ async resizeTerminal(msg) {
1199
+ const attachment = this.attachments.get(msg.attachId);
1200
+ if (!attachment) {
1201
+ this.sendTerminalOperationError(msg.attachId, msg.reqId, "terminal has not started");
1202
+ return;
1203
+ }
1204
+ try {
1205
+ await attachment.resize(msg.cols, msg.rows);
1206
+ await this.transport.sendAndDrain({
1207
+ t: "term.ack",
1208
+ attachId: msg.attachId,
1209
+ reqId: msg.reqId,
1210
+ operation: "resize",
1211
+ });
1212
+ }
1213
+ catch (error) {
1214
+ this.sendTerminalOperationError(msg.attachId, msg.reqId, error);
1215
+ }
1216
+ }
1217
+ sendTerminalOperationError(attachId, reqId, error) {
1218
+ this.transport.send({
1219
+ t: "term.error",
1220
+ attachId,
1221
+ reqId,
1222
+ code: "terminal_open_failed",
1223
+ message: error instanceof Error ? error.message : String(error),
928
1224
  });
929
- this.transport.send({ t: "term.opened", attachId: msg.attachId, role });
930
1225
  }
931
1226
  async runCap(capId, name, args) {
932
1227
  try {