tmux-ide 2.9.3 → 2.9.5

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 (85) hide show
  1. package/LICENSE +1 -1
  2. package/README.md +120 -139
  3. package/bin/cli.js +4847 -3585
  4. package/package.json +16 -9
  5. package/packages/contracts/src/session-runtime.ts +2 -0
  6. package/packages/contracts/src/terminal-delivery.ts +4 -0
  7. package/packages/core/src/terminal-compact-capability.ts +31 -1
  8. package/packages/core/src/terminal-delivery.ts +234 -32
  9. package/packages/core/src/terminal-replica-hash-cache.ts +162 -21
  10. package/packages/core/src/terminal-replica-owned-row.ts +165 -10
  11. package/packages/core/src/terminal-replica.ts +43 -21
  12. package/packages/daemon/dist/lib/daemon-embed.js +19 -34
  13. package/packages/daemon/dist/lib/runtime-trace-writer.js +133 -0
  14. package/packages/daemon/dist/lib/tmux-client-execution.js +37 -2
  15. package/packages/daemon/dist/lib/tmux-server-owner.js +20 -4
  16. package/packages/daemon/dist/native/TmuxIdeNotifier.app/Contents/Info.plist +2 -2
  17. package/packages/daemon/dist/native/TmuxIdeNotifier.app/Contents/MacOS/tmux-ide-notifier +0 -0
  18. package/packages/daemon/dist/native/TmuxIdeNotifier.app/Contents/Resources/Assets.car +0 -0
  19. package/packages/daemon/dist/native/TmuxIdeNotifier.app/Contents/_CodeSignature/CodeResources +2 -2
  20. package/packages/daemon/dist/native/tmux/darwin-arm64/manifest.json +5 -1
  21. package/packages/daemon/dist/native/tmux/darwin-arm64/tmux +0 -0
  22. package/packages/daemon/dist/native/tmux/darwin-x64/manifest.json +5 -1
  23. package/packages/daemon/dist/native/tmux/darwin-x64/tmux +0 -0
  24. package/packages/daemon/dist/native/tmux/linux-arm64/manifest.json +5 -1
  25. package/packages/daemon/dist/native/tmux/linux-arm64/tmux +0 -0
  26. package/packages/daemon/dist/native/tmux/linux-x64/licenses/libevent_core-2.1.so.7.txt +0 -0
  27. package/packages/daemon/dist/native/tmux/linux-x64/manifest.json +5 -1
  28. package/packages/daemon/dist/native/tmux/linux-x64/tmux +0 -0
  29. package/packages/daemon/dist/terminal/attachments/native-runtime.js +6 -6
  30. package/packages/daemon/dist/terminal/mirror/control-channel.js +379 -75
  31. package/packages/daemon/dist/terminal/mirror/mirror-service.js +43 -3
  32. package/packages/daemon/dist/terminal/mirror/native-grid-projection.js +20 -11
  33. package/packages/daemon/dist/terminal/mirror/session-channel.js +639 -811
  34. package/packages/daemon/dist/terminal/mirror/stock-pane-snapshot.js +177 -0
  35. package/packages/daemon/dist/terminal/session-runtime/causal-cell-ledger.js +1 -1
  36. package/packages/daemon/dist/terminal/session-runtime/registry.js +145 -15
  37. package/packages/daemon/dist/terminal/session-runtime/stock-capture-fidelity.js +12 -0
  38. package/packages/daemon/dist/terminal/session-runtime/terminal-delivery-hub.js +128 -10
  39. package/packages/daemon/dist/terminal/session-runtime/terminal-replica-interpreter.js +21 -4
  40. package/packages/daemon/dist/terminal/session-runtime/terminal-replica-owner.js +19 -2
  41. package/packages/daemon/dist/terminal/session-runtime/xterm-terminal-interpreter-backend.js +17 -19
  42. package/packages/daemon/dist/tui/integrations/claude-agent-teams.js +264 -0
  43. package/packages/daemon/dist/tui/mirror/open-tui-workspace-runtime-port.js +64 -4
  44. package/packages/daemon/dist/tui/mirror/pane-surface.jsx +10 -1
  45. package/packages/daemon/dist/tui/mirror/reference-performance-trace.js +117 -71
  46. package/packages/daemon/dist/tui/mirror/runtime/application-terminal-input-ingress.js +27 -3
  47. package/packages/daemon/dist/tui/mirror/runtime/pane-scoped-terminal-surface.jsx +17 -4
  48. package/packages/daemon/dist/tui/mirror/runtime/terminal-fast-lane-renderer-adapter.js +19 -0
  49. package/packages/daemon/dist/tui/mirror/runtime/terminal-input-adapter.js +10 -2
  50. package/packages/daemon/dist/tui/mirror/testing/styled-frame-oracle.js +156 -0
  51. package/packages/daemon/src/lib/daemon-embed.ts +22 -34
  52. package/packages/daemon/src/lib/runtime-trace-writer.ts +132 -0
  53. package/packages/daemon/src/lib/tmux-client-execution.ts +47 -2
  54. package/packages/daemon/src/lib/tmux-server-owner.ts +19 -3
  55. package/packages/daemon/src/terminal/attachments/native-runtime.ts +6 -0
  56. package/packages/daemon/src/terminal/mirror/control-channel.ts +428 -84
  57. package/packages/daemon/src/terminal/mirror/events.ts +7 -7
  58. package/packages/daemon/src/terminal/mirror/mirror-service.ts +49 -3
  59. package/packages/daemon/src/terminal/mirror/native-grid-projection.ts +21 -11
  60. package/packages/daemon/src/terminal/mirror/pane-feed.ts +9 -4
  61. package/packages/daemon/src/terminal/mirror/session-channel.ts +705 -859
  62. package/packages/daemon/src/terminal/mirror/stock-pane-snapshot.ts +228 -0
  63. package/packages/daemon/src/terminal/session-runtime/causal-cell-ledger.ts +1 -1
  64. package/packages/daemon/src/terminal/session-runtime/registry.ts +203 -32
  65. package/packages/daemon/src/terminal/session-runtime/runtime-observability.ts +7 -0
  66. package/packages/daemon/src/terminal/session-runtime/stock-capture-fidelity.ts +13 -0
  67. package/packages/daemon/src/terminal/session-runtime/terminal-delivery-hub.ts +142 -8
  68. package/packages/daemon/src/terminal/session-runtime/terminal-replica-interpreter.ts +26 -3
  69. package/packages/daemon/src/terminal/session-runtime/terminal-replica-owner.ts +25 -1
  70. package/packages/daemon/src/terminal/session-runtime/xterm-terminal-interpreter-backend.ts +26 -20
  71. package/packages/daemon/src/tui/integrations/claude-agent-teams.ts +334 -0
  72. package/packages/daemon/src/tui/mirror/open-tui-workspace-runtime-port.ts +63 -4
  73. package/packages/daemon/src/tui/mirror/pane-surface.tsx +9 -1
  74. package/packages/daemon/src/tui/mirror/reference-performance-trace.ts +141 -90
  75. package/packages/daemon/src/tui/mirror/runtime/application-terminal-input-ingress.ts +27 -4
  76. package/packages/daemon/src/tui/mirror/runtime/pane-scoped-terminal-surface.tsx +61 -31
  77. package/packages/daemon/src/tui/mirror/runtime/terminal-fast-lane-renderer-adapter.ts +31 -3
  78. package/packages/daemon/src/tui/mirror/runtime/terminal-input-adapter.ts +9 -6
  79. package/packages/daemon/src/tui/mirror/testing/styled-frame-oracle.ts +209 -0
  80. package/packages/daemon-client/src/terminal-fast-lane.ts +53 -4
  81. package/packages/daemon-client/src/workspace-client-types.ts +1 -0
  82. package/packages/daemon-client/src/workspace-client.ts +78 -5
  83. package/packages/tmux-bridge/src/index.ts +1 -0
  84. package/packages/tmux-bridge/src/runner.ts +25 -1
  85. package/skill/SKILL.md +30 -30
@@ -21,6 +21,7 @@ import { withBundledTmuxResources } from "../../lib/bundled-tmux.js";
21
21
  * chance to detach us — killing the reader first can wedge the server.
22
22
  */
23
23
  import { spawn } from "node:child_process";
24
+ import { randomBytes } from "node:crypto";
24
25
  import { shellEscape } from "../../lib/shell.js";
25
26
  import { NativeJournalIdentitySchemaZ, } from "@tmux-ide/contracts";
26
27
  import { nativeOperationWrapperArgs } from "../../lib/native-operation-command.js";
@@ -182,6 +183,12 @@ export class ControlChannelCore {
182
183
  armAtomicPaneSnapshotCollector(spec) {
183
184
  if (this.failed || this.atomicCollector)
184
185
  return false;
186
+ if (spec.kind !== undefined && spec.kind !== "snapshot" && spec.kind !== "pause")
187
+ return false;
188
+ if (spec.kind === "pause" && (spec.observerCommandCount !== 0 || spec.dualCapture))
189
+ return false;
190
+ if (spec.dualCapture !== undefined && typeof spec.dualCapture !== "boolean")
191
+ return false;
185
192
  if (!/^[0-9a-f]{32,128}$/.test(spec.nonce) || !/^%\d+$/.test(spec.runtimePaneId))
186
193
  return false;
187
194
  if (!Number.isSafeInteger(spec.maxCaptureBytes) ||
@@ -199,15 +206,19 @@ export class ControlChannelCore {
199
206
  return false;
200
207
  this.atomicCollector = {
201
208
  spec,
209
+ retired: false,
210
+ settled: false,
202
211
  started: false,
203
212
  blockOrdinal: -1,
204
213
  blockContentCount: 0,
205
214
  captureLines: [],
215
+ ansiCaptureLines: [],
206
216
  captureBytes: 0,
207
217
  totalLines: 0,
208
218
  totalBytes: 0,
209
219
  cursorLine: null,
210
220
  continueObserved: false,
221
+ pauseObserved: false,
211
222
  statusObserved: false,
212
223
  observerEmissionObserved: false,
213
224
  lastCompletedOrdinal: -1,
@@ -217,25 +228,112 @@ export class ControlChannelCore {
217
228
  }
218
229
  retireAtomicPaneSnapshotCollector(nonce, reason = "retired") {
219
230
  const collector = this.atomicCollector;
220
- if (!collector || collector.spec.nonce !== nonce)
231
+ if (!collector || collector.spec.nonce !== nonce || collector.settled)
221
232
  return false;
222
- this.atomicCollector = null;
223
- collector.spec.onSettled(Object.freeze({
233
+ collector.retired = true;
234
+ collector.settled = true;
235
+ const result = Object.freeze({
224
236
  ok: false,
225
237
  captureLines: Object.freeze([]),
238
+ ...(collector.spec.dualCapture ? { ansiCaptureLines: Object.freeze([]) } : {}),
226
239
  cursorLine: null,
227
240
  continueObserved: collector.continueObserved,
228
241
  statusObserved: collector.statusObserved,
229
242
  observerEmissionObserved: collector.observerEmissionObserved,
230
243
  started: collector.started,
231
244
  lastCompletedOrdinal: collector.lastCompletedOrdinal,
232
- captureLineCount: collector.captureLines.length,
245
+ captureLineCount: collector.captureLines.length + collector.ansiCaptureLines.length,
233
246
  captureByteCount: collector.captureBytes,
234
247
  failureReason: collector.failureReason ?? reason,
235
- }));
248
+ ...(collector.spec.kind === "pause" ? { pauseObserved: collector.pauseObserved } : {}),
249
+ });
250
+ // Release payload memory before callbacks can throw or reenter retirement.
251
+ collector.captureLines.length = 0;
252
+ collector.ansiCaptureLines.length = 0;
253
+ collector.cursorLine = null;
254
+ collector.totalBytes = 0;
255
+ collector.totalLines = 0;
256
+ collector.spec.onSettled(result);
257
+ return true;
258
+ }
259
+ /** Caller has validated the separate ordinary command's exact fence reply. */
260
+ releaseRetiredCollector(nonce) {
261
+ const collector = this.atomicCollector;
262
+ if (!collector?.retired || collector.spec.nonce !== nonce)
263
+ return false;
264
+ this.atomicCollector = null;
265
+ this.notifyAtomicDrained(collector, "fence");
236
266
  return true;
237
267
  }
268
+ notifyAtomicDrained(collector, reason) {
269
+ try {
270
+ collector.spec.onDrained?.(reason);
271
+ }
272
+ catch {
273
+ this.failRetiredDrain("snapshot drain callback failed");
274
+ }
275
+ }
276
+ failRetiredDrain(reason) {
277
+ try {
278
+ this.fail(reason);
279
+ }
280
+ finally {
281
+ this.handlers.onExit(reason);
282
+ }
283
+ }
284
+ consumeRetiredSnapshotLine(line) {
285
+ const collector = this.atomicCollector;
286
+ if (!collector?.retired)
287
+ return false;
288
+ const event = parseControlLine(line, this.inReply);
289
+ if (this.inReply && this.currentReplyFlags === 0) {
290
+ collector.totalBytes += Buffer.byteLength(line, "latin1") + 1;
291
+ collector.totalLines += 1;
292
+ if (collector.totalBytes > collector.spec.maxCaptureBytes + 64 * 1024 ||
293
+ collector.totalLines > collector.spec.maxCaptureLines + 64) {
294
+ this.failRetiredDrain("retired snapshot drain exceeded bounds");
295
+ return true;
296
+ }
297
+ // Raw rows may look like %begin, a sentinel, or the fence payload. Only
298
+ // the matching raw terminator changes framing; none can redeem a FIFO slot.
299
+ if (event.kind === "end" || event.kind === "error") {
300
+ if (event.flags !== 0 || event.num !== this.currentReplyNum) {
301
+ this.failRetiredDrain("invalid retired snapshot framing");
302
+ return true;
303
+ }
304
+ this.inReply = false;
305
+ this.currentReplyNum = null;
306
+ this.currentReplyFlags = null;
307
+ this.currentReplyConsumesPending = false;
308
+ }
309
+ return true;
310
+ }
311
+ if (event.kind === "begin" &&
312
+ (!Number.isSafeInteger(event.num) || event.num < 0 || ![0, 1].includes(event.flags))) {
313
+ this.failRetiredDrain("invalid retired snapshot opening frame");
314
+ return true;
315
+ }
316
+ if (event.kind === "begin" && event.flags === 0) {
317
+ this.inReply = true;
318
+ this.currentReplyNum = event.num;
319
+ this.currentReplyFlags = 0;
320
+ this.currentReplyConsumesPending = false;
321
+ return true;
322
+ }
323
+ if (event.kind === "end" || event.kind === "error") {
324
+ if (!this.inReply ||
325
+ event.num !== this.currentReplyNum ||
326
+ event.flags !== this.currentReplyFlags) {
327
+ this.failRetiredDrain("invalid retired snapshot fence framing");
328
+ return true;
329
+ }
330
+ }
331
+ // Ordinary flags=1 responses still own their existing pending FIFO slots.
332
+ return false;
333
+ }
238
334
  feed(chunk, receivedAtMicros) {
335
+ if (this.failed)
336
+ return;
239
337
  if (this.droppingOversizedReplyLine) {
240
338
  const newline = chunk.indexOf("\n");
241
339
  if (newline < 0)
@@ -255,6 +353,16 @@ export class ControlChannelCore {
255
353
  const lineReceivedAtMicros = this.bufferReceivedAtMicros;
256
354
  this.bufferReceivedAtMicros = this.buffer.length > 0 ? (receivedAtMicros ?? null) : null;
257
355
  this.handleLine(line, lineReceivedAtMicros);
356
+ if (this.failed) {
357
+ this.buffer = "";
358
+ return;
359
+ }
360
+ }
361
+ if (this.atomicCollector?.retired &&
362
+ this.buffer.length > this.atomicCollector.spec.maxCaptureBytes + 64 * 1024) {
363
+ this.buffer = "";
364
+ this.failRetiredDrain("retired snapshot partial line exceeded bounds");
365
+ return;
258
366
  }
259
367
  const head = this.currentBudget();
260
368
  // Protocol terminators may themselves exceed a tiny payload budget and
@@ -275,25 +383,44 @@ export class ControlChannelCore {
275
383
  if (this.failed)
276
384
  return;
277
385
  this.failed = true;
278
- if (this.atomicCollector)
279
- this.retireAtomicPaneSnapshotCollector(this.atomicCollector.spec.nonce, "channel-exit");
386
+ if (this.atomicCollector) {
387
+ const collector = this.atomicCollector;
388
+ try {
389
+ this.retireAtomicPaneSnapshotCollector(collector.spec.nonce, "channel-exit");
390
+ }
391
+ catch {
392
+ // A failed owner cannot prevent sibling requests from settling on exit.
393
+ }
394
+ this.atomicCollector = null;
395
+ try {
396
+ this.notifyAtomicDrained(collector, "channel-exit");
397
+ }
398
+ catch {
399
+ // Exit notification callbacks may also fail; the pending FIFO still drains.
400
+ }
401
+ }
280
402
  for (const sink of this.pending.splice(0)) {
281
- if (sink.kind === "promise")
282
- sink.reject(new Error(reason));
283
- else if (sink.kind === "inline" || sink.kind === "bounded")
284
- sink.onReply({ ok: false, lines: [reason] });
285
- else if (sink.kind === "native-wrapper" && !sink.state.settled) {
286
- sink.state.settled = true;
287
- notifyNativeWrapper(sink.state, {
288
- ok: false,
289
- lines: [],
290
- metadataStatus: sink.state.metadataStatus,
291
- acknowledgement: sink.state.acknowledgement,
292
- });
403
+ try {
404
+ if (sink.kind === "promise")
405
+ sink.reject(new Error(reason));
406
+ else if (sink.kind === "inline" || sink.kind === "bounded")
407
+ sink.onReply({ ok: false, lines: [reason] });
408
+ else if (sink.kind === "native-wrapper" && !sink.state.settled) {
409
+ sink.state.settled = true;
410
+ notifyNativeWrapper(sink.state, {
411
+ ok: false,
412
+ lines: [],
413
+ metadataStatus: sink.state.metadataStatus,
414
+ acknowledgement: sink.state.acknowledgement,
415
+ });
416
+ }
417
+ else if (sink.kind === "command-list" && !sink.state.settled) {
418
+ sink.state.settled = true;
419
+ sink.state.onReply({ ok: false, lines: [reason] });
420
+ }
293
421
  }
294
- else if (sink.kind === "command-list" && !sink.state.settled) {
295
- sink.state.settled = true;
296
- sink.state.onReply({ ok: false, lines: [reason] });
422
+ catch {
423
+ // Keep failure fan-out independent of any one request callback.
297
424
  }
298
425
  }
299
426
  }
@@ -457,6 +584,7 @@ export class ControlChannelCore {
457
584
  this.handlers.onOutput(event.pane, event.data, event.ageMs, timing);
458
585
  break;
459
586
  case "exit":
587
+ this.fail(event.reason ?? "control channel exited");
460
588
  this.handlers.onExit(event.reason);
461
589
  break;
462
590
  case "notify":
@@ -468,6 +596,8 @@ export class ControlChannelCore {
468
596
  const collector = this.atomicCollector;
469
597
  if (!collector)
470
598
  return false;
599
+ if (collector.retired)
600
+ return this.consumeRetiredSnapshotLine(line);
471
601
  const prefix = "%tmux-ide-atomic-v1 ";
472
602
  const ownPrefix = `${prefix}${collector.spec.nonce} `;
473
603
  if (line.startsWith(prefix) && !line.startsWith(ownPrefix)) {
@@ -481,7 +611,9 @@ export class ControlChannelCore {
481
611
  if (token !== "start" ||
482
612
  !this.inReply ||
483
613
  this.currentReplyFlags !== 0 ||
484
- this.currentReplyNum === null) {
614
+ this.currentReplyNum === null ||
615
+ (collector.spec.kind === "pause" &&
616
+ (!Number.isSafeInteger(this.currentReplyNum) || this.currentReplyNum < 0))) {
485
617
  collector.failureReason ??= "sentinel-order";
486
618
  return true;
487
619
  }
@@ -497,6 +629,9 @@ export class ControlChannelCore {
497
629
  collector.failureReason ??= "capture-line-cap";
498
630
  if (collector.totalBytes > collector.spec.maxCaptureBytes + 64 * 1024)
499
631
  collector.failureReason ??= "capture-byte-cap";
632
+ if (collector.spec.kind === "pause")
633
+ return this.consumeOwnedPauseLine(line, token);
634
+ const shift = collector.spec.dualCapture ? 2 : 0;
500
635
  const guard = parseControlLine(line, this.inReply);
501
636
  if (guard.kind === "begin") {
502
637
  if (guard.flags !== 0 || this.inReply)
@@ -520,11 +655,20 @@ export class ControlChannelCore {
520
655
  // The ownership compare-and-unset is an if-shell command plus exactly
521
656
  // one selected branch command. Both commands retain the control client
522
657
  // and therefore each has its own flags=0 guard block.
523
- const markerBranchOrdinal = 7 + collector.spec.observerCommandCount;
524
- const statusOrdinal = 8 + collector.spec.observerCommandCount;
525
- const completeOrdinal = 10 + collector.spec.observerCommandCount;
526
- const contentRequired = new Set([0, 2, 3, 4, statusOrdinal, completeOrdinal]);
658
+ const markerBranchOrdinal = 7 + shift + collector.spec.observerCommandCount;
659
+ const statusOrdinal = 8 + shift + collector.spec.observerCommandCount;
660
+ const completeOrdinal = 10 + shift + collector.spec.observerCommandCount;
661
+ const contentRequired = new Set([
662
+ 0,
663
+ 2,
664
+ 3 + shift,
665
+ 4 + shift,
666
+ ...(shift ? [4] : []),
667
+ statusOrdinal,
668
+ completeOrdinal,
669
+ ]);
527
670
  const contentSilent = collector.blockOrdinal !== 1 &&
671
+ !(shift && collector.blockOrdinal === 3) &&
528
672
  collector.blockOrdinal !== markerBranchOrdinal &&
529
673
  !contentRequired.has(collector.blockOrdinal);
530
674
  const markerBranchValid = collector.blockOrdinal !== markerBranchOrdinal || collector.blockContentCount <= 1;
@@ -535,7 +679,7 @@ export class ControlChannelCore {
535
679
  if (guard.kind === "end" &&
536
680
  !guardInvalid &&
537
681
  collector.spec.observerCommandCount > 0 &&
538
- collector.blockOrdinal === 5 + collector.spec.observerCommandCount)
682
+ collector.blockOrdinal === 5 + shift + collector.spec.observerCommandCount)
539
683
  collector.observerEmissionObserved = true;
540
684
  if (!guardInvalid && guard.kind === "end") {
541
685
  collector.lastCompletedOrdinal = collector.blockOrdinal;
@@ -545,27 +689,15 @@ export class ControlChannelCore {
545
689
  this.currentReplyNum = null;
546
690
  this.currentReplyFlags = null;
547
691
  if (collector.blockOrdinal === completeOrdinal) {
548
- this.atomicCollector = null;
549
692
  const ok = collector.failureReason === null &&
550
693
  collector.cursorLine !== null &&
551
694
  collector.statusObserved;
552
- collector.spec.onSettled(Object.freeze({
553
- ok,
554
- captureLines: Object.freeze(ok ? [...collector.captureLines] : []),
555
- cursorLine: ok ? collector.cursorLine : null,
556
- continueObserved: collector.continueObserved,
557
- statusObserved: collector.statusObserved,
558
- observerEmissionObserved: collector.observerEmissionObserved,
559
- started: collector.started,
560
- lastCompletedOrdinal: collector.lastCompletedOrdinal,
561
- captureLineCount: collector.captureLines.length,
562
- captureByteCount: collector.captureBytes,
563
- failureReason: ok ? null : (collector.failureReason ?? "sentinel-order"),
564
- }));
695
+ this.completeAtomicCollector(collector, ok);
565
696
  }
566
697
  return true;
567
698
  }
568
- if (line === `%continue ${collector.spec.runtimePaneId}` && collector.blockOrdinal === 5) {
699
+ if (line === `%continue ${collector.spec.runtimePaneId}` &&
700
+ collector.blockOrdinal === 5 + shift) {
569
701
  if (collector.continueObserved)
570
702
  collector.failureReason ??= "duplicate-sentinel";
571
703
  collector.continueObserved = true;
@@ -579,22 +711,23 @@ export class ControlChannelCore {
579
711
  collector.failureReason ??= "unexpected-post-line";
580
712
  return true;
581
713
  }
582
- const markerBranchOrdinal = 7 + collector.spec.observerCommandCount;
583
- const statusOrdinal = 8 + collector.spec.observerCommandCount;
584
- const completeOrdinal = 10 + collector.spec.observerCommandCount;
714
+ const markerBranchOrdinal = 7 + shift + collector.spec.observerCommandCount;
715
+ const statusOrdinal = 8 + shift + collector.spec.observerCommandCount;
716
+ const completeOrdinal = 10 + shift + collector.spec.observerCommandCount;
585
717
  collector.blockContentCount += 1;
586
- if (collector.blockOrdinal === 1) {
718
+ if (collector.blockOrdinal === 1 || (shift && collector.blockOrdinal === 3)) {
587
719
  collector.captureBytes += Buffer.byteLength(line, "latin1") + 1;
588
720
  if (collector.captureBytes > collector.spec.maxCaptureBytes)
589
721
  collector.failureReason ??= "capture-byte-cap";
590
- if (collector.captureLines.length >= collector.spec.maxCaptureLines)
722
+ if (collector.captureLines.length + collector.ansiCaptureLines.length >=
723
+ collector.spec.maxCaptureLines)
591
724
  collector.failureReason ??= "capture-line-cap";
592
725
  else
593
- collector.captureLines.push(line);
726
+ (collector.blockOrdinal === 1 ? collector.captureLines : collector.ansiCaptureLines).push(line);
594
727
  this.reportAtomicPaneSnapshotProgress(collector);
595
728
  return true;
596
729
  }
597
- if (collector.blockOrdinal === 3) {
730
+ if (collector.blockOrdinal === 3 + shift) {
598
731
  if (collector.cursorLine !== null)
599
732
  collector.failureReason ??= "cursor-cardinality";
600
733
  else if (Buffer.byteLength(line, "latin1") > collector.spec.maxCursorBytes)
@@ -610,13 +743,15 @@ export class ControlChannelCore {
610
743
  }
611
744
  const expectedToken = collector.blockOrdinal === 2
612
745
  ? "capture-end"
613
- : collector.blockOrdinal === 4
614
- ? "cursor-end"
615
- : collector.blockOrdinal === statusOrdinal
616
- ? "status-ok"
617
- : collector.blockOrdinal === completeOrdinal
618
- ? "complete"
619
- : null;
746
+ : shift && collector.blockOrdinal === 4
747
+ ? "ansi-capture-end"
748
+ : collector.blockOrdinal === 4 + shift
749
+ ? "cursor-end"
750
+ : collector.blockOrdinal === statusOrdinal
751
+ ? "status-ok"
752
+ : collector.blockOrdinal === completeOrdinal
753
+ ? "complete"
754
+ : null;
620
755
  if (expectedToken === null || token !== expectedToken)
621
756
  collector.failureReason ??= token === "start" ? "duplicate-sentinel" : "sentinel-order";
622
757
  if (collector.blockOrdinal === statusOrdinal && token === "status-ok") {
@@ -626,13 +761,106 @@ export class ControlChannelCore {
626
761
  }
627
762
  return true;
628
763
  }
764
+ completeAtomicCollector(collector, ok) {
765
+ if (!ok) {
766
+ this.retireAtomicPaneSnapshotCollector(collector.spec.nonce, collector.failureReason ?? "sentinel-order");
767
+ return;
768
+ }
769
+ const result = Object.freeze({
770
+ ok: true,
771
+ captureLines: Object.freeze([...collector.captureLines]),
772
+ ...(collector.spec.dualCapture
773
+ ? { ansiCaptureLines: Object.freeze([...collector.ansiCaptureLines]) }
774
+ : {}),
775
+ cursorLine: collector.cursorLine,
776
+ continueObserved: collector.continueObserved,
777
+ statusObserved: collector.statusObserved,
778
+ observerEmissionObserved: collector.observerEmissionObserved,
779
+ started: collector.started,
780
+ lastCompletedOrdinal: collector.lastCompletedOrdinal,
781
+ captureLineCount: collector.captureLines.length + collector.ansiCaptureLines.length,
782
+ captureByteCount: collector.captureBytes,
783
+ failureReason: null,
784
+ ...(collector.spec.kind === "pause" ? { pauseObserved: collector.pauseObserved } : {}),
785
+ });
786
+ // Settlement cannot admit another collector before terminal framing drains.
787
+ collector.settled = true;
788
+ try {
789
+ collector.spec.onSettled(result);
790
+ }
791
+ catch {
792
+ this.failRetiredDrain("snapshot settlement callback failed");
793
+ return;
794
+ }
795
+ if (this.atomicCollector === collector) {
796
+ this.atomicCollector = null;
797
+ this.notifyAtomicDrained(collector, "complete");
798
+ }
799
+ }
800
+ consumeOwnedPauseLine(line, token) {
801
+ const collector = this.atomicCollector;
802
+ const guard = parseControlLine(line, this.inReply);
803
+ if (guard.kind === "begin") {
804
+ if (guard.flags !== 0 || this.inReply || !Number.isSafeInteger(guard.num) || guard.num < 0)
805
+ collector.failureReason ??= "sentinel-order";
806
+ this.inReply = true;
807
+ this.currentReplyNum = guard.num;
808
+ this.currentReplyFlags = guard.flags;
809
+ collector.blockOrdinal += 1;
810
+ collector.blockContentCount = 0;
811
+ return true;
812
+ }
813
+ if (guard.kind === "end" || guard.kind === "error") {
814
+ const valid = this.inReply &&
815
+ guard.kind === "end" &&
816
+ guard.flags === 0 &&
817
+ this.currentReplyFlags === 0 &&
818
+ guard.num === this.currentReplyNum;
819
+ if (!valid ||
820
+ (collector.blockOrdinal === 1
821
+ ? collector.blockContentCount > 1
822
+ : collector.blockContentCount !== 1))
823
+ collector.failureReason ??= "sentinel-order";
824
+ if (valid)
825
+ collector.lastCompletedOrdinal = collector.blockOrdinal;
826
+ this.inReply = false;
827
+ this.currentReplyNum = null;
828
+ this.currentReplyFlags = null;
829
+ this.currentReplyConsumesPending = false;
830
+ if (collector.blockOrdinal === 2)
831
+ this.completeAtomicCollector(collector, collector.failureReason === null && collector.statusObserved);
832
+ return true;
833
+ }
834
+ if (!this.inReply && (line === "%exit" || line.startsWith("%exit "))) {
835
+ this.retireAtomicPaneSnapshotCollector(collector.spec.nonce, "channel-exit");
836
+ return false;
837
+ }
838
+ if (!this.inReply) {
839
+ collector.failureReason ??= "unexpected-post-line";
840
+ return true;
841
+ }
842
+ collector.blockContentCount += 1;
843
+ if (collector.blockOrdinal === 1 && line === `%pause ${collector.spec.runtimePaneId}`) {
844
+ if (collector.pauseObserved)
845
+ collector.failureReason ??= "duplicate-sentinel";
846
+ collector.pauseObserved = true;
847
+ }
848
+ else if (collector.blockOrdinal === 2 && token === "complete") {
849
+ if (collector.statusObserved)
850
+ collector.failureReason ??= "duplicate-sentinel";
851
+ collector.statusObserved = true;
852
+ }
853
+ else
854
+ collector.failureReason ??= "sentinel-order";
855
+ return true;
856
+ }
629
857
  reportAtomicPaneSnapshotProgress(collector) {
630
858
  if (collector.failureReason !== null)
631
859
  return;
632
860
  collector.spec.onProgress?.(Object.freeze({
633
861
  started: collector.started,
634
862
  lastCompletedOrdinal: collector.lastCompletedOrdinal,
635
- captureLineCount: Math.min(collector.captureLines.length, ATOMIC_CAPTURE_LINE_HARD_CAP),
863
+ captureLineCount: Math.min(collector.captureLines.length + collector.ansiCaptureLines.length, ATOMIC_CAPTURE_LINE_HARD_CAP),
636
864
  captureByteCount: Math.min(collector.captureBytes, ATOMIC_CAPTURE_BYTE_HARD_CAP),
637
865
  continueObserved: collector.continueObserved,
638
866
  statusObserved: collector.statusObserved,
@@ -684,7 +912,7 @@ export class MirrorControlChannel {
684
912
  viewerPending = 0;
685
913
  verifiedAttach = false;
686
914
  guardedStart = null;
687
- atomicCollectorTimer = null;
915
+ atomicCollectorOwner = null;
688
916
  constructor(opts) {
689
917
  this.opts = opts;
690
918
  this.core = new ControlChannelCore({
@@ -701,7 +929,14 @@ export class MirrorControlChannel {
701
929
  if (!opts.nativeServerIdentity || this.verifiedAttach)
702
930
  opts.handlers.onNotify(name, rest);
703
931
  },
704
- onExit: (reason) => this.noteExit(reason),
932
+ onExit: (reason) => {
933
+ try {
934
+ this.noteExit(reason);
935
+ }
936
+ finally {
937
+ void this.dispose();
938
+ }
939
+ },
705
940
  }, opts.nowMicros);
706
941
  }
707
942
  start() {
@@ -966,32 +1201,99 @@ export class MirrorControlChannel {
966
1201
  proc.stdin.write(`${cmd}\n`);
967
1202
  }
968
1203
  armAtomicPaneSnapshotCollector(spec, timeoutMs) {
1204
+ if (this.exited || !this.proc?.stdin?.writable)
1205
+ return false;
969
1206
  if (!Number.isSafeInteger(timeoutMs) || timeoutMs < 1 || timeoutMs > 5_000)
970
1207
  return false;
1208
+ const owner = {
1209
+ nonce: spec.nonce,
1210
+ timer: null,
1211
+ drainTimer: null,
1212
+ retiring: false,
1213
+ completed: false,
1214
+ };
1215
+ const clear = () => {
1216
+ if (owner.timer)
1217
+ clearTimeout(owner.timer);
1218
+ if (owner.drainTimer)
1219
+ clearTimeout(owner.drainTimer);
1220
+ owner.timer = owner.drainTimer = null;
1221
+ if (this.atomicCollectorOwner === owner)
1222
+ this.atomicCollectorOwner = null;
1223
+ };
971
1224
  const wrapped = {
972
1225
  ...spec,
973
1226
  onSettled: (result) => {
974
- if (this.atomicCollectorTimer)
975
- clearTimeout(this.atomicCollectorTimer);
976
- this.atomicCollectorTimer = null;
1227
+ if (owner.timer)
1228
+ clearTimeout(owner.timer);
1229
+ owner.timer = null;
1230
+ owner.completed = result.ok;
1231
+ if (!result.ok && result.failureReason !== "channel-exit" && !owner.retiring) {
1232
+ this.beginCollectorDrain(spec.nonce, result.failureReason ?? "retired", () => spec.onSettled(result));
1233
+ return;
1234
+ }
977
1235
  spec.onSettled(result);
978
1236
  },
1237
+ onDrained: (reason) => {
1238
+ clear();
1239
+ spec.onDrained?.(reason);
1240
+ },
979
1241
  };
980
1242
  if (!this.core.armAtomicPaneSnapshotCollector(wrapped))
981
1243
  return false;
982
- this.atomicCollectorTimer = setTimeout(() => {
983
- this.atomicCollectorTimer = null;
984
- this.core.retireAtomicPaneSnapshotCollector(spec.nonce, "timeout");
985
- }, timeoutMs);
986
- this.atomicCollectorTimer.unref?.();
1244
+ this.atomicCollectorOwner = owner;
1245
+ owner.timer = setTimeout(() => this.retireAtomicPaneSnapshotCollector(spec.nonce, "timeout"), timeoutMs);
1246
+ owner.timer.unref?.();
987
1247
  return true;
988
1248
  }
989
1249
  retireAtomicPaneSnapshotCollector(nonce, reason = "retired") {
990
- if (!this.core.retireAtomicPaneSnapshotCollector(nonce, reason))
1250
+ this.beginCollectorDrain(nonce, reason);
1251
+ }
1252
+ beginCollectorDrain(nonce, reason, settle) {
1253
+ const owner = this.atomicCollectorOwner;
1254
+ if (!owner || owner.nonce !== nonce || owner.retiring || owner.completed)
991
1255
  return;
992
- if (this.atomicCollectorTimer)
993
- clearTimeout(this.atomicCollectorTimer);
994
- this.atomicCollectorTimer = null;
1256
+ owner.retiring = true;
1257
+ if (owner.timer)
1258
+ clearTimeout(owner.timer);
1259
+ owner.timer = null;
1260
+ const abort = (message) => {
1261
+ if (this.atomicCollectorOwner !== owner)
1262
+ return;
1263
+ try {
1264
+ this.core.fail(message);
1265
+ this.noteExit(message);
1266
+ }
1267
+ finally {
1268
+ void this.dispose();
1269
+ }
1270
+ };
1271
+ // Establish drain ownership before onSettled can reenter and try another arm.
1272
+ owner.drainTimer = setTimeout(() => abort("retired snapshot drain timed out"), 5_000);
1273
+ owner.drainTimer.unref?.();
1274
+ try {
1275
+ this.core.retireAtomicPaneSnapshotCollector(nonce, reason);
1276
+ // Natural failed completion already settled the core tombstone. Retire
1277
+ // application ownership before queuing a fence behind dispatched work.
1278
+ settle?.();
1279
+ }
1280
+ catch {
1281
+ abort("retired snapshot settlement callback failed");
1282
+ return;
1283
+ }
1284
+ if (this.atomicCollectorOwner !== owner)
1285
+ return;
1286
+ const fence = `tmux-ide-collector-drain-v1:${randomBytes(24).toString("hex")}`;
1287
+ this.commandBoundedInline(`display-message -p -l ${fence}`, { maxBytes: 256, maxLines: 1 }, (reply) => {
1288
+ if (this.atomicCollectorOwner !== owner)
1289
+ return;
1290
+ if (!reply.ok || reply.lines.length !== 1 || reply.lines[0] !== fence) {
1291
+ abort("invalid retired snapshot drain fence");
1292
+ return;
1293
+ }
1294
+ if (!this.core.releaseRetiredCollector(nonce))
1295
+ abort("retired snapshot owner changed");
1296
+ });
995
1297
  }
996
1298
  send(cmd, onReply) {
997
1299
  const proc = this.proc;
@@ -1013,9 +1315,11 @@ export class MirrorControlChannel {
1013
1315
  */
1014
1316
  async dispose() {
1015
1317
  this.retireNativeViewer();
1016
- if (this.atomicCollectorTimer)
1017
- clearTimeout(this.atomicCollectorTimer);
1018
- this.atomicCollectorTimer = null;
1318
+ const collectorOwner = this.atomicCollectorOwner;
1319
+ if (collectorOwner?.timer)
1320
+ clearTimeout(collectorOwner.timer);
1321
+ if (collectorOwner?.drainTimer)
1322
+ clearTimeout(collectorOwner.drainTimer);
1019
1323
  const proc = this.proc;
1020
1324
  this.proc = null;
1021
1325
  if (!proc)