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
|
@@ -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-
|
|
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
|
-
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
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
|
-
|
|
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
|
|
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
|
-
#
|
|
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.#
|
|
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.#
|
|
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.#
|
|
219
|
-
|
|
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
|
-
|
|
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.#
|
|
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
|
-
|
|
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
|
-
|
|
256
|
-
|
|
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
|
|
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
|
|
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
|
|
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-
|
|
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
|
-
|
|
332
|
-
|
|
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
|
-
|
|
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
|
-
|
|
339
|
-
|
|
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
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 {
|
|
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 {
|
|
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 =
|
|
259
|
-
if (!
|
|
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([
|
|
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 = ` ;
|
|
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")}` +
|