tmux-ide 2.9.9 → 2.9.11
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 +173 -61
- package/package.json +1 -1
- package/packages/contracts/src/pane-stream.ts +12 -0
- package/packages/core/src/terminal-replica-hash-cache.ts +8 -1
- package/packages/daemon/dist/native/TmuxIdeNotifier.app/Contents/Info.plist +2 -2
- package/packages/daemon/dist/native/TmuxIdeNotifier.app/Contents/MacOS/tmux-ide-notifier +0 -0
- package/packages/daemon/dist/native/TmuxIdeNotifier.app/Contents/Resources/Assets.car +0 -0
- package/packages/daemon/dist/native/TmuxIdeNotifier.app/Contents/_CodeSignature/CodeResources +2 -2
- package/packages/daemon/dist/terminal/mirror/mirror-service.js +12 -2
- package/packages/daemon/dist/terminal/mirror/session-channel.js +46 -23
- package/packages/daemon/dist/terminal/pane-stream/pane-stream-websocket.js +15 -4
- package/packages/daemon/dist/terminal/session-runtime/registry.js +17 -6
- package/packages/daemon/dist/terminal/session-runtime/terminal-replica-interpreter.js +95 -36
- package/packages/daemon/dist/terminal/session-runtime/terminal-replica-owner.js +6 -0
- package/packages/daemon/dist/terminal/session-runtime/transport-binding.js +2 -2
- package/packages/daemon/dist/tui/mirror/runtime/application-generation-starter.js +16 -1
- package/packages/daemon/dist/tui/mirror/runtime/application-machine-agent-navigation.js +13 -0
- package/packages/daemon/dist/tui/mirror/runtime/application-machine-navigation.js +12 -4
- package/packages/daemon/dist/tui/mirror/runtime/application-retained-agent.js +18 -0
- package/packages/daemon/dist/tui/mirror/runtime/application-terminal-interaction-controller.js +62 -2
- package/packages/daemon/dist/tui/mirror/runtime/open-tui-generation-host.js +2 -2
- package/packages/daemon/dist/tui/mirror/runtime/runtime-layout-presentation.js +6 -3
- package/packages/daemon/dist/tui/mirror/runtime/select-terminal-pane.js +7 -2
- package/packages/daemon/dist/tui/mirror/runtime/terminal-selection-waiter.js +36 -0
- package/packages/daemon/src/terminal/mirror/mirror-service.ts +19 -2
- package/packages/daemon/src/terminal/mirror/session-channel.ts +57 -9
- package/packages/daemon/src/terminal/pane-stream/pane-stream-websocket.ts +29 -1
- package/packages/daemon/src/terminal/session-runtime/registry.ts +23 -3
- package/packages/daemon/src/terminal/session-runtime/terminal-replica-interpreter.ts +102 -44
- package/packages/daemon/src/terminal/session-runtime/terminal-replica-owner.ts +7 -0
- package/packages/daemon/src/terminal/session-runtime/transport-binding.ts +3 -0
- package/packages/daemon/src/tui/mirror/open-tui-workspace-runtime-port.ts +2 -0
- package/packages/daemon/src/tui/mirror/runtime/application-generation-starter.ts +17 -1
- package/packages/daemon/src/tui/mirror/runtime/application-machine-agent-navigation.ts +15 -0
- package/packages/daemon/src/tui/mirror/runtime/application-machine-navigation.ts +18 -4
- package/packages/daemon/src/tui/mirror/runtime/application-retained-agent.ts +29 -0
- package/packages/daemon/src/tui/mirror/runtime/application-terminal-interaction-controller.ts +63 -2
- package/packages/daemon/src/tui/mirror/runtime/open-tui-generation-host.ts +2 -2
- package/packages/daemon/src/tui/mirror/runtime/runtime-layout-presentation.ts +7 -4
- package/packages/daemon/src/tui/mirror/runtime/select-terminal-pane.ts +9 -1
- package/packages/daemon/src/tui/mirror/runtime/terminal-selection-waiter.ts +36 -0
- package/packages/daemon-client/src/pane-stream-client.ts +17 -0
package/packages/daemon/dist/tui/mirror/runtime/application-terminal-interaction-controller.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { createTerminalSelectionWaiter } from "./terminal-selection-waiter.js";
|
|
1
2
|
import { WorkspaceWindowLinkSelectResultSchemaZ } from "@tmux-ide/contracts";
|
|
2
3
|
import { windowLinkForPane, windowLinkTarget } from "./application-terminal-workspace-policy.js";
|
|
3
4
|
import { createHash, randomUUID } from "node:crypto";
|
|
@@ -117,6 +118,10 @@ export function createApplicationTerminalInteractionController(options) {
|
|
|
117
118
|
left.generation === right.generation &&
|
|
118
119
|
left.workspace === right.workspace;
|
|
119
120
|
let selectedControl = null;
|
|
121
|
+
const selectionWaiter = createTerminalSelectionWaiter();
|
|
122
|
+
let observedLayoutEpoch = null;
|
|
123
|
+
let selectionGeneration = null;
|
|
124
|
+
let selectionLayout = options.layout();
|
|
120
125
|
const waitingInput = new Set();
|
|
121
126
|
let waitingInputBytes = 0;
|
|
122
127
|
let releasingInput = null;
|
|
@@ -522,8 +527,28 @@ export function createApplicationTerminalInteractionController(options) {
|
|
|
522
527
|
const paneInput = new TerminalPaneInputRouter({
|
|
523
528
|
select: async (paneId) => {
|
|
524
529
|
finishLinkSelection(false);
|
|
530
|
+
const initial = options.generation();
|
|
531
|
+
const selectionControl = controlIdentity(initial);
|
|
532
|
+
const selectionVersion = paneInput.selectionVersion;
|
|
533
|
+
const selectionCurrent = () => paneInput.selectionVersion === selectionVersion &&
|
|
534
|
+
sameControl(selectionControl, controlIdentity(selectionGeneration));
|
|
535
|
+
const panePresent = () => selectionLayout.windows.some((window) => window.panes.some((pane) => pane.pane === paneId));
|
|
536
|
+
if (initial?.status === "rebinding") {
|
|
537
|
+
// Reserve the optimistic target now, but send its FIRST mutation only
|
|
538
|
+
// after this same control connection has a coherent terminal runtime.
|
|
539
|
+
const ready = await selectionWaiter.wait(() => {
|
|
540
|
+
if (!selectionCurrent())
|
|
541
|
+
return false;
|
|
542
|
+
const active = selectionGeneration;
|
|
543
|
+
if (active?.status !== "live" || observedLayoutEpoch !== active.rendererEpoch)
|
|
544
|
+
return "pending";
|
|
545
|
+
return panePresent();
|
|
546
|
+
});
|
|
547
|
+
if (!ready || !selectionCurrent())
|
|
548
|
+
return false;
|
|
549
|
+
}
|
|
525
550
|
const expected = liveSelectionTarget();
|
|
526
|
-
|
|
551
|
+
let dispatchedRendererEpoch;
|
|
527
552
|
const selecting = pendingWindowSwitch;
|
|
528
553
|
const operationId = selecting?.traceId;
|
|
529
554
|
const selectionFailure = { current: null };
|
|
@@ -536,8 +561,36 @@ export function createApplicationTerminalInteractionController(options) {
|
|
|
536
561
|
: null;
|
|
537
562
|
}, paneId, operationId, (failure) => {
|
|
538
563
|
selectionFailure.current = failure;
|
|
539
|
-
}, windowLinkForPane(options.layout(), paneId) ?? undefined)
|
|
564
|
+
}, windowLinkForPane(options.layout(), paneId) ?? undefined, () => {
|
|
565
|
+
dispatchedRendererEpoch = options.generation()?.rendererEpoch;
|
|
566
|
+
})
|
|
540
567
|
: null;
|
|
568
|
+
if (receipt === null &&
|
|
569
|
+
selectionFailure.current?.reason === "topology-changed" &&
|
|
570
|
+
selectionCurrent()) {
|
|
571
|
+
// The daemon may have selected the pane before retiring its stream.
|
|
572
|
+
// Observe the replacement runtime; never resubmit an uncertain command
|
|
573
|
+
// or manufacture an operation receipt. Input still waits on this promise.
|
|
574
|
+
const confirmed = await selectionWaiter.wait(() => {
|
|
575
|
+
if (!selectionCurrent())
|
|
576
|
+
return false;
|
|
577
|
+
const active = selectionGeneration;
|
|
578
|
+
if (active?.status !== "live" ||
|
|
579
|
+
active.rendererEpoch === dispatchedRendererEpoch ||
|
|
580
|
+
observedLayoutEpoch !== active.rendererEpoch)
|
|
581
|
+
return "pending";
|
|
582
|
+
if (!panePresent())
|
|
583
|
+
return false;
|
|
584
|
+
const current = selectionLayout.current;
|
|
585
|
+
const activePanes = current?.panes.filter((pane) => pane.active) ?? [];
|
|
586
|
+
if (!current?.currentWindow || activePanes.length !== 1)
|
|
587
|
+
return "pending";
|
|
588
|
+
return activePanes[0]?.pane === paneId ? true : "pending";
|
|
589
|
+
});
|
|
590
|
+
if (selecting && pendingWindowSwitch === selecting)
|
|
591
|
+
pendingWindowSwitch = null;
|
|
592
|
+
return confirmed && selectionCurrent();
|
|
593
|
+
}
|
|
541
594
|
if (selecting && pendingWindowSwitch === selecting) {
|
|
542
595
|
const receiptAtMicros = options.detailedWindowSwitchTiming ? diagnosticNowMicros() : null;
|
|
543
596
|
if (selecting.timing)
|
|
@@ -689,18 +742,21 @@ export function createApplicationTerminalInteractionController(options) {
|
|
|
689
742
|
});
|
|
690
743
|
return {
|
|
691
744
|
cancelPendingInput() {
|
|
745
|
+
selectionWaiter.cancel();
|
|
692
746
|
finishLinkSelection(false);
|
|
693
747
|
paneInput.invalidateSelection();
|
|
694
748
|
for (const entry of waitingInput)
|
|
695
749
|
entry.settle(false);
|
|
696
750
|
},
|
|
697
751
|
adoptGeneration(snapshot) {
|
|
752
|
+
selectionGeneration = snapshot;
|
|
698
753
|
const control = controlIdentity(snapshot);
|
|
699
754
|
if (!sameControl(selectedControl, control)) {
|
|
700
755
|
finishLinkSelection(false);
|
|
701
756
|
paneInput.invalidateSelection();
|
|
702
757
|
}
|
|
703
758
|
selectedControl = control;
|
|
759
|
+
selectionWaiter.reconcile();
|
|
704
760
|
if (waitingInput.size > 0 && snapshot?.status === "live" && snapshot.fastLane) {
|
|
705
761
|
// Old waiters resume first; input admitted synchronously by the next
|
|
706
762
|
// layout publication must not overtake them in this microtask turn.
|
|
@@ -764,6 +820,9 @@ export function createApplicationTerminalInteractionController(options) {
|
|
|
764
820
|
inputAuthorityIdentity = next;
|
|
765
821
|
},
|
|
766
822
|
adoptLayout(snapshot) {
|
|
823
|
+
selectionLayout = snapshot;
|
|
824
|
+
observedLayoutEpoch = snapshot.rendererEpoch ?? null;
|
|
825
|
+
selectionWaiter.reconcile();
|
|
767
826
|
reconcileLinkSelection(snapshot);
|
|
768
827
|
paneInput.adoptCanonicalPane(snapshot.current ? paneForWindow(snapshot.current) : null);
|
|
769
828
|
const currentWindow = snapshot.current?.semanticWindowId ?? snapshot.current?.windowName;
|
|
@@ -850,6 +909,7 @@ export function createApplicationTerminalInteractionController(options) {
|
|
|
850
909
|
selectPane: (paneId) => {
|
|
851
910
|
finishLinkSelection(false);
|
|
852
911
|
paneInput.selectPane(paneId);
|
|
912
|
+
selectionWaiter.reconcile();
|
|
853
913
|
},
|
|
854
914
|
async unlinkWindowLink(target) {
|
|
855
915
|
const expected = liveSelectionTarget();
|
|
@@ -371,7 +371,7 @@ export function createOpenTuiGenerationHost(sessionName, presentation, overrides
|
|
|
371
371
|
// desired state against the fresh socket on the following live publish.
|
|
372
372
|
publish({ ...snapshot, status: "rebinding" });
|
|
373
373
|
owner.stopPresentation?.();
|
|
374
|
-
owner.stopPresentation = runtime ? presentation.adopt(runtime) : null;
|
|
374
|
+
owner.stopPresentation = runtime ? presentation.adopt(runtime, rendererEpoch + 1) : null;
|
|
375
375
|
owner.runtime = runtime;
|
|
376
376
|
owner.authorityClient = runtime ? runtimeAuthorityClient(owner.bundle, runtime) : null;
|
|
377
377
|
if (!runtime)
|
|
@@ -394,7 +394,7 @@ export function createOpenTuiGenerationHost(sessionName, presentation, overrides
|
|
|
394
394
|
if (candidate !== owner)
|
|
395
395
|
return;
|
|
396
396
|
if (runtime) {
|
|
397
|
-
owner.stopPresentation = presentation.adopt(runtime);
|
|
397
|
+
owner.stopPresentation = presentation.adopt(runtime, rendererEpoch + 1);
|
|
398
398
|
diagnose?.("host-internal-snapshot-publication", {
|
|
399
399
|
publicationPhase: "presentation-adopted",
|
|
400
400
|
daemonGeneration: owner.bundle.connection.target.daemon.instanceId,
|
|
@@ -52,7 +52,7 @@ export function createOpenTuiRuntimeLayoutPresentation() {
|
|
|
52
52
|
listener(windows);
|
|
53
53
|
return () => windowListeners.delete(listener);
|
|
54
54
|
},
|
|
55
|
-
adopt(port) {
|
|
55
|
+
adopt(port, rendererEpoch) {
|
|
56
56
|
if (disposed)
|
|
57
57
|
return () => undefined;
|
|
58
58
|
let candidate = true;
|
|
@@ -60,15 +60,18 @@ export function createOpenTuiRuntimeLayoutPresentation() {
|
|
|
60
60
|
candidateStop = port.onLayout((layout) => {
|
|
61
61
|
if (disposed || !candidate || !layout.current)
|
|
62
62
|
return;
|
|
63
|
+
// A port may reuse its snapshot object. Own the provenance wrapper so
|
|
64
|
+
// a preparing runtime cannot relabel the retained coherent frame.
|
|
65
|
+
const presented = Object.freeze({ ...layout, rendererEpoch });
|
|
63
66
|
if (currentPort !== port) {
|
|
64
67
|
const previousStop = stopLayout;
|
|
65
68
|
currentPort = port;
|
|
66
69
|
stopLayout = candidateStop;
|
|
67
|
-
publish(
|
|
70
|
+
publish(presented);
|
|
68
71
|
previousStop?.();
|
|
69
72
|
return;
|
|
70
73
|
}
|
|
71
|
-
publish(
|
|
74
|
+
publish(presented);
|
|
72
75
|
});
|
|
73
76
|
// `onLayout` intentionally seeds synchronously. Capture the disposer
|
|
74
77
|
// after that first callback has promoted this candidate.
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/** Acquire controller authority before issuing the canonical pane-select intent. */
|
|
2
|
-
export async function selectTerminalPane(expected, current, semanticPaneId, operationId, onFailure, windowLink) {
|
|
2
|
+
export async function selectTerminalPane(expected, current, semanticPaneId, operationId, onFailure, windowLink, onDispatch) {
|
|
3
3
|
const fail = (failure) => {
|
|
4
4
|
try {
|
|
5
5
|
onFailure?.(failure);
|
|
@@ -20,7 +20,11 @@ export async function selectTerminalPane(expected, current, semanticPaneId, oper
|
|
|
20
20
|
: undefined;
|
|
21
21
|
return fail({
|
|
22
22
|
stage,
|
|
23
|
-
reason: code === "operation-timeout"
|
|
23
|
+
reason: code === "operation-timeout"
|
|
24
|
+
? "operation-timeout"
|
|
25
|
+
: code === "topology-changed" && stage === "dispatch"
|
|
26
|
+
? "topology-changed"
|
|
27
|
+
: "transport-rejected",
|
|
24
28
|
...(backendReason ? { backendReason } : {}),
|
|
25
29
|
});
|
|
26
30
|
};
|
|
@@ -50,6 +54,7 @@ export async function selectTerminalPane(expected, current, semanticPaneId, oper
|
|
|
50
54
|
return fail({ stage: "pre-dispatch", reason: "generation-replaced" });
|
|
51
55
|
let result;
|
|
52
56
|
try {
|
|
57
|
+
onDispatch?.();
|
|
53
58
|
result = await expected.client.dispatch({
|
|
54
59
|
kind: "semantic-intent",
|
|
55
60
|
...(operationId ? { operationId } : {}),
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
/** One bounded selection observation; never submits or repeats a mutation. */
|
|
2
|
+
export function createTerminalSelectionWaiter() {
|
|
3
|
+
let pending = null;
|
|
4
|
+
const cancel = () => pending?.finish(false);
|
|
5
|
+
const reconcile = () => {
|
|
6
|
+
const waiting = pending;
|
|
7
|
+
if (!waiting)
|
|
8
|
+
return;
|
|
9
|
+
const result = waiting.check();
|
|
10
|
+
if (result !== "pending")
|
|
11
|
+
waiting.finish(result);
|
|
12
|
+
};
|
|
13
|
+
return {
|
|
14
|
+
cancel,
|
|
15
|
+
reconcile,
|
|
16
|
+
wait(check) {
|
|
17
|
+
cancel();
|
|
18
|
+
return new Promise((resolve) => {
|
|
19
|
+
const timer = setTimeout(() => waiting.finish(false), 5000);
|
|
20
|
+
timer.unref?.();
|
|
21
|
+
const waiting = {
|
|
22
|
+
check,
|
|
23
|
+
finish(selected) {
|
|
24
|
+
if (pending !== waiting)
|
|
25
|
+
return;
|
|
26
|
+
pending = null;
|
|
27
|
+
clearTimeout(timer);
|
|
28
|
+
resolve(selected);
|
|
29
|
+
},
|
|
30
|
+
};
|
|
31
|
+
pending = waiting;
|
|
32
|
+
reconcile();
|
|
33
|
+
});
|
|
34
|
+
},
|
|
35
|
+
};
|
|
36
|
+
}
|
|
@@ -237,14 +237,31 @@ export class MirrorService {
|
|
|
237
237
|
}
|
|
238
238
|
}
|
|
239
239
|
|
|
240
|
-
async describeSessionAuthority(
|
|
240
|
+
async describeSessionAuthority(
|
|
241
|
+
session: string,
|
|
242
|
+
expectedSemanticPaneIds?: readonly string[],
|
|
243
|
+
): Promise<{
|
|
241
244
|
readonly description: MirrorSessionDescription;
|
|
242
245
|
readonly runtimeSessionId: string;
|
|
243
246
|
}> {
|
|
244
247
|
const entry = await this.acquire(session);
|
|
245
248
|
try {
|
|
246
249
|
const identity = await entry.channel.attachedSessionIdentity();
|
|
247
|
-
|
|
250
|
+
let description = entry.channel.describe();
|
|
251
|
+
if (
|
|
252
|
+
expectedSemanticPaneIds?.some(
|
|
253
|
+
(id) => !description.panes.some((pane) => pane.semanticPaneId === id),
|
|
254
|
+
)
|
|
255
|
+
) {
|
|
256
|
+
// A mutation receipt and fresh catalog can precede debounced mirror
|
|
257
|
+
// discovery. Join the verified native inventory before declaring a
|
|
258
|
+
// requested pane absent; never substitute receipt IDs for tmux truth.
|
|
259
|
+
await entry.channel.describeTrustedInventory(identity.runtimeSessionId, true);
|
|
260
|
+
description = entry.channel.describe();
|
|
261
|
+
}
|
|
262
|
+
if (this.disposed || entry.retired || this.channels.get(session) !== entry)
|
|
263
|
+
throw new Error("Mirror authority retired during description");
|
|
264
|
+
return { description, runtimeSessionId: identity.runtimeSessionId };
|
|
248
265
|
} finally {
|
|
249
266
|
this.release(session, entry);
|
|
250
267
|
}
|
|
@@ -507,6 +507,8 @@ export class SessionChannel {
|
|
|
507
507
|
private readonly fittedWindows = new Map<string, { cols: number; rows: number }>();
|
|
508
508
|
private cancelSync: (() => void) | null = null;
|
|
509
509
|
private syncOrdinal = 0;
|
|
510
|
+
private identityJoinOrdinal = 0;
|
|
511
|
+
private trustedInventoryJoinsMissing = false;
|
|
510
512
|
private completedSyncOrdinal = 0;
|
|
511
513
|
private lastDisplayNameSyncAtMs = 0;
|
|
512
514
|
private disposed = false;
|
|
@@ -799,22 +801,36 @@ export class SessionChannel {
|
|
|
799
801
|
|
|
800
802
|
/**
|
|
801
803
|
* Strict daemon-internal inventory from this channel's current tmux truth.
|
|
802
|
-
* Unlike
|
|
803
|
-
*
|
|
804
|
-
*
|
|
804
|
+
* Unlike background discovery, this query verifies current native membership
|
|
805
|
+
* before projecting and rejects incomplete identity. Stream admission may
|
|
806
|
+
* explicitly join missing pane records after that verification; ordinary
|
|
807
|
+
* inventory readers retain their existing fail-closed no-join contract.
|
|
805
808
|
*/
|
|
806
809
|
describeTrustedInventory(
|
|
807
810
|
expectedRuntimeSessionId: string,
|
|
811
|
+
joinMissingPanes = false,
|
|
808
812
|
): Promise<TrustedMirrorSessionInventory> {
|
|
809
813
|
if (this.disposed) {
|
|
810
814
|
return Promise.reject(new Error(`mirror session ${this.opts.session} is disposed`));
|
|
811
815
|
}
|
|
812
816
|
if (this.trustedInventoryFlight) {
|
|
817
|
+
// A default read can fail precisely because it does not join new panes.
|
|
818
|
+
// Preserve that failure for its caller, then qualify admission separately.
|
|
819
|
+
if (
|
|
820
|
+
joinMissingPanes &&
|
|
821
|
+
!this.trustedInventoryJoinsMissing &&
|
|
822
|
+
this.trustedInventoryFlightSessionId === expectedRuntimeSessionId
|
|
823
|
+
)
|
|
824
|
+
return this.trustedInventoryFlight.then(
|
|
825
|
+
() => this.describeTrustedInventory(expectedRuntimeSessionId, true),
|
|
826
|
+
() => this.describeTrustedInventory(expectedRuntimeSessionId, true),
|
|
827
|
+
);
|
|
813
828
|
return this.trustedInventoryFlightSessionId === expectedRuntimeSessionId
|
|
814
829
|
? this.trustedInventoryFlight
|
|
815
830
|
: Promise.reject(new Error(`trusted inventory identity changed for ${this.opts.session}`));
|
|
816
831
|
}
|
|
817
|
-
|
|
832
|
+
this.trustedInventoryJoinsMissing = joinMissingPanes;
|
|
833
|
+
const flight = this.refreshTrustedInventory(expectedRuntimeSessionId, 0, joinMissingPanes)
|
|
818
834
|
.catch((error) => {
|
|
819
835
|
this.windowLinkAuthority?.invalidate();
|
|
820
836
|
this.latestWindowStage = null;
|
|
@@ -3387,6 +3403,7 @@ export class SessionChannel {
|
|
|
3387
3403
|
private async refreshTrustedInventory(
|
|
3388
3404
|
expectedRuntimeSessionId: string,
|
|
3389
3405
|
attempt = 0,
|
|
3406
|
+
joinMissingPanes = false,
|
|
3390
3407
|
): Promise<TrustedMirrorSessionInventory> {
|
|
3391
3408
|
const authorityOrdinal = this.windowIdentityOrdinal;
|
|
3392
3409
|
const beforeLines = await this.io.request(
|
|
@@ -3486,7 +3503,11 @@ export class SessionChannel {
|
|
|
3486
3503
|
) {
|
|
3487
3504
|
if (attempt >= 1)
|
|
3488
3505
|
throw new Error(`trusted inventory for ${this.opts.session} did not settle`);
|
|
3489
|
-
return await this.refreshTrustedInventory(
|
|
3506
|
+
return await this.refreshTrustedInventory(
|
|
3507
|
+
expectedRuntimeSessionId,
|
|
3508
|
+
attempt + 1,
|
|
3509
|
+
joinMissingPanes,
|
|
3510
|
+
);
|
|
3490
3511
|
}
|
|
3491
3512
|
if (this.disposed) throw new Error(`mirror session ${this.opts.session} is disposed`);
|
|
3492
3513
|
const activeWindowId = activeWindowIds.values().next().value;
|
|
@@ -3521,7 +3542,28 @@ export class SessionChannel {
|
|
|
3521
3542
|
stagedPaneIds.size !== descriptors.length ||
|
|
3522
3543
|
[...computedWindowCounts.keys()].some(
|
|
3523
3544
|
(runtimeWindowId) => !confirmedWindowStage.windows.has(runtimeWindowId),
|
|
3524
|
-
)
|
|
3545
|
+
)
|
|
3546
|
+
)
|
|
3547
|
+
throw new Error(`trusted inventory for ${this.opts.session} lacks verified identity`);
|
|
3548
|
+
// Fresh native inventory can beat background discovery after window creation.
|
|
3549
|
+
// Join verified stable membership before projecting semantic records.
|
|
3550
|
+
if (
|
|
3551
|
+
joinMissingPanes &&
|
|
3552
|
+
descriptors.some(
|
|
3553
|
+
(pane) => this.panesByRuntime.get(pane.runtimePaneId)?.semanticId !== pane.semanticPaneId,
|
|
3554
|
+
)
|
|
3555
|
+
) {
|
|
3556
|
+
this.discovery.invalidate();
|
|
3557
|
+
await this.reconcileIdentity(
|
|
3558
|
+
descriptors,
|
|
3559
|
+
runtimePaneIds,
|
|
3560
|
+
() => authorityOrdinal === this.windowIdentityOrdinal,
|
|
3561
|
+
false,
|
|
3562
|
+
);
|
|
3563
|
+
}
|
|
3564
|
+
if (this.disposed || authorityOrdinal !== this.windowIdentityOrdinal)
|
|
3565
|
+
throw new Error(`trusted inventory for ${this.opts.session} changed during identity join`);
|
|
3566
|
+
if (
|
|
3525
3567
|
descriptors.some((descriptor) => {
|
|
3526
3568
|
const pane = this.panesByRuntime.get(descriptor.runtimePaneId);
|
|
3527
3569
|
const window = confirmedWindowStage.windows.get(descriptor.windowId!);
|
|
@@ -3934,7 +3976,10 @@ export class SessionChannel {
|
|
|
3934
3976
|
private async reconcileIdentity(
|
|
3935
3977
|
descriptors: readonly SessionPaneDescriptor[],
|
|
3936
3978
|
listed: ReadonlySet<string>,
|
|
3979
|
+
current: () => boolean = () => true,
|
|
3980
|
+
publish = true,
|
|
3937
3981
|
): Promise<boolean> {
|
|
3982
|
+
const joinOrdinal = ++this.identityJoinOrdinal;
|
|
3938
3983
|
if (this.disposed) return false;
|
|
3939
3984
|
if (this.opts.hasSharedWindowConflict?.()) {
|
|
3940
3985
|
this.settleFirstJoin();
|
|
@@ -3973,7 +4018,8 @@ export class SessionChannel {
|
|
|
3973
4018
|
),
|
|
3974
4019
|
),
|
|
3975
4020
|
);
|
|
3976
|
-
if (this.disposed)
|
|
4021
|
+
if (this.disposed || !current() || joinOrdinal !== this.identityJoinOrdinal)
|
|
4022
|
+
return plan.stampEffects.length > 0;
|
|
3977
4023
|
const reconciliation = finalizeWorkspaceTmuxReconciliation(plan, outcomes);
|
|
3978
4024
|
const descriptorByRuntime = new Map(descriptors.map((d) => [d.runtimePaneId, d]));
|
|
3979
4025
|
for (const verified of reconciliation.panes) {
|
|
@@ -4080,8 +4126,10 @@ export class SessionChannel {
|
|
|
4080
4126
|
* without this the new pane stays invisible until something else happens to
|
|
4081
4127
|
* move a layout, and a split looks like it did not reach the view.
|
|
4082
4128
|
*/
|
|
4083
|
-
|
|
4084
|
-
|
|
4129
|
+
if (publish) {
|
|
4130
|
+
for (const runtimeId of this.layoutByWindow.keys()) this.emitLayout(runtimeId);
|
|
4131
|
+
this.emitLayoutAuthority();
|
|
4132
|
+
}
|
|
4085
4133
|
this.settleFirstJoin();
|
|
4086
4134
|
return plan.stampEffects.length > 0;
|
|
4087
4135
|
}
|
|
@@ -1,3 +1,7 @@
|
|
|
1
|
+
import {
|
|
2
|
+
PANE_STREAM_INPUT_LIFETIME_CAPABILITY_VERSION,
|
|
3
|
+
type PaneStreamInputLifetime,
|
|
4
|
+
} from "@tmux-ide/contracts";
|
|
1
5
|
import { inflateSync } from "node:zlib";
|
|
2
6
|
import { PaneStreamLayoutSnapshotFrameSchemaZ, type WindowLinkTopology } from "@tmux-ide/contracts";
|
|
3
7
|
import type { TerminalDeliveryGraphics } from "../session-runtime/terminal-delivery-hub.ts";
|
|
@@ -149,6 +153,7 @@ export interface PaneStreamMirror {
|
|
|
149
153
|
describeSession(session: string): Promise<MirrorSessionDescription>;
|
|
150
154
|
describeSessionAuthority?(
|
|
151
155
|
session: string,
|
|
156
|
+
expectedSemanticPaneIds?: readonly string[],
|
|
152
157
|
): Promise<{ readonly description: MirrorSessionDescription; readonly runtimeSessionId: string }>;
|
|
153
158
|
subscribe(request: MirrorSubscribeRequest): Promise<MirrorSubscription>;
|
|
154
159
|
subscribeLayout?(
|
|
@@ -304,6 +309,7 @@ export interface SessionRuntimePaneStreamTransportBinding {
|
|
|
304
309
|
performanceTraceId?: string,
|
|
305
310
|
causalProbe?: CausalCellProbeV1,
|
|
306
311
|
onCausalResult?: (result: CausalCellLedgerResult) => void,
|
|
312
|
+
expectedLifetime?: PaneStreamInputLifetime,
|
|
307
313
|
): void;
|
|
308
314
|
fitViewport(
|
|
309
315
|
lease: SessionRuntimeAuthorityLease,
|
|
@@ -493,7 +499,10 @@ export class PaneStreamAdmissionCoordinator {
|
|
|
493
499
|
try {
|
|
494
500
|
const describeStartedAtMicros = trace ? this.#observability!.nowMicros() : 0;
|
|
495
501
|
if (this.#mirror.describeSessionAuthority) {
|
|
496
|
-
const authority = await this.#mirror.describeSessionAuthority(
|
|
502
|
+
const authority = await this.#mirror.describeSessionAuthority(
|
|
503
|
+
context.sessionName,
|
|
504
|
+
request.panes,
|
|
505
|
+
);
|
|
497
506
|
described = authority.description;
|
|
498
507
|
runtimeSessionId = authority.runtimeSessionId;
|
|
499
508
|
} else {
|
|
@@ -1279,6 +1288,12 @@ export class PaneStreamLiveConnection {
|
|
|
1279
1288
|
try {
|
|
1280
1289
|
sendControl(this.#socket, {
|
|
1281
1290
|
type: "ready",
|
|
1291
|
+
...(this.#sessionRuntimeBinding &&
|
|
1292
|
+
this.#descriptor.terminalDelivery?.protocolVersions.includes(
|
|
1293
|
+
PANE_STREAM_INPUT_LIFETIME_CAPABILITY_VERSION,
|
|
1294
|
+
)
|
|
1295
|
+
? { inputLifetime: true as const }
|
|
1296
|
+
: {}),
|
|
1282
1297
|
protocolVersion: PANE_STREAM_PROTOCOL_VERSION,
|
|
1283
1298
|
daemonInstanceId: this.#binding.daemonInstanceId,
|
|
1284
1299
|
requestId: this.#binding.requestId,
|
|
@@ -2560,6 +2575,7 @@ export class PaneStreamLiveConnection {
|
|
|
2560
2575
|
ingressAtMicros,
|
|
2561
2576
|
callbackAtSharedMicros,
|
|
2562
2577
|
ingressAtSharedMicros,
|
|
2578
|
+
frame.expectedLifetime,
|
|
2563
2579
|
);
|
|
2564
2580
|
};
|
|
2565
2581
|
|
|
@@ -2712,7 +2728,18 @@ export class PaneStreamLiveConnection {
|
|
|
2712
2728
|
ingressAtMicros: number | null = null,
|
|
2713
2729
|
callbackAtSharedMicros: number | null = null,
|
|
2714
2730
|
ingressAtSharedMicros: number | null = null,
|
|
2731
|
+
expectedLifetime?: PaneStreamInputLifetime,
|
|
2715
2732
|
): void {
|
|
2733
|
+
if (
|
|
2734
|
+
expectedLifetime &&
|
|
2735
|
+
(!this.#sessionRuntimeBinding ||
|
|
2736
|
+
!this.#descriptor.terminalDelivery?.protocolVersions.includes(
|
|
2737
|
+
PANE_STREAM_INPUT_LIFETIME_CAPABILITY_VERSION,
|
|
2738
|
+
))
|
|
2739
|
+
) {
|
|
2740
|
+
this.#failProtocol("input-rejected");
|
|
2741
|
+
return;
|
|
2742
|
+
}
|
|
2716
2743
|
if (this.#descriptor.viewerMode !== "interactive") {
|
|
2717
2744
|
this.#failProtocol("input-rejected");
|
|
2718
2745
|
return;
|
|
@@ -2832,6 +2859,7 @@ export class PaneStreamLiveConnection {
|
|
|
2832
2859
|
}
|
|
2833
2860
|
}
|
|
2834
2861
|
: undefined,
|
|
2862
|
+
expectedLifetime,
|
|
2835
2863
|
);
|
|
2836
2864
|
else if (kind === "text") channel.sub!.sendText(data);
|
|
2837
2865
|
else channel.sub!.sendKey(data);
|
|
@@ -30,6 +30,7 @@ import {
|
|
|
30
30
|
type CanonicalTerminalReplicaUpdate,
|
|
31
31
|
type CausalCellFailureReasonV1,
|
|
32
32
|
type CausalCellProbeV1,
|
|
33
|
+
type PaneStreamInputLifetime,
|
|
33
34
|
type TerminalDeliveryAck,
|
|
34
35
|
type TerminalDeliveryNack,
|
|
35
36
|
type TerminalDeliveryOffer,
|
|
@@ -244,6 +245,7 @@ export interface SessionRuntimeConsumer {
|
|
|
244
245
|
performanceTraceId?: string,
|
|
245
246
|
causalProbe?: CausalCellProbeV1,
|
|
246
247
|
onCausalResult?: (result: CausalCellLedgerResult) => void,
|
|
248
|
+
expectedLifetime?: PaneStreamInputLifetime,
|
|
247
249
|
): void;
|
|
248
250
|
failCausalCellProbe(
|
|
249
251
|
semanticPaneId: string,
|
|
@@ -896,13 +898,16 @@ export class SessionRuntimeRegistry implements PaneStreamMirror {
|
|
|
896
898
|
return result;
|
|
897
899
|
}
|
|
898
900
|
|
|
899
|
-
async describeSessionAuthority(
|
|
901
|
+
async describeSessionAuthority(
|
|
902
|
+
session: string,
|
|
903
|
+
expectedSemanticPaneIds?: readonly string[],
|
|
904
|
+
): Promise<{
|
|
900
905
|
readonly description: MirrorSessionDescription;
|
|
901
906
|
readonly runtimeSessionId: string;
|
|
902
907
|
}> {
|
|
903
908
|
const runtime = this.#runtime(session);
|
|
904
909
|
await runtime.whenReady();
|
|
905
|
-
const authority = await this.#mirror.describeSessionAuthority(session);
|
|
910
|
+
const authority = await this.#mirror.describeSessionAuthority(session, expectedSemanticPaneIds);
|
|
906
911
|
if (this.#disposed || this.#sessions.get(session) !== runtime) {
|
|
907
912
|
const error = new Error(`session runtime ${session} changed topology`);
|
|
908
913
|
error.name = "MirrorTopologyChangedError";
|
|
@@ -1589,6 +1594,7 @@ class SessionRuntime {
|
|
|
1589
1594
|
performanceTraceId?: string,
|
|
1590
1595
|
rawCausalProbe?: CausalCellProbeV1,
|
|
1591
1596
|
onCausalResult?: (result: CausalCellLedgerResult) => void,
|
|
1597
|
+
expectedLifetime?: PaneStreamInputLifetime,
|
|
1592
1598
|
): void {
|
|
1593
1599
|
this.assertController(lease, clientId);
|
|
1594
1600
|
const inputLease = this.#authority.leaseFor(clientId, "input");
|
|
@@ -1626,7 +1632,10 @@ class SessionRuntime {
|
|
|
1626
1632
|
})
|
|
1627
1633
|
: null;
|
|
1628
1634
|
const started = this.#observability.enabled ? this.#observability.nowMicros() : 0;
|
|
1629
|
-
const replicaOwner =
|
|
1635
|
+
const replicaOwner = expectedLifetime
|
|
1636
|
+
? this.#terminalReplicas.get(semanticPaneId)
|
|
1637
|
+
: this.#terminalReplicaOwner(semanticPaneId);
|
|
1638
|
+
if (!replicaOwner) throw new Error("Input target lifetime unavailable");
|
|
1630
1639
|
if (causalProbe) {
|
|
1631
1640
|
this.#activeCausalCellProbes += 1;
|
|
1632
1641
|
let settled = false;
|
|
@@ -1649,6 +1658,15 @@ class SessionRuntime {
|
|
|
1649
1658
|
this.#outputTraces.arm(semanticPaneId, trace);
|
|
1650
1659
|
}
|
|
1651
1660
|
try {
|
|
1661
|
+
// Synchronous final admission: never create a new owner to satisfy a stale
|
|
1662
|
+
// held-input fence, and never await between this check and mirror dispatch.
|
|
1663
|
+
if (
|
|
1664
|
+
expectedLifetime &&
|
|
1665
|
+
(expectedLifetime.generation !== this.generation ||
|
|
1666
|
+
this.#terminalReplicas.get(semanticPaneId) !== replicaOwner ||
|
|
1667
|
+
replicaOwner.inputAdmissionIncarnation() !== expectedLifetime.incarnation)
|
|
1668
|
+
)
|
|
1669
|
+
throw new Error("Input target lifetime changed");
|
|
1652
1670
|
if (input.kind === "text")
|
|
1653
1671
|
this.#mirror.sendText(
|
|
1654
1672
|
this.session,
|
|
@@ -2323,6 +2341,7 @@ class SessionRuntimeConsumerImpl implements SessionRuntimeConsumer {
|
|
|
2323
2341
|
performanceTraceId?: string,
|
|
2324
2342
|
causalProbe?: CausalCellProbeV1,
|
|
2325
2343
|
onCausalResult?: (result: CausalCellLedgerResult) => void,
|
|
2344
|
+
expectedLifetime?: PaneStreamInputLifetime,
|
|
2326
2345
|
): void {
|
|
2327
2346
|
this.#assertOpen();
|
|
2328
2347
|
this.#runtime.sendInput(
|
|
@@ -2333,6 +2352,7 @@ class SessionRuntimeConsumerImpl implements SessionRuntimeConsumer {
|
|
|
2333
2352
|
performanceTraceId,
|
|
2334
2353
|
causalProbe,
|
|
2335
2354
|
onCausalResult,
|
|
2355
|
+
expectedLifetime,
|
|
2336
2356
|
);
|
|
2337
2357
|
}
|
|
2338
2358
|
|