tmux-ide 2.9.0-beta.19 → 2.9.0-beta.21

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 (90) hide show
  1. package/bin/cli.js +1979 -783
  2. package/package.json +4 -2
  3. package/packages/contracts/src/__tests__/daemon-wire.test.ts +32 -0
  4. package/packages/contracts/src/daemon-events.ts +12 -12
  5. package/packages/contracts/src/daemon-wire.ts +30 -0
  6. package/packages/daemon/dist/command-center/agent-status-watch.js +9 -2
  7. package/packages/daemon/dist/command-center/diagnostics.js +65 -0
  8. package/packages/daemon/dist/command-center/log-stream.js +9 -0
  9. package/packages/daemon/dist/command-center/resources/fleet-preview-route.js +22 -6
  10. package/packages/daemon/dist/command-center/server.js +7 -0
  11. package/packages/daemon/dist/doctor.js +62 -0
  12. package/packages/daemon/dist/lib/__tests__/installed-recovery-fixture.js +400 -0
  13. package/packages/daemon/dist/lib/app-config.js +4 -2
  14. package/packages/daemon/dist/lib/canonical-daemon.js +1 -0
  15. package/packages/daemon/dist/lib/daemon-embed.js +24 -0
  16. package/packages/daemon/dist/lib/daemon-provenance.js +575 -0
  17. package/packages/daemon/dist/lib/headless-daemon.js +1 -0
  18. package/packages/daemon/dist/lib/log-sanitize.js +199 -0
  19. package/packages/daemon/dist/lib/log.js +123 -13
  20. package/packages/daemon/dist/lib/soak-diagnostics.js +124 -0
  21. package/packages/daemon/dist/lib/soak-verdict.js +472 -0
  22. package/packages/daemon/dist/lib/terminal-host-color.js +33 -0
  23. package/packages/daemon/dist/lib/tmux-external-interaction-observer.js +187 -39
  24. package/packages/daemon/dist/lib/tmux-interaction-retention.js +21 -0
  25. package/packages/daemon/dist/lib/workspace-promotion.js +55 -37
  26. package/packages/daemon/dist/terminal/mirror/session-channel.js +2 -1
  27. package/packages/daemon/dist/tui/mirror/automatic-contrast.js +161 -0
  28. package/packages/daemon/dist/tui/mirror/open-tui-workspace-runtime-port.js +9 -3
  29. package/packages/daemon/dist/tui/mirror/pane-surface.jsx +7 -5
  30. package/packages/daemon/dist/tui/mirror/resize-transaction.js +48 -20
  31. package/packages/daemon/dist/tui/mirror/runtime/application-appearance-owner.js +40 -6
  32. package/packages/daemon/dist/tui/mirror/runtime/application-fleet-preview.js +4 -1
  33. package/packages/daemon/dist/tui/mirror/runtime/application-machine-sidebar.jsx +63 -42
  34. package/packages/daemon/dist/tui/mirror/runtime/application-terminal-interaction-controller.js +138 -67
  35. package/packages/daemon/dist/tui/mirror/runtime/application-terminal-palette-owner.js +44 -28
  36. package/packages/daemon/dist/tui/mirror/runtime/application-terminal-workspace.jsx +49 -11
  37. package/packages/daemon/dist/tui/mirror/runtime/semantic-shell-viewport-resize.js +140 -2
  38. package/packages/daemon/dist/tui/mirror/runtime/workspace-terminal-fast-lane.js +3 -1
  39. package/packages/daemon/dist/tui/mirror/semantic-pane-render-source.js +2 -1
  40. package/packages/daemon/dist/tui/mirror/theme.js +4 -31
  41. package/packages/daemon/dist/tui/mirror/workspace/terminal-pane-header.jsx +15 -8
  42. package/packages/daemon/dist/tui/team/wait-receipts.js +112 -33
  43. package/packages/daemon/src/command-center/agent-status-watch.ts +8 -2
  44. package/packages/daemon/src/command-center/diagnostics.ts +75 -0
  45. package/packages/daemon/src/command-center/log-stream.ts +8 -0
  46. package/packages/daemon/src/command-center/resources/fleet-preview-route.ts +27 -5
  47. package/packages/daemon/src/command-center/server.ts +8 -0
  48. package/packages/daemon/src/doctor.ts +70 -0
  49. package/packages/daemon/src/lib/app-config.ts +11 -3
  50. package/packages/daemon/src/lib/canonical-daemon.ts +1 -0
  51. package/packages/daemon/src/lib/daemon-embed.ts +30 -0
  52. package/packages/daemon/src/lib/daemon-provenance.ts +769 -0
  53. package/packages/daemon/src/lib/fleet-preview-model.ts +1 -0
  54. package/packages/daemon/src/lib/headless-daemon.ts +1 -0
  55. package/packages/daemon/src/lib/log-sanitize.ts +248 -0
  56. package/packages/daemon/src/lib/log.ts +165 -14
  57. package/packages/daemon/src/lib/soak-diagnostics.ts +183 -0
  58. package/packages/daemon/src/lib/soak-verdict.ts +785 -0
  59. package/packages/daemon/src/lib/terminal-host-color.ts +43 -0
  60. package/packages/daemon/src/lib/tmux-external-interaction-observer.ts +233 -37
  61. package/packages/daemon/src/lib/tmux-interaction-retention.ts +24 -0
  62. package/packages/daemon/src/lib/workspace-promotion.ts +74 -45
  63. package/packages/daemon/src/terminal/mirror/session-channel.ts +2 -1
  64. package/packages/daemon/src/tui/mirror/automatic-contrast.ts +180 -0
  65. package/packages/daemon/src/tui/mirror/open-tui-workspace-runtime-port.ts +26 -5
  66. package/packages/daemon/src/tui/mirror/pane-surface.tsx +9 -8
  67. package/packages/daemon/src/tui/mirror/resize-transaction.ts +46 -22
  68. package/packages/daemon/src/tui/mirror/runtime/application-appearance-owner.ts +45 -6
  69. package/packages/daemon/src/tui/mirror/runtime/application-fleet-preview.ts +4 -0
  70. package/packages/daemon/src/tui/mirror/runtime/application-fleet-switcher.tsx +2 -1
  71. package/packages/daemon/src/tui/mirror/runtime/application-machine-sidebar.tsx +99 -75
  72. package/packages/daemon/src/tui/mirror/runtime/application-palette-preview.tsx +13 -3
  73. package/packages/daemon/src/tui/mirror/runtime/application-reference-sheet.tsx +125 -0
  74. package/packages/daemon/src/tui/mirror/runtime/application-root-v2.tsx +2 -1
  75. package/packages/daemon/src/tui/mirror/runtime/application-shell-overlays.tsx +199 -157
  76. package/packages/daemon/src/tui/mirror/runtime/application-shell-view.tsx +2 -0
  77. package/packages/daemon/src/tui/mirror/runtime/application-terminal-interaction-controller.ts +148 -69
  78. package/packages/daemon/src/tui/mirror/runtime/application-terminal-palette-owner.ts +55 -28
  79. package/packages/daemon/src/tui/mirror/runtime/application-terminal-workspace.tsx +59 -17
  80. package/packages/daemon/src/tui/mirror/runtime/semantic-shell-viewport-resize.ts +151 -3
  81. package/packages/daemon/src/tui/mirror/runtime/workspace-terminal-fast-lane.ts +3 -1
  82. package/packages/daemon/src/tui/mirror/semantic-pane-render-source.ts +2 -1
  83. package/packages/daemon/src/tui/mirror/theme.ts +4 -39
  84. package/packages/daemon/src/tui/mirror/workspace/terminal-pane-header.tsx +21 -8
  85. package/packages/daemon/src/tui/team/wait-receipts.ts +115 -40
  86. package/packages/daemon-client/src/terminal-fast-lane.test.ts +18 -0
  87. package/packages/daemon-client/src/terminal-fast-lane.ts +7 -2
  88. package/packages/tmux-bridge/src/index.ts +2 -0
  89. package/packages/tmux-bridge/src/runner.test.ts +41 -0
  90. package/packages/tmux-bridge/src/runner.ts +41 -2
@@ -0,0 +1,43 @@
1
+ import type { RendererNeutralColor } from "@tmux-ide/contracts";
2
+ import type { ResolvedThemeMode } from "./theme-mode.ts";
3
+
4
+ function color(red: number, green: number, blue: number): RendererNeutralColor {
5
+ return { space: "srgb", red, green, blue, alpha: 255 };
6
+ }
7
+
8
+ export function parseTerminalHostColor(
9
+ value: string | null | undefined,
10
+ ): RendererNeutralColor | null {
11
+ if (!value) return null;
12
+ const normalized = value.trim().toLowerCase();
13
+ const hex = /^#([\da-f]{3}|[\da-f]{6})$/u.exec(normalized)?.[1];
14
+ if (hex) {
15
+ const expanded =
16
+ hex.length === 3 ? `${hex[0]}${hex[0]}${hex[1]}${hex[1]}${hex[2]}${hex[2]}` : hex;
17
+ return color(
18
+ Number.parseInt(expanded.slice(0, 2), 16),
19
+ Number.parseInt(expanded.slice(2, 4), 16),
20
+ Number.parseInt(expanded.slice(4, 6), 16),
21
+ );
22
+ }
23
+ // OSC palette replies may use X11's rgb:RR/GG/BB form with one to four
24
+ // hexadecimal digits per channel. Scale each channel to a byte rather than
25
+ // truncating high-fidelity replies.
26
+ const x11 = /^rgb:([\da-f]{1,4})\/([\da-f]{1,4})\/([\da-f]{1,4})$/u.exec(normalized);
27
+ if (!x11) return null;
28
+ const channel = (part: string): number => {
29
+ const maximum = 16 ** part.length - 1;
30
+ return Math.round((Number.parseInt(part, 16) / maximum) * 255);
31
+ };
32
+ return color(channel(x11[1]!), channel(x11[2]!), channel(x11[3]!));
33
+ }
34
+
35
+ /** Only a reported default background determines host appearance. */
36
+ export function terminalHostMode(background: string | null | undefined): ResolvedThemeMode | null {
37
+ const value = parseTerminalHostColor(background);
38
+ return value
39
+ ? 0.299 * value.red + 0.587 * value.green + 0.114 * value.blue > 127.5
40
+ ? "light"
41
+ : "dark"
42
+ : null;
43
+ }
@@ -3,6 +3,13 @@ import { execFile } from "node:child_process";
3
3
  import { WorkspacePaneCreationReferenceSchemaZ } from "@tmux-ide/contracts";
4
4
  import { z } from "zod";
5
5
 
6
+ import { logger } from "./log.ts";
7
+ import {
8
+ boundedTmuxInteractionAppendCommand,
9
+ tmuxInteractionOption,
10
+ TMUX_INTERACTION_GAP_RECORD,
11
+ TMUX_INTERACTION_MAX_DRAIN_BYTES,
12
+ } from "./tmux-interaction-retention.ts";
6
13
  import type { WorkspacePaneTmuxAuthority } from "./workspace-pane-creation.ts";
7
14
  import { createPinnedWorkspaceTmuxAsyncRunner } from "./workspace-pane-creation.ts";
8
15
  import { getDefaultWorkspaceRegistry, type WorkspaceRegistry } from "./workspace-registry.ts";
@@ -13,18 +20,72 @@ import {
13
20
  INTERNAL_SEND_OPERATION_OPTION,
14
21
  } from "./tmux-interaction-options.ts";
15
22
 
16
- const HOOK_MARKER = "tmux-ide-interaction-v2";
23
+ const HOOK_MARKER = "tmux-ide-interaction-v3";
17
24
  const OWNED_HOOK_MARKER = "tmux-ide-interaction-v";
18
25
  const FIELD_SEPARATOR = "|tmux-ide-input-field-v1|";
19
26
  const EVENT_SEPARATOR = "|tmux-ide-input-event-v1|";
27
+ export interface ExternalTmuxInteractionGap {
28
+ readonly reason:
29
+ | "overflow"
30
+ | "read-failed"
31
+ | "detach-failed"
32
+ | "projection-failed"
33
+ | "hooks-replaced"
34
+ | "hook-repair-failed";
35
+ readonly recovery: "future-observations-only";
36
+ }
37
+
20
38
  const RUNTIME_PANE = /^%[0-9]+$/u;
21
39
  const RETRY_MS = 1_000;
22
40
  /**
23
41
  * Hooks are shared tmux state. A config reload, another client, or a debugging
24
42
  * command can replace them without killing the daemon, so waiting forever on
25
- * the old signal channel is not a sufficient health check.
43
+ * the old signal channel is not a sufficient health check. Every check costs
44
+ * one tmux child process, so a check that keeps finding the hooks intact backs
45
+ * off (doubling from `baseMs`, capped at `maxMs`); any repair, failure, or
46
+ * signal-loop error returns the cadence to `baseMs`. Measured on an otherwise
47
+ * idle daemon the fixed one-second cadence was the daemon's only recurring
48
+ * spawn source (two `show-hooks` children per second).
49
+ */
50
+ export interface HookHealthcheckSchedule {
51
+ readonly baseMs: number;
52
+ readonly maxMs: number;
53
+ }
54
+
55
+ export const DEFAULT_HOOK_HEALTHCHECK_SCHEDULE: HookHealthcheckSchedule = Object.freeze({
56
+ baseMs: 1_000,
57
+ maxMs: 30_000,
58
+ });
59
+
60
+ export type HookHealthcheckOutcome = "healthy" | "repaired" | "failed";
61
+
62
+ /** Pure cadence rule: healthy checks double the wait up to the cap; anything else resets it. */
63
+ export function nextHookHealthcheckDelay(
64
+ previousMs: number,
65
+ outcome: HookHealthcheckOutcome,
66
+ schedule: HookHealthcheckSchedule = DEFAULT_HOOK_HEALTHCHECK_SCHEDULE,
67
+ ): number {
68
+ const base = Math.max(1, Math.floor(schedule.baseMs));
69
+ const max = Math.max(base, Math.floor(schedule.maxMs));
70
+ if (outcome !== "healthy") return base;
71
+ const previous = Number.isFinite(previousMs) ? Math.max(base, Math.floor(previousMs)) : base;
72
+ return Math.min(previous * 2, max);
73
+ }
74
+
75
+ /**
76
+ * True when `show-hooks` output lists an entry of `hookName` whose body carries
77
+ * this observer's buffer name. Output may hold several hook arrays at once
78
+ * (one combined `show-hooks -g a ; show-hooks -g b` client), so the check is
79
+ * per hook line rather than a substring search over the whole output.
26
80
  */
27
- const HOOK_HEALTHCHECK_MS = 1_000;
81
+ export function ownedHookInstalled(output: string, hookName: string, bufferName: string): boolean {
82
+ const row = new RegExp(`^${hookName}\\[([0-9]+)\\]\\s+(.+)$`, "u");
83
+ for (const line of output.split("\n")) {
84
+ const match = row.exec(line);
85
+ if (match && match[2]!.includes(bufferName)) return true;
86
+ }
87
+ return false;
88
+ }
28
89
 
29
90
  export { INTERNAL_READ_OPERATION_OPTION, INTERNAL_SEND_OPERATION_OPTION };
30
91
 
@@ -185,7 +246,7 @@ function hookIndexes(
185
246
  /**
186
247
  * Event-driven adapter from tmux's native send/capture hooks into the semantic
187
248
  * interaction spine. Hooks write only runtime identity, operation kind, and an
188
- * internal-operation marker to a tmux paste buffer, then signal a blocked
249
+ * internal-operation marker to bounded tmux option storage, then signal a blocked
189
250
  * `wait-for` client. No terminal input or captured output crosses this boundary.
190
251
  */
191
252
  export class TmuxExternalInteractionObserver {
@@ -200,8 +261,11 @@ export class TmuxExternalInteractionObserver {
200
261
  #installed = false;
201
262
  #loop: Promise<void> | null = null;
202
263
  #starting: Promise<void> | null = null;
203
- #hookHealthcheck: ReturnType<typeof setInterval> | null = null;
204
- #drainSequence = 0;
264
+ #hookHealthcheck: ReturnType<typeof setTimeout> | null = null;
265
+ readonly #healthcheckSchedule: HookHealthcheckSchedule;
266
+ #healthcheckDelayMs: number;
267
+ #lastHealthcheckOutcome: HookHealthcheckOutcome = "failed";
268
+ readonly #onGap: ((gap: ExternalTmuxInteractionGap) => void) | undefined;
205
269
  #tmuxWork: Promise<unknown> = Promise.resolve();
206
270
  #reconcile: Promise<void> | null = null;
207
271
  #diagnostics: ExternalTmuxObserverDiagnostics | null;
@@ -221,10 +285,19 @@ export class TmuxExternalInteractionObserver {
221
285
  */
222
286
  onObserved: (interaction: ExternalTmuxInteraction) => boolean;
223
287
  diagnostics?: ExternalTmuxObserverDiagnostics;
288
+ onGap?: (gap: ExternalTmuxInteractionGap) => void;
289
+ /** Health-check cadence bounds. Tests inject small values; production uses the default. */
290
+ healthcheck?: Partial<HookHealthcheckSchedule>;
224
291
  }) {
225
292
  this.#daemonInstanceId = options.daemonInstanceId;
293
+ this.#healthcheckSchedule = Object.freeze({
294
+ baseMs: options.healthcheck?.baseMs ?? DEFAULT_HOOK_HEALTHCHECK_SCHEDULE.baseMs,
295
+ maxMs: options.healthcheck?.maxMs ?? DEFAULT_HOOK_HEALTHCHECK_SCHEDULE.maxMs,
296
+ });
297
+ this.#healthcheckDelayMs = this.#healthcheckSchedule.baseMs;
226
298
  this.#registry = options.registry ?? getDefaultWorkspaceRegistry();
227
299
  this.#onObserved = options.onObserved;
300
+ this.#onGap = options.onGap;
228
301
  this.#authenticatedInternalReads = new AuthenticatedInternalReadVerifier({
229
302
  daemonInstanceId: options.daemonInstanceId,
230
303
  ownerToken: options.internalReadOwnerToken,
@@ -274,7 +347,7 @@ export class TmuxExternalInteractionObserver {
274
347
  } catch (error) {
275
348
  await this.#serializeTmux(async () => {
276
349
  await this.#removeOwnedHooks();
277
- await this.#deleteBuffer(this.#bufferName);
350
+ await this.#deleteRetention();
278
351
  });
279
352
  if (!isTmuxServerUnavailable(error)) {
280
353
  this.#active = false;
@@ -289,13 +362,58 @@ export class TmuxExternalInteractionObserver {
289
362
  if (!this.#active || this.#abort.signal.aborted) {
290
363
  await this.#serializeTmux(async () => {
291
364
  await this.#removeOwnedHooks();
292
- await this.#deleteBuffer(this.#bufferName);
365
+ await this.#deleteRetention();
293
366
  });
294
367
  throw new Error("tmux external interaction observer was disposed during startup");
295
368
  }
296
369
  this.#loop = this.#run();
297
- this.#hookHealthcheck = setInterval(() => void this.reconcileHooks(), HOOK_HEALTHCHECK_MS);
298
- this.#hookHealthcheck.unref?.();
370
+ this.#scheduleHealthcheck();
371
+ }
372
+
373
+ /** Wait before the next scheduled hook health check. Exposed for tests. */
374
+ get healthcheckDelayMs(): number {
375
+ return this.#healthcheckDelayMs;
376
+ }
377
+
378
+ #scheduleHealthcheck(): void {
379
+ if (this.#hookHealthcheck) clearTimeout(this.#hookHealthcheck);
380
+ this.#hookHealthcheck = null;
381
+ if (!this.#active || this.#abort.signal.aborted) return;
382
+ const timer = setTimeout(() => {
383
+ if (this.#hookHealthcheck === timer) this.#hookHealthcheck = null;
384
+ void this.#runScheduledHealthcheck();
385
+ }, this.#healthcheckDelayMs);
386
+ timer.unref?.();
387
+ this.#hookHealthcheck = timer;
388
+ }
389
+
390
+ async #runScheduledHealthcheck(): Promise<void> {
391
+ const delayBefore = this.#healthcheckDelayMs;
392
+ let outcome: HookHealthcheckOutcome;
393
+ try {
394
+ await this.reconcileHooks();
395
+ outcome = this.#lastHealthcheckOutcome;
396
+ } catch {
397
+ if (!this.#abort.signal.aborted) this.#reportGap("hook-repair-failed");
398
+ outcome = "failed";
399
+ }
400
+ // A signal-path reset that landed during this check wins over the outcome.
401
+ if (this.#healthcheckDelayMs === delayBefore) {
402
+ this.#healthcheckDelayMs = nextHookHealthcheckDelay(
403
+ delayBefore,
404
+ outcome,
405
+ this.#healthcheckSchedule,
406
+ );
407
+ }
408
+ if (this.#hookHealthcheck === null) this.#scheduleHealthcheck();
409
+ }
410
+
411
+ /** Something failed on the signal path: verify the hooks promptly again. */
412
+ #resetHealthcheckBackoff(): void {
413
+ const base = this.#healthcheckSchedule.baseMs;
414
+ if (this.#healthcheckDelayMs === base) return;
415
+ this.#healthcheckDelayMs = base;
416
+ if (this.#hookHealthcheck) this.#scheduleHealthcheck();
299
417
  }
300
418
 
301
419
  setDiagnostics(diagnostics: ExternalTmuxObserverDiagnostics | null): void {
@@ -303,17 +421,17 @@ export class TmuxExternalInteractionObserver {
303
421
  }
304
422
 
305
423
  async dispose(): Promise<void> {
306
- if (!this.#active && !this.#loop && !this.#starting) return;
424
+ if (!this.#active && !this.#loop && !this.#starting && !this.#installed) return;
307
425
  const starting = this.#starting;
308
426
  this.#active = false;
309
427
  this.#abort.abort();
310
- if (this.#hookHealthcheck) clearInterval(this.#hookHealthcheck);
428
+ if (this.#hookHealthcheck) clearTimeout(this.#hookHealthcheck);
311
429
  this.#hookHealthcheck = null;
312
430
  await Promise.allSettled([starting, this.#loop]);
313
431
  this.#loop = null;
314
432
  await this.#serializeTmux(async () => {
315
433
  await this.#removeOwnedHooks();
316
- await this.#deleteBuffer(this.#bufferName);
434
+ await this.#deleteRetention();
317
435
  });
318
436
  }
319
437
 
@@ -326,20 +444,30 @@ export class TmuxExternalInteractionObserver {
326
444
  await this.#removeOwnedHooks(signal);
327
445
  await this.#deleteOwnedBuffers(signal);
328
446
  signal?.throwIfAborted();
447
+ // The small named buffer indexes retention options for crash cleanup;
448
+ // event data lives only in bounded options, never in this buffer.
449
+ await this.#io.runTmux(["set-buffer", "-b", this.#bufferName, "retention-owner"], signal);
329
450
  const hook = (
330
451
  operationKind: ExternalTmuxInteraction["operationKind"],
331
452
  markerOption: string,
332
453
  consumeMarker: boolean,
333
454
  ) => {
334
- const data = `#{pane_id}${FIELD_SEPARATOR}#{q:${markerOption}}${FIELD_SEPARATOR}${operationKind}${EVENT_SEPARATOR}`;
455
+ // Reject arbitrary option text before expansion into a tmux command.
456
+ // Bound markers at the producer, including across multibyte input.
457
+ const validMarker = `#{&&:#{m/r:^[A-Za-z0-9:._-]*$,#{${markerOption}}},#{e|<=:#{n:${markerOption}},160}}`;
458
+ const marker = `#{?${validMarker},#{${markerOption}},}`;
459
+ const data = `#{pane_id}${FIELD_SEPARATOR}${marker}${FIELD_SEPARATOR}${operationKind}${EVENT_SEPARATOR}`;
335
460
  // Expand pane/marker identity at hook invocation, then schedule the
336
461
  // append+signal as a background tmux-native command list. No shell and
337
462
  // no second tmux client sit on the invoking command queue. The tiny
338
463
  // synchronous native cleanup runs only after the record string has been
339
464
  // captured, so a marker is single-use without racing the async drain.
340
- const publish =
341
- `run-shell -b -C "set-buffer -a -b '${this.#bufferName}' '${data}'` +
342
- ` ; wait-for -S '${this.#signalChannel}'"`;
465
+ // Escape only the retention format for evaluation in the queued native
466
+ // command; capture pane and marker immediately, before consuming it.
467
+ const append = boundedTmuxInteractionAppendCommand(this.#bufferName, "RECORD")
468
+ .replace("#{=", "##{=")
469
+ .replace("RECORD", data);
470
+ const publish = `run-shell -b -C "${append} ; wait-for -S '${this.#signalChannel}'"`;
343
471
  // Hook commands inherit the triggering pane as their target, so cleanup
344
472
  // needs neither format expansion nor a nested command queue.
345
473
  const consume = consumeMarker ? ` ; set-option -pu '${markerOption}'` : "";
@@ -371,7 +499,7 @@ export class TmuxExternalInteractionObserver {
371
499
  /**
372
500
  * Restore product hooks when external tmux configuration removed them.
373
501
  * Public only so the lifecycle is hermetically testable; the production
374
- * observer invokes it from a cheap one-second health check.
502
+ * observer invokes it from the backed-off health check.
375
503
  */
376
504
  reconcileHooks(options: { readonly allowInactive?: boolean } = {}): Promise<void> {
377
505
  if (!this.#active && options.allowInactive !== true) return Promise.resolve();
@@ -380,15 +508,20 @@ export class TmuxExternalInteractionObserver {
380
508
  const finish = this.#beginDiagnostic("healthcheck");
381
509
  try {
382
510
  if (await this.#ownedHooksPresent(this.#abort.signal)) {
511
+ this.#lastHealthcheckOutcome = "healthy";
383
512
  finish(true);
384
513
  return;
385
514
  }
515
+ // Hooks that were installed and are now gone dropped every interaction
516
+ // since their removal; the repair restores future observation only.
517
+ if (this.#installed) this.#reportGap("hooks-replaced");
386
518
  this.#installed = false;
387
519
  try {
388
520
  await this.#install(this.#abort.signal);
389
521
  } catch {
390
522
  this.#installed = false;
391
523
  }
524
+ this.#lastHealthcheckOutcome = this.#installed ? "repaired" : "failed";
392
525
  finish(this.#installed);
393
526
  } catch (error) {
394
527
  finish(false);
@@ -402,38 +535,57 @@ export class TmuxExternalInteractionObserver {
402
535
  return settled;
403
536
  }
404
537
 
405
- /** Atomically detach and drain the current event buffer. */
538
+ /** Atomically detach and drain the current bounded event batch. */
406
539
  drain(): Promise<boolean> {
407
540
  return this.#serializeTmux(() => this.#drain());
408
541
  }
409
542
 
410
543
  async #drain(): Promise<boolean> {
411
544
  const finish = this.#beginDiagnostic("drain");
412
- const drainName = `${this.#bufferName}-drain-${++this.#drainSequence}`;
545
+ const option = tmuxInteractionOption(this.#bufferName);
546
+ // A single reusable detached slot bounds retained storage even if deletion
547
+ // fails. Native synchronous commands execute consecutively in one queue.
548
+ const drainName = `${option}-drain`;
413
549
  try {
414
550
  await this.#io.runTmux(
415
- ["set-buffer", "-b", this.#bufferName, "-n", drainName],
551
+ ["set-option", "-gF", drainName, `#{${option}}`, ";", "set-option", "-g", option, ""],
416
552
  this.#abort.signal,
417
553
  );
418
554
  } catch {
555
+ this.#reportGap("detach-failed");
419
556
  finish(false);
420
557
  return false;
421
558
  }
422
- let raw: string;
423
- try {
424
- raw = await this.#io.runTmux(["show-buffer", "-b", drainName], this.#abort.signal);
425
- } catch {
559
+ let raw: string | undefined;
560
+ // Retry the same immutable detached batch once. Never accumulate an
561
+ // unbounded collection of failed batches or spin until a server recovers.
562
+ for (let attempt = 0; attempt < 2 && !this.#abort.signal.aborted; attempt += 1) {
563
+ try {
564
+ raw = await this.#io.runTmux(["show-options", "-gv", drainName], this.#abort.signal);
565
+ break;
566
+ } catch {
567
+ // Exhaustion is surfaced below, without terminal content or errors.
568
+ }
569
+ }
570
+ await this.#deleteOption(drainName);
571
+ if (raw === undefined) {
572
+ this.#reportGap("read-failed");
573
+ finish(false);
574
+ return false;
575
+ }
576
+ if (Buffer.byteLength(raw, "utf8") > TMUX_INTERACTION_MAX_DRAIN_BYTES) {
577
+ this.#reportGap("overflow");
426
578
  finish(false);
427
579
  return false;
428
- } finally {
429
- await this.#deleteBuffer(drainName);
430
580
  }
581
+ if (raw.includes(TMUX_INTERACTION_GAP_RECORD)) this.#reportGap("overflow");
431
582
  let consumed = false;
432
583
  try {
433
584
  for (const record of parseTmuxInputHookRecords(raw)) {
434
585
  consumed = (await this.#project(record)) || consumed;
435
586
  }
436
587
  } catch (error) {
588
+ this.#reportGap("projection-failed");
437
589
  finish(false);
438
590
  throw error;
439
591
  }
@@ -452,6 +604,7 @@ export class TmuxExternalInteractionObserver {
452
604
  await this.drain();
453
605
  } catch {
454
606
  this.#installed = false;
607
+ this.#resetHealthcheckBackoff();
455
608
  await this.#io.delay(RETRY_MS, this.#abort.signal);
456
609
  }
457
610
  }
@@ -527,16 +680,54 @@ export class TmuxExternalInteractionObserver {
527
680
  }
528
681
 
529
682
  async #ownedHooksPresent(signal?: AbortSignal): Promise<boolean> {
530
- for (const hookName of ["after-send-keys", "after-capture-pane"] as const) {
531
- let output: string;
532
- try {
533
- output = await this.#io.runTmux(["show-hooks", "-g", hookName], signal);
534
- } catch {
535
- return false;
536
- }
537
- if (!output.includes(this.#bufferName)) return false;
683
+ // One client verifies both hook arrays: tmux runs the `;`-separated list in
684
+ // a single command queue and prints each array's lines in order.
685
+ let output: string;
686
+ try {
687
+ output = await this.#io.runTmux(
688
+ ["show-hooks", "-g", "after-send-keys", ";", "show-hooks", "-g", "after-capture-pane"],
689
+ signal,
690
+ );
691
+ } catch {
692
+ return false;
693
+ }
694
+ return (
695
+ ownedHookInstalled(output, "after-send-keys", this.#bufferName) &&
696
+ ownedHookInstalled(output, "after-capture-pane", this.#bufferName)
697
+ );
698
+ }
699
+
700
+ #reportGap(reason: ExternalTmuxInteractionGap["reason"]): void {
701
+ if (this.#abort.signal.aborted) return;
702
+ const gap: ExternalTmuxInteractionGap = { reason, recovery: "future-observations-only" };
703
+ logger.warn(
704
+ "tmux-interaction-observer",
705
+ "Interaction observation gap; missing history cannot be replayed",
706
+ {
707
+ daemonInstanceId: this.#daemonInstanceId,
708
+ ...gap,
709
+ },
710
+ );
711
+ try {
712
+ this.#onGap?.(gap);
713
+ } catch {
714
+ /* Reporting cannot create operation authority. */
538
715
  }
539
- return true;
716
+ }
717
+
718
+ async #deleteOption(name: string, signal?: AbortSignal): Promise<void> {
719
+ try {
720
+ await this.#io.runTmux(["set-option", "-gu", name], signal);
721
+ } catch {
722
+ /* Best effort. */
723
+ }
724
+ }
725
+
726
+ async #deleteRetention(): Promise<void> {
727
+ const option = tmuxInteractionOption(this.#bufferName);
728
+ await this.#deleteOption(option);
729
+ await this.#deleteOption(`${option}-drain`);
730
+ await this.#deleteBuffer(this.#bufferName);
540
731
  }
541
732
 
542
733
  async #deleteOwnedBuffers(signal?: AbortSignal): Promise<void> {
@@ -547,7 +738,12 @@ export class TmuxExternalInteractionObserver {
547
738
  return;
548
739
  }
549
740
  for (const name of output.split("\n")) {
550
- if (name.startsWith(OWNED_HOOK_MARKER)) await this.#deleteBuffer(name, signal);
741
+ if (!name.startsWith(OWNED_HOOK_MARKER) || name === this.#bufferName) continue;
742
+ if (/^[A-Za-z0-9._-]{1,256}$/u.test(name)) {
743
+ await this.#deleteOption(tmuxInteractionOption(name), signal);
744
+ await this.#deleteOption(`${tmuxInteractionOption(name)}-drain`, signal);
745
+ }
746
+ await this.#deleteBuffer(name, signal);
551
747
  }
552
748
  }
553
749
 
@@ -0,0 +1,24 @@
1
+ const EVENT_SEPARATOR = "|tmux-ide-input-event-v1|";
2
+
3
+ // ASCII metadata only. The native producer retains a prefix plus the newest
4
+ // record; truncation inserts a framed gap, never an invented interaction.
5
+ // Keep this within tmux's supported format-width range: larger widths can
6
+ // silently disable truncation. Live tests cover tmux 3.4 and 3.7c.
7
+ export const TMUX_INTERACTION_RETAINED_CHARS = 8_192;
8
+ export const TMUX_INTERACTION_GAP_RECORD = `${EVENT_SEPARATOR}gap${EVENT_SEPARATOR}`;
9
+ export const TMUX_INTERACTION_MAX_DRAIN_BYTES =
10
+ TMUX_INTERACTION_RETAINED_CHARS + TMUX_INTERACTION_GAP_RECORD.length + 1_024;
11
+
12
+ export function tmuxInteractionOption(bufferName: string): string {
13
+ if (!/^[A-Za-z0-9._-]{1,256}$/u.test(bufferName)) throw new TypeError("Invalid observer name");
14
+ return `@${bufferName}`;
15
+ }
16
+
17
+ /** One native command, also used by atomic NOHOOKS recovery (fixed ordinals). */
18
+ export function boundedTmuxInteractionAppendCommand(bufferName: string, record: string): string {
19
+ const option = tmuxInteractionOption(bufferName);
20
+ if (!/^[A-Za-z0-9%:._|-]{1,1024}$/u.test(record)) {
21
+ throw new TypeError("Invalid observer metadata");
22
+ }
23
+ return `set-option -gF '${option}' '#{=/${TMUX_INTERACTION_RETAINED_CHARS}/${TMUX_INTERACTION_GAP_RECORD}:${option}}${record}'`;
24
+ }