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.
- package/bin/cli.js +1979 -783
- package/package.json +4 -2
- package/packages/contracts/src/__tests__/daemon-wire.test.ts +32 -0
- package/packages/contracts/src/daemon-events.ts +12 -12
- package/packages/contracts/src/daemon-wire.ts +30 -0
- package/packages/daemon/dist/command-center/agent-status-watch.js +9 -2
- package/packages/daemon/dist/command-center/diagnostics.js +65 -0
- package/packages/daemon/dist/command-center/log-stream.js +9 -0
- package/packages/daemon/dist/command-center/resources/fleet-preview-route.js +22 -6
- package/packages/daemon/dist/command-center/server.js +7 -0
- package/packages/daemon/dist/doctor.js +62 -0
- package/packages/daemon/dist/lib/__tests__/installed-recovery-fixture.js +400 -0
- package/packages/daemon/dist/lib/app-config.js +4 -2
- package/packages/daemon/dist/lib/canonical-daemon.js +1 -0
- package/packages/daemon/dist/lib/daemon-embed.js +24 -0
- package/packages/daemon/dist/lib/daemon-provenance.js +575 -0
- package/packages/daemon/dist/lib/headless-daemon.js +1 -0
- package/packages/daemon/dist/lib/log-sanitize.js +199 -0
- package/packages/daemon/dist/lib/log.js +123 -13
- package/packages/daemon/dist/lib/soak-diagnostics.js +124 -0
- package/packages/daemon/dist/lib/soak-verdict.js +472 -0
- package/packages/daemon/dist/lib/terminal-host-color.js +33 -0
- package/packages/daemon/dist/lib/tmux-external-interaction-observer.js +187 -39
- package/packages/daemon/dist/lib/tmux-interaction-retention.js +21 -0
- package/packages/daemon/dist/lib/workspace-promotion.js +55 -37
- package/packages/daemon/dist/terminal/mirror/session-channel.js +2 -1
- package/packages/daemon/dist/tui/mirror/automatic-contrast.js +161 -0
- package/packages/daemon/dist/tui/mirror/open-tui-workspace-runtime-port.js +9 -3
- package/packages/daemon/dist/tui/mirror/pane-surface.jsx +7 -5
- package/packages/daemon/dist/tui/mirror/resize-transaction.js +48 -20
- package/packages/daemon/dist/tui/mirror/runtime/application-appearance-owner.js +40 -6
- package/packages/daemon/dist/tui/mirror/runtime/application-fleet-preview.js +4 -1
- package/packages/daemon/dist/tui/mirror/runtime/application-machine-sidebar.jsx +63 -42
- package/packages/daemon/dist/tui/mirror/runtime/application-terminal-interaction-controller.js +138 -67
- package/packages/daemon/dist/tui/mirror/runtime/application-terminal-palette-owner.js +44 -28
- package/packages/daemon/dist/tui/mirror/runtime/application-terminal-workspace.jsx +49 -11
- package/packages/daemon/dist/tui/mirror/runtime/semantic-shell-viewport-resize.js +140 -2
- package/packages/daemon/dist/tui/mirror/runtime/workspace-terminal-fast-lane.js +3 -1
- package/packages/daemon/dist/tui/mirror/semantic-pane-render-source.js +2 -1
- package/packages/daemon/dist/tui/mirror/theme.js +4 -31
- package/packages/daemon/dist/tui/mirror/workspace/terminal-pane-header.jsx +15 -8
- package/packages/daemon/dist/tui/team/wait-receipts.js +112 -33
- package/packages/daemon/src/command-center/agent-status-watch.ts +8 -2
- package/packages/daemon/src/command-center/diagnostics.ts +75 -0
- package/packages/daemon/src/command-center/log-stream.ts +8 -0
- package/packages/daemon/src/command-center/resources/fleet-preview-route.ts +27 -5
- package/packages/daemon/src/command-center/server.ts +8 -0
- package/packages/daemon/src/doctor.ts +70 -0
- package/packages/daemon/src/lib/app-config.ts +11 -3
- package/packages/daemon/src/lib/canonical-daemon.ts +1 -0
- package/packages/daemon/src/lib/daemon-embed.ts +30 -0
- package/packages/daemon/src/lib/daemon-provenance.ts +769 -0
- package/packages/daemon/src/lib/fleet-preview-model.ts +1 -0
- package/packages/daemon/src/lib/headless-daemon.ts +1 -0
- package/packages/daemon/src/lib/log-sanitize.ts +248 -0
- package/packages/daemon/src/lib/log.ts +165 -14
- package/packages/daemon/src/lib/soak-diagnostics.ts +183 -0
- package/packages/daemon/src/lib/soak-verdict.ts +785 -0
- package/packages/daemon/src/lib/terminal-host-color.ts +43 -0
- package/packages/daemon/src/lib/tmux-external-interaction-observer.ts +233 -37
- package/packages/daemon/src/lib/tmux-interaction-retention.ts +24 -0
- package/packages/daemon/src/lib/workspace-promotion.ts +74 -45
- package/packages/daemon/src/terminal/mirror/session-channel.ts +2 -1
- package/packages/daemon/src/tui/mirror/automatic-contrast.ts +180 -0
- package/packages/daemon/src/tui/mirror/open-tui-workspace-runtime-port.ts +26 -5
- package/packages/daemon/src/tui/mirror/pane-surface.tsx +9 -8
- package/packages/daemon/src/tui/mirror/resize-transaction.ts +46 -22
- package/packages/daemon/src/tui/mirror/runtime/application-appearance-owner.ts +45 -6
- package/packages/daemon/src/tui/mirror/runtime/application-fleet-preview.ts +4 -0
- package/packages/daemon/src/tui/mirror/runtime/application-fleet-switcher.tsx +2 -1
- package/packages/daemon/src/tui/mirror/runtime/application-machine-sidebar.tsx +99 -75
- package/packages/daemon/src/tui/mirror/runtime/application-palette-preview.tsx +13 -3
- package/packages/daemon/src/tui/mirror/runtime/application-reference-sheet.tsx +125 -0
- package/packages/daemon/src/tui/mirror/runtime/application-root-v2.tsx +2 -1
- package/packages/daemon/src/tui/mirror/runtime/application-shell-overlays.tsx +199 -157
- package/packages/daemon/src/tui/mirror/runtime/application-shell-view.tsx +2 -0
- package/packages/daemon/src/tui/mirror/runtime/application-terminal-interaction-controller.ts +148 -69
- package/packages/daemon/src/tui/mirror/runtime/application-terminal-palette-owner.ts +55 -28
- package/packages/daemon/src/tui/mirror/runtime/application-terminal-workspace.tsx +59 -17
- package/packages/daemon/src/tui/mirror/runtime/semantic-shell-viewport-resize.ts +151 -3
- package/packages/daemon/src/tui/mirror/runtime/workspace-terminal-fast-lane.ts +3 -1
- package/packages/daemon/src/tui/mirror/semantic-pane-render-source.ts +2 -1
- package/packages/daemon/src/tui/mirror/theme.ts +4 -39
- package/packages/daemon/src/tui/mirror/workspace/terminal-pane-header.tsx +21 -8
- package/packages/daemon/src/tui/team/wait-receipts.ts +115 -40
- package/packages/daemon-client/src/terminal-fast-lane.test.ts +18 -0
- package/packages/daemon-client/src/terminal-fast-lane.ts +7 -2
- package/packages/tmux-bridge/src/index.ts +2 -0
- package/packages/tmux-bridge/src/runner.test.ts +41 -0
- 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-
|
|
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
|
-
|
|
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
|
|
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
|
|
204
|
-
#
|
|
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.#
|
|
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.#
|
|
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.#
|
|
298
|
-
|
|
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)
|
|
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.#
|
|
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
|
-
|
|
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
|
-
|
|
341
|
-
|
|
342
|
-
|
|
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
|
|
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
|
|
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
|
|
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-
|
|
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
|
-
|
|
424
|
-
|
|
425
|
-
|
|
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
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
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
|
-
|
|
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)
|
|
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
|
+
}
|