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
@@ -1,21 +1,45 @@
1
1
  import { execFile } from "node:child_process";
2
2
  import { WorkspacePaneCreationReferenceSchemaZ } from "@tmux-ide/contracts";
3
3
  import { z } from "zod";
4
+ import { logger } from "./log.js";
5
+ import { boundedTmuxInteractionAppendCommand, tmuxInteractionOption, TMUX_INTERACTION_GAP_RECORD, TMUX_INTERACTION_MAX_DRAIN_BYTES, } from "./tmux-interaction-retention.js";
4
6
  import { createPinnedWorkspaceTmuxAsyncRunner } from "./workspace-pane-creation.js";
5
7
  import { getDefaultWorkspaceRegistry } from "./workspace-registry.js";
6
8
  import { AuthenticatedInternalReadVerifier, consumeInternalReadOperation, INTERNAL_READ_OPERATION_OPTION, INTERNAL_SEND_OPERATION_OPTION, } from "./tmux-interaction-options.js";
7
- const HOOK_MARKER = "tmux-ide-interaction-v2";
9
+ const HOOK_MARKER = "tmux-ide-interaction-v3";
8
10
  const OWNED_HOOK_MARKER = "tmux-ide-interaction-v";
9
11
  const FIELD_SEPARATOR = "|tmux-ide-input-field-v1|";
10
12
  const EVENT_SEPARATOR = "|tmux-ide-input-event-v1|";
11
13
  const RUNTIME_PANE = /^%[0-9]+$/u;
12
14
  const RETRY_MS = 1_000;
15
+ export const DEFAULT_HOOK_HEALTHCHECK_SCHEDULE = Object.freeze({
16
+ baseMs: 1_000,
17
+ maxMs: 30_000,
18
+ });
19
+ /** Pure cadence rule: healthy checks double the wait up to the cap; anything else resets it. */
20
+ export function nextHookHealthcheckDelay(previousMs, outcome, schedule = DEFAULT_HOOK_HEALTHCHECK_SCHEDULE) {
21
+ const base = Math.max(1, Math.floor(schedule.baseMs));
22
+ const max = Math.max(base, Math.floor(schedule.maxMs));
23
+ if (outcome !== "healthy")
24
+ return base;
25
+ const previous = Number.isFinite(previousMs) ? Math.max(base, Math.floor(previousMs)) : base;
26
+ return Math.min(previous * 2, max);
27
+ }
13
28
  /**
14
- * Hooks are shared tmux state. A config reload, another client, or a debugging
15
- * command can replace them without killing the daemon, so waiting forever on
16
- * the old signal channel is not a sufficient health check.
29
+ * True when `show-hooks` output lists an entry of `hookName` whose body carries
30
+ * this observer's buffer name. Output may hold several hook arrays at once
31
+ * (one combined `show-hooks -g a ; show-hooks -g b` client), so the check is
32
+ * per hook line rather than a substring search over the whole output.
17
33
  */
18
- const HOOK_HEALTHCHECK_MS = 1_000;
34
+ export function ownedHookInstalled(output, hookName, bufferName) {
35
+ const row = new RegExp(`^${hookName}\\[([0-9]+)\\]\\s+(.+)$`, "u");
36
+ for (const line of output.split("\n")) {
37
+ const match = row.exec(line);
38
+ if (match && match[2].includes(bufferName))
39
+ return true;
40
+ }
41
+ return false;
42
+ }
19
43
  export { INTERNAL_READ_OPERATION_OPTION, INTERNAL_SEND_OPERATION_OPTION };
20
44
  function socketArguments(authority) {
21
45
  return authority.socketSelector.kind === "path"
@@ -120,7 +144,7 @@ function hookIndexes(output, hookName) {
120
144
  /**
121
145
  * Event-driven adapter from tmux's native send/capture hooks into the semantic
122
146
  * interaction spine. Hooks write only runtime identity, operation kind, and an
123
- * internal-operation marker to a tmux paste buffer, then signal a blocked
147
+ * internal-operation marker to bounded tmux option storage, then signal a blocked
124
148
  * `wait-for` client. No terminal input or captured output crosses this boundary.
125
149
  */
126
150
  export class TmuxExternalInteractionObserver {
@@ -136,7 +160,10 @@ export class TmuxExternalInteractionObserver {
136
160
  #loop = null;
137
161
  #starting = null;
138
162
  #hookHealthcheck = null;
139
- #drainSequence = 0;
163
+ #healthcheckSchedule;
164
+ #healthcheckDelayMs;
165
+ #lastHealthcheckOutcome = "failed";
166
+ #onGap;
140
167
  #tmuxWork = Promise.resolve();
141
168
  #reconcile = null;
142
169
  #diagnostics;
@@ -144,8 +171,14 @@ export class TmuxExternalInteractionObserver {
144
171
  #authenticatedInternalReads;
145
172
  constructor(options) {
146
173
  this.#daemonInstanceId = options.daemonInstanceId;
174
+ this.#healthcheckSchedule = Object.freeze({
175
+ baseMs: options.healthcheck?.baseMs ?? DEFAULT_HOOK_HEALTHCHECK_SCHEDULE.baseMs,
176
+ maxMs: options.healthcheck?.maxMs ?? DEFAULT_HOOK_HEALTHCHECK_SCHEDULE.maxMs,
177
+ });
178
+ this.#healthcheckDelayMs = this.#healthcheckSchedule.baseMs;
147
179
  this.#registry = options.registry ?? getDefaultWorkspaceRegistry();
148
180
  this.#onObserved = options.onObserved;
181
+ this.#onGap = options.onGap;
149
182
  this.#authenticatedInternalReads = new AuthenticatedInternalReadVerifier({
150
183
  daemonInstanceId: options.daemonInstanceId,
151
184
  ownerToken: options.internalReadOwnerToken,
@@ -195,7 +228,7 @@ export class TmuxExternalInteractionObserver {
195
228
  catch (error) {
196
229
  await this.#serializeTmux(async () => {
197
230
  await this.#removeOwnedHooks();
198
- await this.#deleteBuffer(this.#bufferName);
231
+ await this.#deleteRetention();
199
232
  });
200
233
  if (!isTmuxServerUnavailable(error)) {
201
234
  this.#active = false;
@@ -210,31 +243,76 @@ export class TmuxExternalInteractionObserver {
210
243
  if (!this.#active || this.#abort.signal.aborted) {
211
244
  await this.#serializeTmux(async () => {
212
245
  await this.#removeOwnedHooks();
213
- await this.#deleteBuffer(this.#bufferName);
246
+ await this.#deleteRetention();
214
247
  });
215
248
  throw new Error("tmux external interaction observer was disposed during startup");
216
249
  }
217
250
  this.#loop = this.#run();
218
- this.#hookHealthcheck = setInterval(() => void this.reconcileHooks(), HOOK_HEALTHCHECK_MS);
219
- this.#hookHealthcheck.unref?.();
251
+ this.#scheduleHealthcheck();
252
+ }
253
+ /** Wait before the next scheduled hook health check. Exposed for tests. */
254
+ get healthcheckDelayMs() {
255
+ return this.#healthcheckDelayMs;
256
+ }
257
+ #scheduleHealthcheck() {
258
+ if (this.#hookHealthcheck)
259
+ clearTimeout(this.#hookHealthcheck);
260
+ this.#hookHealthcheck = null;
261
+ if (!this.#active || this.#abort.signal.aborted)
262
+ return;
263
+ const timer = setTimeout(() => {
264
+ if (this.#hookHealthcheck === timer)
265
+ this.#hookHealthcheck = null;
266
+ void this.#runScheduledHealthcheck();
267
+ }, this.#healthcheckDelayMs);
268
+ timer.unref?.();
269
+ this.#hookHealthcheck = timer;
270
+ }
271
+ async #runScheduledHealthcheck() {
272
+ const delayBefore = this.#healthcheckDelayMs;
273
+ let outcome;
274
+ try {
275
+ await this.reconcileHooks();
276
+ outcome = this.#lastHealthcheckOutcome;
277
+ }
278
+ catch {
279
+ if (!this.#abort.signal.aborted)
280
+ this.#reportGap("hook-repair-failed");
281
+ outcome = "failed";
282
+ }
283
+ // A signal-path reset that landed during this check wins over the outcome.
284
+ if (this.#healthcheckDelayMs === delayBefore) {
285
+ this.#healthcheckDelayMs = nextHookHealthcheckDelay(delayBefore, outcome, this.#healthcheckSchedule);
286
+ }
287
+ if (this.#hookHealthcheck === null)
288
+ this.#scheduleHealthcheck();
289
+ }
290
+ /** Something failed on the signal path: verify the hooks promptly again. */
291
+ #resetHealthcheckBackoff() {
292
+ const base = this.#healthcheckSchedule.baseMs;
293
+ if (this.#healthcheckDelayMs === base)
294
+ return;
295
+ this.#healthcheckDelayMs = base;
296
+ if (this.#hookHealthcheck)
297
+ this.#scheduleHealthcheck();
220
298
  }
221
299
  setDiagnostics(diagnostics) {
222
300
  this.#diagnostics = diagnostics;
223
301
  }
224
302
  async dispose() {
225
- if (!this.#active && !this.#loop && !this.#starting)
303
+ if (!this.#active && !this.#loop && !this.#starting && !this.#installed)
226
304
  return;
227
305
  const starting = this.#starting;
228
306
  this.#active = false;
229
307
  this.#abort.abort();
230
308
  if (this.#hookHealthcheck)
231
- clearInterval(this.#hookHealthcheck);
309
+ clearTimeout(this.#hookHealthcheck);
232
310
  this.#hookHealthcheck = null;
233
311
  await Promise.allSettled([starting, this.#loop]);
234
312
  this.#loop = null;
235
313
  await this.#serializeTmux(async () => {
236
314
  await this.#removeOwnedHooks();
237
- await this.#deleteBuffer(this.#bufferName);
315
+ await this.#deleteRetention();
238
316
  });
239
317
  }
240
318
  /** Install the hook once. Public for hermetic lifecycle tests. */
@@ -245,15 +323,26 @@ export class TmuxExternalInteractionObserver {
245
323
  await this.#removeOwnedHooks(signal);
246
324
  await this.#deleteOwnedBuffers(signal);
247
325
  signal?.throwIfAborted();
326
+ // The small named buffer indexes retention options for crash cleanup;
327
+ // event data lives only in bounded options, never in this buffer.
328
+ await this.#io.runTmux(["set-buffer", "-b", this.#bufferName, "retention-owner"], signal);
248
329
  const hook = (operationKind, markerOption, consumeMarker) => {
249
- const data = `#{pane_id}${FIELD_SEPARATOR}#{q:${markerOption}}${FIELD_SEPARATOR}${operationKind}${EVENT_SEPARATOR}`;
330
+ // Reject arbitrary option text before expansion into a tmux command.
331
+ // Bound markers at the producer, including across multibyte input.
332
+ const validMarker = `#{&&:#{m/r:^[A-Za-z0-9:._-]*$,#{${markerOption}}},#{e|<=:#{n:${markerOption}},160}}`;
333
+ const marker = `#{?${validMarker},#{${markerOption}},}`;
334
+ const data = `#{pane_id}${FIELD_SEPARATOR}${marker}${FIELD_SEPARATOR}${operationKind}${EVENT_SEPARATOR}`;
250
335
  // Expand pane/marker identity at hook invocation, then schedule the
251
336
  // append+signal as a background tmux-native command list. No shell and
252
337
  // no second tmux client sit on the invoking command queue. The tiny
253
338
  // synchronous native cleanup runs only after the record string has been
254
339
  // captured, so a marker is single-use without racing the async drain.
255
- const publish = `run-shell -b -C "set-buffer -a -b '${this.#bufferName}' '${data}'` +
256
- ` ; wait-for -S '${this.#signalChannel}'"`;
340
+ // Escape only the retention format for evaluation in the queued native
341
+ // command; capture pane and marker immediately, before consuming it.
342
+ const append = boundedTmuxInteractionAppendCommand(this.#bufferName, "RECORD")
343
+ .replace("#{=", "##{=")
344
+ .replace("RECORD", data);
345
+ const publish = `run-shell -b -C "${append} ; wait-for -S '${this.#signalChannel}'"`;
257
346
  // Hook commands inherit the triggering pane as their target, so cleanup
258
347
  // needs neither format expansion nor a nested command queue.
259
348
  const consume = consumeMarker ? ` ; set-option -pu '${markerOption}'` : "";
@@ -278,7 +367,7 @@ export class TmuxExternalInteractionObserver {
278
367
  /**
279
368
  * Restore product hooks when external tmux configuration removed them.
280
369
  * Public only so the lifecycle is hermetically testable; the production
281
- * observer invokes it from a cheap one-second health check.
370
+ * observer invokes it from the backed-off health check.
282
371
  */
283
372
  reconcileHooks(options = {}) {
284
373
  if (!this.#active && options.allowInactive !== true)
@@ -289,9 +378,14 @@ export class TmuxExternalInteractionObserver {
289
378
  const finish = this.#beginDiagnostic("healthcheck");
290
379
  try {
291
380
  if (await this.#ownedHooksPresent(this.#abort.signal)) {
381
+ this.#lastHealthcheckOutcome = "healthy";
292
382
  finish(true);
293
383
  return;
294
384
  }
385
+ // Hooks that were installed and are now gone dropped every interaction
386
+ // since their removal; the repair restores future observation only.
387
+ if (this.#installed)
388
+ this.#reportGap("hooks-replaced");
295
389
  this.#installed = false;
296
390
  try {
297
391
  await this.#install(this.#abort.signal);
@@ -299,6 +393,7 @@ export class TmuxExternalInteractionObserver {
299
393
  catch {
300
394
  this.#installed = false;
301
395
  }
396
+ this.#lastHealthcheckOutcome = this.#installed ? "repaired" : "failed";
302
397
  finish(this.#installed);
303
398
  }
304
399
  catch (error) {
@@ -313,31 +408,49 @@ export class TmuxExternalInteractionObserver {
313
408
  this.#reconcile = settled;
314
409
  return settled;
315
410
  }
316
- /** Atomically detach and drain the current event buffer. */
411
+ /** Atomically detach and drain the current bounded event batch. */
317
412
  drain() {
318
413
  return this.#serializeTmux(() => this.#drain());
319
414
  }
320
415
  async #drain() {
321
416
  const finish = this.#beginDiagnostic("drain");
322
- const drainName = `${this.#bufferName}-drain-${++this.#drainSequence}`;
417
+ const option = tmuxInteractionOption(this.#bufferName);
418
+ // A single reusable detached slot bounds retained storage even if deletion
419
+ // fails. Native synchronous commands execute consecutively in one queue.
420
+ const drainName = `${option}-drain`;
323
421
  try {
324
- await this.#io.runTmux(["set-buffer", "-b", this.#bufferName, "-n", drainName], this.#abort.signal);
422
+ await this.#io.runTmux(["set-option", "-gF", drainName, `#{${option}}`, ";", "set-option", "-g", option, ""], this.#abort.signal);
325
423
  }
326
424
  catch {
425
+ this.#reportGap("detach-failed");
327
426
  finish(false);
328
427
  return false;
329
428
  }
330
429
  let raw;
331
- try {
332
- raw = await this.#io.runTmux(["show-buffer", "-b", drainName], this.#abort.signal);
430
+ // Retry the same immutable detached batch once. Never accumulate an
431
+ // unbounded collection of failed batches or spin until a server recovers.
432
+ for (let attempt = 0; attempt < 2 && !this.#abort.signal.aborted; attempt += 1) {
433
+ try {
434
+ raw = await this.#io.runTmux(["show-options", "-gv", drainName], this.#abort.signal);
435
+ break;
436
+ }
437
+ catch {
438
+ // Exhaustion is surfaced below, without terminal content or errors.
439
+ }
333
440
  }
334
- catch {
441
+ await this.#deleteOption(drainName);
442
+ if (raw === undefined) {
443
+ this.#reportGap("read-failed");
335
444
  finish(false);
336
445
  return false;
337
446
  }
338
- finally {
339
- await this.#deleteBuffer(drainName);
447
+ if (Buffer.byteLength(raw, "utf8") > TMUX_INTERACTION_MAX_DRAIN_BYTES) {
448
+ this.#reportGap("overflow");
449
+ finish(false);
450
+ return false;
340
451
  }
452
+ if (raw.includes(TMUX_INTERACTION_GAP_RECORD))
453
+ this.#reportGap("overflow");
341
454
  let consumed = false;
342
455
  try {
343
456
  for (const record of parseTmuxInputHookRecords(raw)) {
@@ -345,6 +458,7 @@ export class TmuxExternalInteractionObserver {
345
458
  }
346
459
  }
347
460
  catch (error) {
461
+ this.#reportGap("projection-failed");
348
462
  finish(false);
349
463
  throw error;
350
464
  }
@@ -365,6 +479,7 @@ export class TmuxExternalInteractionObserver {
365
479
  }
366
480
  catch {
367
481
  this.#installed = false;
482
+ this.#resetHealthcheckBackoff();
368
483
  await this.#io.delay(RETRY_MS, this.#abort.signal);
369
484
  }
370
485
  }
@@ -430,18 +545,46 @@ export class TmuxExternalInteractionObserver {
430
545
  this.#installed = false;
431
546
  }
432
547
  async #ownedHooksPresent(signal) {
433
- for (const hookName of ["after-send-keys", "after-capture-pane"]) {
434
- let output;
435
- try {
436
- output = await this.#io.runTmux(["show-hooks", "-g", hookName], signal);
437
- }
438
- catch {
439
- return false;
440
- }
441
- if (!output.includes(this.#bufferName))
442
- return false;
548
+ // One client verifies both hook arrays: tmux runs the `;`-separated list in
549
+ // a single command queue and prints each array's lines in order.
550
+ let output;
551
+ try {
552
+ output = await this.#io.runTmux(["show-hooks", "-g", "after-send-keys", ";", "show-hooks", "-g", "after-capture-pane"], signal);
553
+ }
554
+ catch {
555
+ return false;
556
+ }
557
+ return (ownedHookInstalled(output, "after-send-keys", this.#bufferName) &&
558
+ ownedHookInstalled(output, "after-capture-pane", this.#bufferName));
559
+ }
560
+ #reportGap(reason) {
561
+ if (this.#abort.signal.aborted)
562
+ return;
563
+ const gap = { reason, recovery: "future-observations-only" };
564
+ logger.warn("tmux-interaction-observer", "Interaction observation gap; missing history cannot be replayed", {
565
+ daemonInstanceId: this.#daemonInstanceId,
566
+ ...gap,
567
+ });
568
+ try {
569
+ this.#onGap?.(gap);
570
+ }
571
+ catch {
572
+ /* Reporting cannot create operation authority. */
573
+ }
574
+ }
575
+ async #deleteOption(name, signal) {
576
+ try {
577
+ await this.#io.runTmux(["set-option", "-gu", name], signal);
578
+ }
579
+ catch {
580
+ /* Best effort. */
443
581
  }
444
- return true;
582
+ }
583
+ async #deleteRetention() {
584
+ const option = tmuxInteractionOption(this.#bufferName);
585
+ await this.#deleteOption(option);
586
+ await this.#deleteOption(`${option}-drain`);
587
+ await this.#deleteBuffer(this.#bufferName);
445
588
  }
446
589
  async #deleteOwnedBuffers(signal) {
447
590
  let output;
@@ -452,8 +595,13 @@ export class TmuxExternalInteractionObserver {
452
595
  return;
453
596
  }
454
597
  for (const name of output.split("\n")) {
455
- if (name.startsWith(OWNED_HOOK_MARKER))
456
- await this.#deleteBuffer(name, signal);
598
+ if (!name.startsWith(OWNED_HOOK_MARKER) || name === this.#bufferName)
599
+ continue;
600
+ if (/^[A-Za-z0-9._-]{1,256}$/u.test(name)) {
601
+ await this.#deleteOption(tmuxInteractionOption(name), signal);
602
+ await this.#deleteOption(`${tmuxInteractionOption(name)}-drain`, signal);
603
+ }
604
+ await this.#deleteBuffer(name, signal);
457
605
  }
458
606
  }
459
607
  async #deleteBuffer(name, signal) {
@@ -0,0 +1,21 @@
1
+ const EVENT_SEPARATOR = "|tmux-ide-input-event-v1|";
2
+ // ASCII metadata only. The native producer retains a prefix plus the newest
3
+ // record; truncation inserts a framed gap, never an invented interaction.
4
+ // Keep this within tmux's supported format-width range: larger widths can
5
+ // silently disable truncation. Live tests cover tmux 3.4 and 3.7c.
6
+ export const TMUX_INTERACTION_RETAINED_CHARS = 8_192;
7
+ export const TMUX_INTERACTION_GAP_RECORD = `${EVENT_SEPARATOR}gap${EVENT_SEPARATOR}`;
8
+ export const TMUX_INTERACTION_MAX_DRAIN_BYTES = TMUX_INTERACTION_RETAINED_CHARS + TMUX_INTERACTION_GAP_RECORD.length + 1_024;
9
+ export function tmuxInteractionOption(bufferName) {
10
+ if (!/^[A-Za-z0-9._-]{1,256}$/u.test(bufferName))
11
+ throw new TypeError("Invalid observer name");
12
+ return `@${bufferName}`;
13
+ }
14
+ /** One native command, also used by atomic NOHOOKS recovery (fixed ordinals). */
15
+ export function boundedTmuxInteractionAppendCommand(bufferName, record) {
16
+ const option = tmuxInteractionOption(bufferName);
17
+ if (!/^[A-Za-z0-9%:._|-]{1,1024}$/u.test(record)) {
18
+ throw new TypeError("Invalid observer metadata");
19
+ }
20
+ return `set-option -gF '${option}' '#{=/${TMUX_INTERACTION_RETAINED_CHARS}/${TMUX_INTERACTION_GAP_RECORD}:${option}}${record}'`;
21
+ }
@@ -1,8 +1,8 @@
1
1
  import { createHash } from "node:crypto";
2
- import { realpathSync, statSync } from "node:fs";
2
+ import { realpath, stat } from "node:fs/promises";
3
3
  import { WorkspacePromoteMutationRequestSchemaZ, WorkspacePromoteMutationResultSchemaZ, } from "@tmux-ide/contracts";
4
- import { TmuxError } from "@tmux-ide/tmux-bridge";
5
- import { createPinnedWorkspaceTmuxRunner, resolveWorkspacePaneTmuxAuthority, } from "./workspace-pane-creation.js";
4
+ import { classifyTmuxError, TmuxError } from "@tmux-ide/tmux-bridge";
5
+ import { createPinnedWorkspaceTmuxAsyncRunner, resolveWorkspacePaneTmuxAuthority, } from "./workspace-pane-creation.js";
6
6
  import { getDefaultWorkspaceRegistry, WorkspaceAlreadyExistsError, } from "./workspace-registry.js";
7
7
  import { analyzeTrustedSemanticPaneCatalog } from "../terminal/attachments/semantic-pane-catalog.js";
8
8
  import { fleetSessionIdForName } from "../command-center/resources/fleet-catalog.js";
@@ -253,11 +253,23 @@ function resource(workspaceName) {
253
253
  function requestFingerprint(request) {
254
254
  return JSON.stringify(request);
255
255
  }
256
+ /**
257
+ * The daemon's pinned async runner keeps every promotion round-trip off the
258
+ * event loop; it surfaces raw child-process failures, so classify them here
259
+ * into the shared `TmuxError` codes the io predicates below inspect.
260
+ */
261
+ function classifiedAsyncRunner(tmuxAuthority) {
262
+ const run = createPinnedWorkspaceTmuxAsyncRunner(tmuxAuthority ?? resolveWorkspacePaneTmuxAuthority());
263
+ return (args) => run(args).catch((error) => {
264
+ throw error instanceof TmuxError ? error : classifyTmuxError(error);
265
+ });
266
+ }
256
267
  const DEFAULT_IO = {
257
- canonicalProjectDir: (path) => {
258
- const canonical = realpathSync(path);
259
- if (!statSync(canonical).isDirectory())
268
+ canonicalProjectDir: async (path) => {
269
+ const canonical = await realpath(path);
270
+ if (!(await stat(canonical)).isDirectory()) {
260
271
  throw new Error("project root is not a directory");
272
+ }
261
273
  return canonical;
262
274
  },
263
275
  isMissingTmuxTarget: (error) => error instanceof TmuxError && error.code === "SESSION_NOT_FOUND",
@@ -295,8 +307,7 @@ export class WorkspacePromotionAuthority {
295
307
  this.#io = {
296
308
  ...DEFAULT_IO,
297
309
  ...options.io,
298
- runTmux: options.io?.runTmux ??
299
- createPinnedWorkspaceTmuxRunner(options.tmuxAuthority ?? resolveWorkspacePaneTmuxAuthority()),
310
+ runTmux: options.io?.runTmux ?? classifiedAsyncRunner(options.tmuxAuthority),
300
311
  };
301
312
  this.#maxReplayOperations = boundedAuthorityLimit(options.maxOperations, MAX_OPERATIONS);
302
313
  this.#maxPendingOperations = boundedAuthorityLimit(options.maxPendingOperations, MAX_OPERATIONS);
@@ -347,7 +358,7 @@ export class WorkspacePromotionAuthority {
347
358
  if (existing)
348
359
  return this.#replay(existing, request, fingerprint);
349
360
  try {
350
- const session = this.#resolveSession(request.intent.sessionId);
361
+ const session = await this.#resolveSession(request.intent.sessionId);
351
362
  // Already a registry workspace — including an app-created (m32) session —
352
363
  // is idempotent, not an error. It is NOT automatically attachable though:
353
364
  // the registry entry is keyed by session NAME and outlives the tmux
@@ -365,20 +376,20 @@ export class WorkspacePromotionAuthority {
365
376
  workspaceName: alreadyRegistered.name,
366
377
  sessionName: session.sessionName,
367
378
  };
368
- this.#stampPaneInventory(request, session, registeredIdentity);
379
+ await this.#stampPaneInventory(request, session, registeredIdentity);
369
380
  this.#assertActive(request.operationId);
370
- this.#verifyPromotedInventory(session.sessionId, registeredIdentity);
371
- this.#publishFleetEnrollment(request, session, registeredIdentity);
381
+ await this.#verifyPromotedInventory(session.sessionId, registeredIdentity);
382
+ await this.#publishFleetEnrollment(request, session, registeredIdentity);
372
383
  return this.#succeed(request, fingerprint, alreadyRegistered.name, session.sessionName, {
373
384
  replayed: true,
374
385
  });
375
386
  }
376
387
  const identity = derivePromotionIdentity(session.sessionName);
377
388
  this.#assertConflictFreeIdentity(identity);
378
- const canonicalRoot = this.#stampSession(request, session, identity);
389
+ const canonicalRoot = await this.#stampSession(request, session, identity);
379
390
  this.#assertActive(request.operationId);
380
- this.#verifyPromotedInventory(session.sessionId, identity);
381
- this.#publishFleetEnrollment(request, session, identity);
391
+ await this.#verifyPromotedInventory(session.sessionId, identity);
392
+ await this.#publishFleetEnrollment(request, session, identity);
382
393
  let registered;
383
394
  try {
384
395
  registered = this.#registry.add({
@@ -416,8 +427,8 @@ export class WorkspacePromotionAuthority {
416
427
  return this.#rememberFailure(request, fingerprint, this.#mapFailure(error, request));
417
428
  }
418
429
  }
419
- #resolveSession(sessionId) {
420
- const records = this.#listSessions();
430
+ async #resolveSession(sessionId) {
431
+ const records = await this.#listSessions();
421
432
  const matches = records.filter((record) => fleetSessionIdForName(record.sessionName) === sessionId);
422
433
  if (matches.length !== 1) {
423
434
  throw new WorkspacePromotionError("session_not_found", { sessionId });
@@ -428,9 +439,9 @@ export class WorkspacePromotionAuthority {
428
439
  }
429
440
  return match;
430
441
  }
431
- #listSessions() {
442
+ async #listSessions() {
432
443
  try {
433
- return parseSessionRecords(this.#io.runTmux(["list-sessions", "-F", SESSION_FORMAT]));
444
+ return parseSessionRecords(await this.#io.runTmux(["list-sessions", "-F", SESSION_FORMAT]));
434
445
  }
435
446
  catch (error) {
436
447
  if (this.#io.isTmuxUnavailable(error))
@@ -454,11 +465,11 @@ export class WorkspacePromotionAuthority {
454
465
  * a newly published marker makes the soon-to-be registered workspace visible
455
466
  * to the shared FleetCatalog in the same mutation transaction.
456
467
  */
457
- #publishFleetEnrollment(request, session, identity) {
468
+ async #publishFleetEnrollment(request, session, identity) {
458
469
  if (session.adopted)
459
470
  return;
460
471
  try {
461
- this.#io.runTmux(["set-option", "-t", session.sessionId, ADOPTED_OPTION, "1"]);
472
+ await this.#io.runTmux(["set-option", "-t", session.sessionId, ADOPTED_OPTION, "1"]);
462
473
  }
463
474
  catch (error) {
464
475
  throw new WorkspacePromotionError("stamp_failed", { operationId: request.operationId, workspaceName: identity.workspaceName }, error);
@@ -470,21 +481,21 @@ export class WorkspacePromotionAuthority {
470
481
  * `set-option` failure maps to `stamp_failed`; the caller has not yet touched
471
482
  * the registry, so a failure here leaves the session harmless.
472
483
  */
473
- #stampSession(request, session, identity) {
474
- const scanned = this.#stampPaneInventory(request, session, identity);
484
+ async #stampSession(request, session, identity) {
485
+ const scanned = await this.#stampPaneInventory(request, session, identity);
475
486
  try {
476
487
  for (const [option, value] of [
477
488
  [SESSION_OPERATION_OPTION, request.operationId],
478
489
  [SESSION_WORKSPACE_OPTION, identity.workspaceName],
479
490
  [SESSION_PROMOTED_MARKER_OPTION, "1"],
480
491
  ]) {
481
- this.#io.runTmux(["set-option", "-t", session.sessionId, option, value]);
492
+ await this.#io.runTmux(["set-option", "-t", session.sessionId, option, value]);
482
493
  }
483
494
  }
484
495
  catch (error) {
485
496
  throw new WorkspacePromotionError("stamp_failed", { operationId: request.operationId, workspaceName: identity.workspaceName }, error);
486
497
  }
487
- return this.#resolveProjectDir(session, scanned);
498
+ return await this.#resolveProjectDir(session, scanned);
488
499
  }
489
500
  /**
490
501
  * Stamp every pane and window of the session — additive, never overwriting a
@@ -493,10 +504,17 @@ export class WorkspacePromotionAuthority {
493
504
  * session, which may be an m32-open workspace whose provenance must never
494
505
  * acquire the promotion marker.
495
506
  */
496
- #stampPaneInventory(request, session, identity) {
507
+ async #stampPaneInventory(request, session, identity) {
497
508
  let scanned;
498
509
  try {
499
- scanned = parseScanPanes(this.#io.runTmux(["list-panes", "-s", "-t", session.sessionId, "-F", PANE_SCAN_FORMAT]));
510
+ scanned = parseScanPanes(await this.#io.runTmux([
511
+ "list-panes",
512
+ "-s",
513
+ "-t",
514
+ session.sessionId,
515
+ "-F",
516
+ PANE_SCAN_FORMAT,
517
+ ]));
500
518
  }
501
519
  catch (error) {
502
520
  if (error instanceof WorkspacePromotionError)
@@ -519,7 +537,7 @@ export class WorkspacePromotionAuthority {
519
537
  for (const pane of scanned) {
520
538
  if (!hasValidPaneStamp(pane.semanticPaneId)) {
521
539
  const paneStamp = `pane.promoted.${digest(`${session.sessionName}\0${pane.paneId}`)}`;
522
- this.#io.runTmux([
540
+ await this.#io.runTmux([
523
541
  "set-option",
524
542
  "-p",
525
543
  "-t",
@@ -531,14 +549,14 @@ export class WorkspacePromotionAuthority {
531
549
  // Additive: only fill an empty `@ide_*`, never clobber existing intent.
532
550
  if (value === null)
533
551
  continue;
534
- this.#io.runTmux(["set-option", "-p", "-t", pane.paneId, option, value]);
552
+ await this.#io.runTmux(["set-option", "-p", "-t", pane.paneId, option, value]);
535
553
  }
536
554
  }
537
555
  if (!reconciledWindows.has(pane.windowId)) {
538
556
  reconciledWindows.add(pane.windowId);
539
557
  if (pane.semanticWindowId.length === 0) {
540
558
  const windowStamp = `window.promoted.${digest(`${session.sessionName}\0${pane.windowId}`)}`;
541
- this.#io.runTmux([
559
+ await this.#io.runTmux([
542
560
  "set-option",
543
561
  "-w",
544
562
  "-t",
@@ -549,7 +567,7 @@ export class WorkspacePromotionAuthority {
549
567
  // Initialize chrome only when adopting a previously unstamped
550
568
  // window. Reopening an existing workspace must preserve native
551
569
  // border choices and PTY dimensions, even while stamping new panes.
552
- this.#io.runTmux([
570
+ await this.#io.runTmux([
553
571
  "set-option",
554
572
  "-w",
555
573
  "-t",
@@ -557,7 +575,7 @@ export class WorkspacePromotionAuthority {
557
575
  "pane-border-status",
558
576
  "top",
559
577
  ]);
560
- this.#io.runTmux([
578
+ await this.#io.runTmux([
561
579
  "set-option",
562
580
  "-w",
563
581
  "-t",
@@ -584,7 +602,7 @@ export class WorkspacePromotionAuthority {
584
602
  * (b) then the active pane's cwd, then the remaining panes in scan order;
585
603
  * (c) only when NOTHING resolves does promotion fail.
586
604
  */
587
- #resolveProjectDir(session, scanned) {
605
+ async #resolveProjectDir(session, scanned) {
588
606
  const active = scanned.find((pane) => pane.active);
589
607
  const candidates = [
590
608
  session.sessionPath,
@@ -595,7 +613,7 @@ export class WorkspacePromotionAuthority {
595
613
  if (candidate.length === 0)
596
614
  continue;
597
615
  try {
598
- return this.#io.canonicalProjectDir(candidate);
616
+ return await this.#io.canonicalProjectDir(candidate);
599
617
  }
600
618
  catch {
601
619
  // A dead or non-directory cwd (a pruned worktree) is expected; try the
@@ -648,12 +666,12 @@ export class WorkspacePromotionAuthority {
648
666
  * initial-Terminal-window assertion — a promoted session may have any number
649
667
  * of windows and multi-pane windows.
650
668
  */
651
- #verifyPromotedInventory(sessionId, identity) {
669
+ async #verifyPromotedInventory(sessionId, identity) {
652
670
  let panes;
653
671
  try {
654
672
  const args = ["list-panes", "-s", "-t", sessionId, "-F", PANE_VERIFY_FORMAT];
655
- const before = boundedTmuxOutput(this.#io.runTmux(args));
656
- const after = boundedTmuxOutput(this.#io.runTmux(args));
673
+ const before = boundedTmuxOutput(await this.#io.runTmux(args));
674
+ const after = boundedTmuxOutput(await this.#io.runTmux(args));
657
675
  if (before !== after) {
658
676
  throw new WorkspacePromotionError("promotion_verification_failed", {
659
677
  reason: "inventory_changed_during_proof",
@@ -1,3 +1,4 @@
1
+ import { boundedTmuxInteractionAppendCommand } from "../../lib/tmux-interaction-retention.js";
1
2
  import { decodeNativeGridCapture, isNativeBootstrapCapture, } from "./native-grid-capture.js";
2
3
  /**
3
4
  * SessionChannel — one control-mode channel serving every pane subscription
@@ -1422,7 +1423,7 @@ export class SessionChannel {
1422
1423
  return;
1423
1424
  }
1424
1425
  const sentinel = (kind) => `display-message -p -l -t ${pane.runtimeId} ` + `"%tmux-ide-atomic-v1 ${nonce} ${kind}"`;
1425
- const observerCommands = ` ; set-buffer -a -b ${observer.bufferName} ${tmuxSingleQuote(observer.record)}` +
1426
+ const observerCommands = ` ; ${boundedTmuxInteractionAppendCommand(observer.bufferName, observer.record)}` +
1426
1427
  ` ; wait-for -S ${observer.signalChannel}`;
1427
1428
  const body = `set-option -po -t ${pane.runtimeId} ${INTERNAL_READ_OPERATION_OPTION} ${internalReadMarker}` +
1428
1429
  ` ; ${sentinel("start")}` +