tmux-ide 2.9.10 → 2.9.11

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 (42) hide show
  1. package/bin/cli.js +173 -61
  2. package/package.json +1 -1
  3. package/packages/contracts/src/pane-stream.ts +12 -0
  4. package/packages/core/src/terminal-replica-hash-cache.ts +8 -1
  5. package/packages/daemon/dist/native/TmuxIdeNotifier.app/Contents/Info.plist +2 -2
  6. package/packages/daemon/dist/native/TmuxIdeNotifier.app/Contents/MacOS/tmux-ide-notifier +0 -0
  7. package/packages/daemon/dist/native/TmuxIdeNotifier.app/Contents/Resources/Assets.car +0 -0
  8. package/packages/daemon/dist/native/TmuxIdeNotifier.app/Contents/_CodeSignature/CodeResources +2 -2
  9. package/packages/daemon/dist/terminal/mirror/mirror-service.js +12 -2
  10. package/packages/daemon/dist/terminal/mirror/session-channel.js +46 -23
  11. package/packages/daemon/dist/terminal/pane-stream/pane-stream-websocket.js +15 -4
  12. package/packages/daemon/dist/terminal/session-runtime/registry.js +17 -6
  13. package/packages/daemon/dist/terminal/session-runtime/terminal-replica-interpreter.js +95 -36
  14. package/packages/daemon/dist/terminal/session-runtime/terminal-replica-owner.js +6 -0
  15. package/packages/daemon/dist/terminal/session-runtime/transport-binding.js +2 -2
  16. package/packages/daemon/dist/tui/mirror/runtime/application-generation-starter.js +16 -1
  17. package/packages/daemon/dist/tui/mirror/runtime/application-machine-agent-navigation.js +13 -0
  18. package/packages/daemon/dist/tui/mirror/runtime/application-machine-navigation.js +12 -4
  19. package/packages/daemon/dist/tui/mirror/runtime/application-retained-agent.js +18 -0
  20. package/packages/daemon/dist/tui/mirror/runtime/application-terminal-interaction-controller.js +62 -2
  21. package/packages/daemon/dist/tui/mirror/runtime/open-tui-generation-host.js +2 -2
  22. package/packages/daemon/dist/tui/mirror/runtime/runtime-layout-presentation.js +6 -3
  23. package/packages/daemon/dist/tui/mirror/runtime/select-terminal-pane.js +7 -2
  24. package/packages/daemon/dist/tui/mirror/runtime/terminal-selection-waiter.js +36 -0
  25. package/packages/daemon/src/terminal/mirror/mirror-service.ts +19 -2
  26. package/packages/daemon/src/terminal/mirror/session-channel.ts +57 -9
  27. package/packages/daemon/src/terminal/pane-stream/pane-stream-websocket.ts +29 -1
  28. package/packages/daemon/src/terminal/session-runtime/registry.ts +23 -3
  29. package/packages/daemon/src/terminal/session-runtime/terminal-replica-interpreter.ts +102 -44
  30. package/packages/daemon/src/terminal/session-runtime/terminal-replica-owner.ts +7 -0
  31. package/packages/daemon/src/terminal/session-runtime/transport-binding.ts +3 -0
  32. package/packages/daemon/src/tui/mirror/open-tui-workspace-runtime-port.ts +2 -0
  33. package/packages/daemon/src/tui/mirror/runtime/application-generation-starter.ts +17 -1
  34. package/packages/daemon/src/tui/mirror/runtime/application-machine-agent-navigation.ts +15 -0
  35. package/packages/daemon/src/tui/mirror/runtime/application-machine-navigation.ts +18 -4
  36. package/packages/daemon/src/tui/mirror/runtime/application-retained-agent.ts +29 -0
  37. package/packages/daemon/src/tui/mirror/runtime/application-terminal-interaction-controller.ts +63 -2
  38. package/packages/daemon/src/tui/mirror/runtime/open-tui-generation-host.ts +2 -2
  39. package/packages/daemon/src/tui/mirror/runtime/runtime-layout-presentation.ts +7 -4
  40. package/packages/daemon/src/tui/mirror/runtime/select-terminal-pane.ts +9 -1
  41. package/packages/daemon/src/tui/mirror/runtime/terminal-selection-waiter.ts +36 -0
  42. package/packages/daemon-client/src/pane-stream-client.ts +17 -0
@@ -177,6 +177,8 @@ export class SessionChannel {
177
177
  fittedWindows = new Map();
178
178
  cancelSync = null;
179
179
  syncOrdinal = 0;
180
+ identityJoinOrdinal = 0;
181
+ trustedInventoryJoinsMissing = false;
180
182
  completedSyncOrdinal = 0;
181
183
  lastDisplayNameSyncAtMs = 0;
182
184
  disposed = false;
@@ -412,20 +414,28 @@ export class SessionChannel {
412
414
  }
413
415
  /**
414
416
  * Strict daemon-internal inventory from this channel's current tmux truth.
415
- * Unlike the background discovery path, this query awaits descriptor
416
- * reconciliation before projecting and rejects incomplete identity rather
417
- * than returning the previous descriptor snapshot.
417
+ * Unlike background discovery, this query verifies current native membership
418
+ * before projecting and rejects incomplete identity. Stream admission may
419
+ * explicitly join missing pane records after that verification; ordinary
420
+ * inventory readers retain their existing fail-closed no-join contract.
418
421
  */
419
- describeTrustedInventory(expectedRuntimeSessionId) {
422
+ describeTrustedInventory(expectedRuntimeSessionId, joinMissingPanes = false) {
420
423
  if (this.disposed) {
421
424
  return Promise.reject(new Error(`mirror session ${this.opts.session} is disposed`));
422
425
  }
423
426
  if (this.trustedInventoryFlight) {
427
+ // A default read can fail precisely because it does not join new panes.
428
+ // Preserve that failure for its caller, then qualify admission separately.
429
+ if (joinMissingPanes &&
430
+ !this.trustedInventoryJoinsMissing &&
431
+ this.trustedInventoryFlightSessionId === expectedRuntimeSessionId)
432
+ return this.trustedInventoryFlight.then(() => this.describeTrustedInventory(expectedRuntimeSessionId, true), () => this.describeTrustedInventory(expectedRuntimeSessionId, true));
424
433
  return this.trustedInventoryFlightSessionId === expectedRuntimeSessionId
425
434
  ? this.trustedInventoryFlight
426
435
  : Promise.reject(new Error(`trusted inventory identity changed for ${this.opts.session}`));
427
436
  }
428
- const flight = this.refreshTrustedInventory(expectedRuntimeSessionId)
437
+ this.trustedInventoryJoinsMissing = joinMissingPanes;
438
+ const flight = this.refreshTrustedInventory(expectedRuntimeSessionId, 0, joinMissingPanes)
429
439
  .catch((error) => {
430
440
  this.windowLinkAuthority?.invalidate();
431
441
  this.latestWindowStage = null;
@@ -2723,7 +2733,7 @@ export class SessionChannel {
2723
2733
  }
2724
2734
  return { listed, movedWindowRuntimeIds };
2725
2735
  }
2726
- async refreshTrustedInventory(expectedRuntimeSessionId, attempt = 0) {
2736
+ async refreshTrustedInventory(expectedRuntimeSessionId, attempt = 0, joinMissingPanes = false) {
2727
2737
  const authorityOrdinal = this.windowIdentityOrdinal;
2728
2738
  const beforeLines = await this.io.request(`list-panes -s -t "${expectedRuntimeSessionId}" -F "${SESSION_PANE_DESCRIPTOR_FORMAT}"`);
2729
2739
  if (this.disposed)
@@ -2800,7 +2810,7 @@ export class SessionChannel {
2800
2810
  authorityOrdinal !== this.windowIdentityOrdinal) {
2801
2811
  if (attempt >= 1)
2802
2812
  throw new Error(`trusted inventory for ${this.opts.session} did not settle`);
2803
- return await this.refreshTrustedInventory(expectedRuntimeSessionId, attempt + 1);
2813
+ return await this.refreshTrustedInventory(expectedRuntimeSessionId, attempt + 1, joinMissingPanes);
2804
2814
  }
2805
2815
  if (this.disposed)
2806
2816
  throw new Error(`mirror session ${this.opts.session} is disposed`);
@@ -2832,17 +2842,27 @@ export class SessionChannel {
2832
2842
  !stagedPaneMembershipExact ||
2833
2843
  stagedPaneCount !== descriptors.length ||
2834
2844
  stagedPaneIds.size !== descriptors.length ||
2835
- [...computedWindowCounts.keys()].some((runtimeWindowId) => !confirmedWindowStage.windows.has(runtimeWindowId)) ||
2836
- descriptors.some((descriptor) => {
2837
- const pane = this.panesByRuntime.get(descriptor.runtimePaneId);
2838
- const window = confirmedWindowStage.windows.get(descriptor.windowId);
2839
- return (!pane ||
2840
- !window?.semanticId ||
2841
- descriptor.semanticPaneId !== pane.semanticId ||
2842
- descriptor.semanticWindowId !== window.semanticId ||
2843
- !WorkspaceIdSchemaZ.safeParse(pane.semanticId).success ||
2844
- !WorkspaceIdSchemaZ.safeParse(window.semanticId).success);
2845
- })) {
2845
+ [...computedWindowCounts.keys()].some((runtimeWindowId) => !confirmedWindowStage.windows.has(runtimeWindowId)))
2846
+ throw new Error(`trusted inventory for ${this.opts.session} lacks verified identity`);
2847
+ // Fresh native inventory can beat background discovery after window creation.
2848
+ // Join verified stable membership before projecting semantic records.
2849
+ if (joinMissingPanes &&
2850
+ descriptors.some((pane) => this.panesByRuntime.get(pane.runtimePaneId)?.semanticId !== pane.semanticPaneId)) {
2851
+ this.discovery.invalidate();
2852
+ await this.reconcileIdentity(descriptors, runtimePaneIds, () => authorityOrdinal === this.windowIdentityOrdinal, false);
2853
+ }
2854
+ if (this.disposed || authorityOrdinal !== this.windowIdentityOrdinal)
2855
+ throw new Error(`trusted inventory for ${this.opts.session} changed during identity join`);
2856
+ if (descriptors.some((descriptor) => {
2857
+ const pane = this.panesByRuntime.get(descriptor.runtimePaneId);
2858
+ const window = confirmedWindowStage.windows.get(descriptor.windowId);
2859
+ return (!pane ||
2860
+ !window?.semanticId ||
2861
+ descriptor.semanticPaneId !== pane.semanticId ||
2862
+ descriptor.semanticWindowId !== window.semanticId ||
2863
+ !WorkspaceIdSchemaZ.safeParse(pane.semanticId).success ||
2864
+ !WorkspaceIdSchemaZ.safeParse(window.semanticId).success);
2865
+ })) {
2846
2866
  throw new Error(`trusted inventory for ${this.opts.session} lacks verified identity`);
2847
2867
  }
2848
2868
  const previousCurrentWindow = this.currentWindow;
@@ -3174,7 +3194,8 @@ export class SessionChannel {
3174
3194
  })),
3175
3195
  };
3176
3196
  }
3177
- async reconcileIdentity(descriptors, listed) {
3197
+ async reconcileIdentity(descriptors, listed, current = () => true, publish = true) {
3198
+ const joinOrdinal = ++this.identityJoinOrdinal;
3178
3199
  if (this.disposed)
3179
3200
  return false;
3180
3201
  if (this.opts.hasSharedWindowConflict?.()) {
@@ -3205,7 +3226,7 @@ export class SessionChannel {
3205
3226
  ok: false,
3206
3227
  error: cause instanceof Error ? cause.message : String(cause),
3207
3228
  }))));
3208
- if (this.disposed)
3229
+ if (this.disposed || !current() || joinOrdinal !== this.identityJoinOrdinal)
3209
3230
  return plan.stampEffects.length > 0;
3210
3231
  const reconciliation = finalizeWorkspaceTmuxReconciliation(plan, outcomes);
3211
3232
  const descriptorByRuntime = new Map(descriptors.map((d) => [d.runtimePaneId, d]));
@@ -3311,9 +3332,11 @@ export class SessionChannel {
3311
3332
  * without this the new pane stays invisible until something else happens to
3312
3333
  * move a layout, and a split looks like it did not reach the view.
3313
3334
  */
3314
- for (const runtimeId of this.layoutByWindow.keys())
3315
- this.emitLayout(runtimeId);
3316
- this.emitLayoutAuthority();
3335
+ if (publish) {
3336
+ for (const runtimeId of this.layoutByWindow.keys())
3337
+ this.emitLayout(runtimeId);
3338
+ this.emitLayoutAuthority();
3339
+ }
3317
3340
  this.settleFirstJoin();
3318
3341
  return plan.stampEffects.length > 0;
3319
3342
  }
@@ -1,3 +1,4 @@
1
+ import { PANE_STREAM_INPUT_LIFETIME_CAPABILITY_VERSION, } from "@tmux-ide/contracts";
1
2
  import { inflateSync } from "node:zlib";
2
3
  import { PaneStreamLayoutSnapshotFrameSchemaZ } from "@tmux-ide/contracts";
3
4
  import { z } from "zod";
@@ -193,7 +194,7 @@ export class PaneStreamAdmissionCoordinator {
193
194
  try {
194
195
  const describeStartedAtMicros = trace ? this.#observability.nowMicros() : 0;
195
196
  if (this.#mirror.describeSessionAuthority) {
196
- const authority = await this.#mirror.describeSessionAuthority(context.sessionName);
197
+ const authority = await this.#mirror.describeSessionAuthority(context.sessionName, request.panes);
197
198
  described = authority.description;
198
199
  runtimeSessionId = authority.runtimeSessionId;
199
200
  }
@@ -765,6 +766,10 @@ export class PaneStreamLiveConnection {
765
766
  try {
766
767
  sendControl(this.#socket, {
767
768
  type: "ready",
769
+ ...(this.#sessionRuntimeBinding &&
770
+ this.#descriptor.terminalDelivery?.protocolVersions.includes(PANE_STREAM_INPUT_LIFETIME_CAPABILITY_VERSION)
771
+ ? { inputLifetime: true }
772
+ : {}),
768
773
  protocolVersion: PANE_STREAM_PROTOCOL_VERSION,
769
774
  daemonInstanceId: this.#binding.daemonInstanceId,
770
775
  requestId: this.#binding.requestId,
@@ -1995,7 +2000,7 @@ export class PaneStreamLiveConnection {
1995
2000
  }
1996
2001
  return;
1997
2002
  }
1998
- this.#acceptInput(frame.pane, frame.seq, frame.kind, frame.data, byteLength, frame.performanceTraceId, frame.causalProbe, ingressAtMicros, callbackAtSharedMicros, ingressAtSharedMicros);
2003
+ this.#acceptInput(frame.pane, frame.seq, frame.kind, frame.data, byteLength, frame.performanceTraceId, frame.causalProbe, ingressAtMicros, callbackAtSharedMicros, ingressAtSharedMicros, frame.expectedLifetime);
1999
2004
  };
2000
2005
  #acceptAuthorityGeneration(generation) {
2001
2006
  if (!this.#sessionRuntimeBinding || generation !== this.#sessionRuntimeBinding.generation) {
@@ -2121,7 +2126,13 @@ export class PaneStreamLiveConnection {
2121
2126
  this.#ledger.give(this.#clientId, pane, "renderer-backlog", returned);
2122
2127
  this.#evaluateResume(pane);
2123
2128
  }
2124
- #acceptInput(pane, seq, kind, data, frameBytes, performanceTraceId, causalProbe, ingressAtMicros = null, callbackAtSharedMicros = null, ingressAtSharedMicros = null) {
2129
+ #acceptInput(pane, seq, kind, data, frameBytes, performanceTraceId, causalProbe, ingressAtMicros = null, callbackAtSharedMicros = null, ingressAtSharedMicros = null, expectedLifetime) {
2130
+ if (expectedLifetime &&
2131
+ (!this.#sessionRuntimeBinding ||
2132
+ !this.#descriptor.terminalDelivery?.protocolVersions.includes(PANE_STREAM_INPUT_LIFETIME_CAPABILITY_VERSION))) {
2133
+ this.#failProtocol("input-rejected");
2134
+ return;
2135
+ }
2125
2136
  if (this.#descriptor.viewerMode !== "interactive") {
2126
2137
  this.#failProtocol("input-rejected");
2127
2138
  return;
@@ -2213,7 +2224,7 @@ export class PaneStreamLiveConnection {
2213
2224
  });
2214
2225
  }
2215
2226
  }
2216
- : undefined);
2227
+ : undefined, expectedLifetime);
2217
2228
  else if (kind === "text")
2218
2229
  channel.sub.sendText(data);
2219
2230
  else
@@ -472,10 +472,10 @@ export class SessionRuntimeRegistry {
472
472
  return { status: "retired" };
473
473
  return result;
474
474
  }
475
- async describeSessionAuthority(session) {
475
+ async describeSessionAuthority(session, expectedSemanticPaneIds) {
476
476
  const runtime = this.#runtime(session);
477
477
  await runtime.whenReady();
478
- const authority = await this.#mirror.describeSessionAuthority(session);
478
+ const authority = await this.#mirror.describeSessionAuthority(session, expectedSemanticPaneIds);
479
479
  if (this.#disposed || this.#sessions.get(session) !== runtime) {
480
480
  const error = new Error(`session runtime ${session} changed topology`);
481
481
  error.name = "MirrorTopologyChangedError";
@@ -1003,7 +1003,7 @@ class SessionRuntime {
1003
1003
  return Promise.reject(error);
1004
1004
  }
1005
1005
  }
1006
- sendInput(clientId, lease, semanticPaneId, rawInput, performanceTraceId, rawCausalProbe, onCausalResult) {
1006
+ sendInput(clientId, lease, semanticPaneId, rawInput, performanceTraceId, rawCausalProbe, onCausalResult, expectedLifetime) {
1007
1007
  this.assertController(lease, clientId);
1008
1008
  const inputLease = this.#authority.leaseFor(clientId, "input");
1009
1009
  if (!inputLease) {
@@ -1037,7 +1037,11 @@ class SessionRuntime {
1037
1037
  })
1038
1038
  : null;
1039
1039
  const started = this.#observability.enabled ? this.#observability.nowMicros() : 0;
1040
- const replicaOwner = this.#terminalReplicaOwner(semanticPaneId);
1040
+ const replicaOwner = expectedLifetime
1041
+ ? this.#terminalReplicas.get(semanticPaneId)
1042
+ : this.#terminalReplicaOwner(semanticPaneId);
1043
+ if (!replicaOwner)
1044
+ throw new Error("Input target lifetime unavailable");
1041
1045
  if (causalProbe) {
1042
1046
  this.#activeCausalCellProbes += 1;
1043
1047
  let settled = false;
@@ -1061,6 +1065,13 @@ class SessionRuntime {
1061
1065
  this.#outputTraces.arm(semanticPaneId, trace);
1062
1066
  }
1063
1067
  try {
1068
+ // Synchronous final admission: never create a new owner to satisfy a stale
1069
+ // held-input fence, and never await between this check and mirror dispatch.
1070
+ if (expectedLifetime &&
1071
+ (expectedLifetime.generation !== this.generation ||
1072
+ this.#terminalReplicas.get(semanticPaneId) !== replicaOwner ||
1073
+ replicaOwner.inputAdmissionIncarnation() !== expectedLifetime.incarnation))
1074
+ throw new Error("Input target lifetime changed");
1064
1075
  if (input.kind === "text")
1065
1076
  this.#mirror.sendText(this.session, semanticPaneId, input.data, performanceTraceId, causalProbe !== null);
1066
1077
  else if (input.kind === "bytes")
@@ -1564,9 +1575,9 @@ class SessionRuntimeConsumerImpl {
1564
1575
  this.#assertOpen();
1565
1576
  return this.#runtime.submitIntent(this.clientId, lease, operationId, intent);
1566
1577
  }
1567
- sendInput(lease, semanticPaneId, input, performanceTraceId, causalProbe, onCausalResult) {
1578
+ sendInput(lease, semanticPaneId, input, performanceTraceId, causalProbe, onCausalResult, expectedLifetime) {
1568
1579
  this.#assertOpen();
1569
- this.#runtime.sendInput(this.clientId, lease, semanticPaneId, input, performanceTraceId, causalProbe, onCausalResult);
1580
+ this.#runtime.sendInput(this.clientId, lease, semanticPaneId, input, performanceTraceId, causalProbe, onCausalResult, expectedLifetime);
1570
1581
  }
1571
1582
  failCausalCellProbe(semanticPaneId, traceId, reason) {
1572
1583
  this.#runtime.failCausalCellProbe(semanticPaneId, traceId, reason);
@@ -7,6 +7,9 @@ import { DISABLED_SESSION_RUNTIME_OBSERVABILITY, } from "./runtime-observability
7
7
  import { createXtermTerminalInterpreterBackend } from "./xterm-terminal-interpreter-backend.js";
8
8
  import { CAUSAL_CELL_OSC, CausalCellLedger, cellsEqual, } from "./causal-cell-ledger.js";
9
9
  import { KITTY_PLACEMENT_OSC, KittyApcScanner, KittyImageStore, } from "../graphics/kitty-graphics.js";
10
+ // Bound parser work per turn without changing byte-stream semantics. This is a
11
+ // batch bound, not a producer queue cap: upstream pressure must preserve bytes.
12
+ const MAX_INGEST_BATCH_BYTES = 256 * 1024;
10
13
  /** Daemon-owned, sans-I/O VT interpreter with one FIFO for bytes and geometry. */
11
14
  export class TerminalReplicaInterpreter {
12
15
  #nativeSeedBackingCandidate;
@@ -35,8 +38,7 @@ export class TerminalReplicaInterpreter {
35
38
  #needsSeed = true;
36
39
  #closed = false;
37
40
  #walkCount = 0;
38
- #pendingWrites = [];
39
- #writeFlushScheduled = false;
41
+ #pendingWrites = null;
40
42
  #widgetGate = false;
41
43
  // kitty graphics: images outlive reseeds; the scanner owns split APCs.
42
44
  #kittyScanner = new KittyApcScanner();
@@ -106,16 +108,25 @@ export class TerminalReplicaInterpreter {
106
108
  if (operation.type === "write") {
107
109
  const data = operation.data.slice();
108
110
  const trace = operation.trace ?? null;
109
- const pendingTrace = this.#pendingWrites[0]?.trace ?? null;
110
- if (this.#pendingWrites.length > 0 &&
111
- (pendingTrace?.traceId ?? null) !== (trace?.traceId ?? null))
111
+ if (this.#pendingWrites &&
112
+ (this.#pendingWrites.trace?.traceId ?? null) !== (trace?.traceId ?? null))
112
113
  this.#flushWrites();
114
+ const group = (this.#pendingWrites ??= {
115
+ entries: [],
116
+ bytes: 0,
117
+ scheduled: false,
118
+ flushQueued: false,
119
+ trace,
120
+ });
113
121
  const promise = new Promise((resolve, reject) => {
114
- this.#pendingWrites.push({ data, trace, resolve, reject });
122
+ group.entries.push({ data, offset: 0, resolve, reject });
123
+ group.bytes += data.length;
115
124
  });
116
- if (!this.#writeFlushScheduled) {
117
- this.#writeFlushScheduled = true;
118
- this.#scheduler.microtask(() => this.#flushWrites());
125
+ // Leave this group open while the parser awaits its callback. Scheduling
126
+ // it once on the FIFO lets later arrivals join the next bounded batch.
127
+ if (!group.scheduled && !group.flushQueued) {
128
+ group.flushQueued = true;
129
+ this.#scheduler.microtask(() => this.#scheduleWriteGroup(group));
119
130
  }
120
131
  return promise;
121
132
  }
@@ -361,22 +372,26 @@ export class TerminalReplicaInterpreter {
361
372
  // serialize dozens of turns and fall behind a 30 fps image stream.
362
373
  if (scanned.forward.byteLength === 0)
363
374
  return;
364
- await this.#writeToBackend(this.#backend, scanned.forward);
375
+ // Scanning can prepend an escape retained from the previous chunk. Bound
376
+ // the actual parser calls too, and union damage across every fragment.
377
+ for (let offset = 0; offset < scanned.forward.length; offset += MAX_INGEST_BATCH_BYTES) {
378
+ await this.#writeToBackend(this.#backend, scanned.forward.subarray(offset, offset + MAX_INGEST_BATCH_BYTES));
379
+ // The parser resets its dirty tracker per write. Synchronized output and
380
+ // bounded fragments can both span writes before canonical publication.
381
+ const dirty = this.#backend.dirtyRange();
382
+ const pending = this.#pendingWriteDirty;
383
+ this.#pendingWriteDirty =
384
+ pending === null
385
+ ? dirty
386
+ : pending === undefined || dirty === undefined
387
+ ? undefined
388
+ : {
389
+ start: Math.min(pending.start, dirty.start),
390
+ end: Math.max(pending.end, dirty.end),
391
+ };
392
+ }
365
393
  if (this.#observability.enabled)
366
394
  this.#observability.recordSpan("parse", "terminal-replica-write", parseStarted, this.#observability.nowMicros(), trace);
367
- // The parser resets its dirty tracker per write. Synchronized output may
368
- // span many writes before publication, so retain their union until commit.
369
- const dirty = this.#backend.dirtyRange();
370
- const pending = this.#pendingWriteDirty;
371
- this.#pendingWriteDirty =
372
- pending === null
373
- ? dirty
374
- : pending === undefined || dirty === undefined
375
- ? undefined
376
- : {
377
- start: Math.min(pending.start, dirty.start),
378
- end: Math.max(pending.end, dirty.end),
379
- };
380
395
  // DEC synchronized-output is atomic: no intermediate frame leaks.
381
396
  if (this.#backend.requiresNativeReseed()) {
382
397
  this.#commit(false);
@@ -402,6 +417,7 @@ export class TerminalReplicaInterpreter {
402
417
  return;
403
418
  this.#syncRecovery = this.#scheduler.timer(() => {
404
419
  this.#syncRecovery = null;
420
+ this.#flushWrites();
405
421
  const run = this.#tail.then(async () => {
406
422
  if (this.#closed || !this.#backend.modes().synchronizedOutput)
407
423
  return;
@@ -661,22 +677,65 @@ export class TerminalReplicaInterpreter {
661
677
  }
662
678
  }
663
679
  }
680
+ /** Seal the current group at every trace, control and idle barrier. */
664
681
  #flushWrites() {
665
- this.#writeFlushScheduled = false;
666
- if (this.#pendingWrites.length === 0)
682
+ const group = this.#pendingWrites;
683
+ this.#pendingWrites = null;
684
+ if (group)
685
+ this.#scheduleWriteGroup(group);
686
+ }
687
+ #scheduleWriteGroup(group) {
688
+ if (group.scheduled)
667
689
  return;
668
- const pending = this.#pendingWrites.splice(0);
669
- const length = pending.reduce((total, entry) => total + entry.data.length, 0);
670
- const data = new Uint8Array(length);
671
- let offset = 0;
672
- for (const entry of pending) {
673
- data.set(entry.data, offset);
674
- offset += entry.data.length;
675
- }
676
- const trace = pending.find(({ trace }) => trace !== null)?.trace ?? null;
677
- const run = this.#tail.then(() => this.#write(data, trace));
690
+ group.scheduled = true;
691
+ const run = this.#tail.then(async () => {
692
+ try {
693
+ while (group.entries.length > 0) {
694
+ const length = Math.min(group.bytes, MAX_INGEST_BATCH_BYTES);
695
+ const data = new Uint8Array(length);
696
+ const completed = [];
697
+ let offset = 0;
698
+ for (const entry of group.entries) {
699
+ const take = Math.min(entry.data.length - entry.offset, length - offset);
700
+ data.set(entry.data.subarray(entry.offset, entry.offset + take), offset);
701
+ offset += take;
702
+ entry.offset += take;
703
+ if (entry.offset === entry.data.length)
704
+ completed.push(entry);
705
+ else
706
+ break;
707
+ if (offset === length && length > 0)
708
+ break;
709
+ }
710
+ group.entries.splice(0, completed.length);
711
+ group.bytes -= length;
712
+ try {
713
+ await this.#write(data, group.trace);
714
+ }
715
+ catch (error) {
716
+ for (const entry of completed)
717
+ entry.reject(error);
718
+ throw error;
719
+ }
720
+ for (const entry of completed)
721
+ entry.resolve();
722
+ }
723
+ }
724
+ catch (error) {
725
+ // Reject every remaining fragment in the failed group; later FIFO
726
+ // controls (including recovery reseeds and close) still execute.
727
+ for (const entry of group.entries)
728
+ entry.reject(error);
729
+ group.entries.length = 0;
730
+ group.bytes = 0;
731
+ throw error;
732
+ }
733
+ finally {
734
+ if (this.#pendingWrites === group)
735
+ this.#pendingWrites = null;
736
+ }
737
+ });
678
738
  this.#tail = run.catch(() => undefined);
679
- void run.then(() => pending.forEach((entry) => entry.resolve()), (error) => pending.forEach((entry) => entry.reject(error)));
680
739
  }
681
740
  #observeMarkerBytes(data) {
682
741
  if (this.#widgetGate)
@@ -155,6 +155,12 @@ export class SessionRuntimeTerminalReplicaOwner {
155
155
  inputDispatchIncarnation() {
156
156
  return this.#disposed ? null : this.#publishedIncarnation;
157
157
  }
158
+ /** Scalar admission fence; unavailable while canonical replacement is pending. */
159
+ inputAdmissionIncarnation() {
160
+ return this.#disposed || this.#reseed || this.#waitingForGeometryCapture
161
+ ? null
162
+ : this.#publishedIncarnation;
163
+ }
158
164
  qualificationSnapshot() {
159
165
  const seed = this.#interpreter.currentSeed();
160
166
  return Object.freeze({
@@ -275,7 +275,7 @@ export class SessionRuntimeTransportBinding {
275
275
  authorize();
276
276
  return authorize;
277
277
  }
278
- sendInput(semanticPaneId, input, performanceTraceId, causalProbe, onCausalResult) {
278
+ sendInput(semanticPaneId, input, performanceTraceId, causalProbe, onCausalResult, expectedLifetime) {
279
279
  this.assertController(semanticPaneId);
280
280
  const lease = this.#shared.lease;
281
281
  if (!lease) {
@@ -289,7 +289,7 @@ export class SessionRuntimeTransportBinding {
289
289
  this.#causalCellProbes.delete(causalProbe.traceId);
290
290
  onCausalResult?.(result);
291
291
  }
292
- : onCausalResult);
292
+ : onCausalResult, expectedLifetime);
293
293
  }
294
294
  catch (error) {
295
295
  if (causalProbe)
@@ -67,8 +67,23 @@ export function createApplicationAgentNavigator(options) {
67
67
  return async (sessionName, paneId, source = "mouse") => {
68
68
  const token = ++navigationToken;
69
69
  const currentOwner = options.sessionOwner();
70
+ const snapshot = currentOwner.snapshot();
71
+ // A renderer rebind keeps its semantic control connection. Reserve this
72
+ // exact target synchronously; the controller fences and bounds first send.
73
+ const retainedControl = (() => {
74
+ try {
75
+ return (snapshot?.status === "rebinding" &&
76
+ snapshot.connection?.workspaceName === sessionName &&
77
+ snapshot.daemonGeneration &&
78
+ snapshot.client &&
79
+ Number.isSafeInteger(snapshot.client.getSnapshot().generation));
80
+ }
81
+ catch {
82
+ return false;
83
+ }
84
+ })();
70
85
  if (currentOwner.sessionName() === sessionName &&
71
- applicationGenerationNavigationKey(currentOwner.snapshot()) !== null) {
86
+ (applicationGenerationNavigationKey(snapshot) !== null || retainedControl)) {
72
87
  // Sidebar agents belong to the already-live workspace. Select their pane
73
88
  // before returning control to OpenTUI so the very next key cannot leak to
74
89
  // the pane that was focused before the click.
@@ -1,3 +1,4 @@
1
+ import { retainsAgentControl } from "./application-retained-agent.js";
1
2
  import { createApplicationHomeAgentNavigator, } from "./application-home-agent-navigation.js";
2
3
  import { applicationGenerationNavigationKey } from "./application-generation-starter.js";
3
4
  import { waitForHomeAgentSemantic } from "./application-home-agents-owner.js";
@@ -16,6 +17,18 @@ export function createApplicationMachineAgentNavigator(options) {
16
17
  opening = true;
17
18
  const selected = () => options.selectedMachineId() === machineId;
18
19
  const generation = () => selected() && options.generationMachineId() === machineId ? options.generation() : null;
20
+ if (selected() &&
21
+ options.isCurrentTarget(machineId, target) &&
22
+ options.sessionName() === target.sessionName &&
23
+ retainsAgentControl(generation(), target)) {
24
+ // Reserve before the next key; the terminal controller waits for a fresh
25
+ // coherent runtime and remains the only owner of physical input.
26
+ options.selectPane(target.paneId, source);
27
+ options.showTerminals(source);
28
+ options.setNote(null);
29
+ opening = false;
30
+ return Promise.resolve({ opened: true });
31
+ }
19
32
  current = createApplicationHomeAgentNavigator({
20
33
  isCurrentTarget: (row) => selected() && options.isCurrentTarget(machineId, row),
21
34
  currentGeneration() {
@@ -1,3 +1,4 @@
1
+ import { retainsAgentControl } from "./application-retained-agent.js";
1
2
  import { fleetHostColor } from "./fleet-presentation.js";
2
3
  import { createFleetTabs } from "./application-fleet-tabs.js";
3
4
  import { saveMachineProfiles } from "../../../lib/local-fleet-request.js";
@@ -64,7 +65,7 @@ export function createApplicationMachineNavigation(options) {
64
65
  const [tabRevision, setTabRevision] = createSignal(0);
65
66
  const history = [...saved().recent].reverse();
66
67
  let historyIndex = history.length - 1;
67
- const select = (id, expectedLiveSessionId, server) => {
68
+ const select = (id, expectedLiveSessionId, server, retainedAgent) => {
68
69
  if (!manager.getMachine(id))
69
70
  return false;
70
71
  if (manager.snapshot().selectedMachineId !== id) {
@@ -78,8 +79,15 @@ export function createApplicationMachineNavigation(options) {
78
79
  manager.select(id);
79
80
  }
80
81
  else if (expectedLiveSessionId) {
81
- const attached = options.attachedTarget?.() ??
82
- attachedMachineNavigationTarget(options.attachedGeneration?.() ?? null);
82
+ const active = options.attachedGeneration?.() ?? null;
83
+ const retained = retainedAgent && retainsAgentControl(active, retainedAgent)
84
+ ? {
85
+ liveSessionId: active.connection.liveSessionId,
86
+ daemonGeneration: active.daemonGeneration,
87
+ ...(active.connection.server ? { server: active.connection.server } : {}),
88
+ }
89
+ : null;
90
+ const attached = options.attachedTarget?.() ?? attachedMachineNavigationTarget(active) ?? retained;
83
91
  const sameAuthority = attached?.liveSessionId === expectedLiveSessionId &&
84
92
  (server
85
93
  ? attached.server
@@ -302,7 +310,7 @@ export function createApplicationMachineNavigation(options) {
302
310
  return;
303
311
  }
304
312
  options.cancelOpen();
305
- if (!select(id, selectedSession?.liveSessionId, selectedSession?.server))
313
+ if (!select(id, selectedSession?.liveSessionId, selectedSession?.server, row))
306
314
  return;
307
315
  if (selectedSession)
308
316
  setActiveSessionKey(selectedSession.id);
@@ -0,0 +1,18 @@
1
+ /** Selection intent may wait on this exact retained control; input cannot use it yet. */
2
+ export function retainsAgentControl(active, target) {
3
+ if (active?.status !== "rebinding" ||
4
+ !active.client ||
5
+ !target.paneId ||
6
+ active.daemonGeneration !== target.daemonInstanceId ||
7
+ active.connection?.workspaceName !== target.sessionName ||
8
+ active.connection.liveSessionId !== target.liveSessionId)
9
+ return false;
10
+ try {
11
+ const state = active.client.getSnapshot();
12
+ return (Number.isSafeInteger(state.generation) &&
13
+ state.semantic?.sidebar.agents.some((agent) => agent.id === target.agentId && agent.paneId === target.paneId) === true);
14
+ }
15
+ catch {
16
+ return false;
17
+ }
18
+ }