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
|
@@ -177,6 +177,8 @@ export class SessionChannel {
|
|
|
177
177
|
fittedWindows = new Map();
|
|
178
178
|
cancelSync = null;
|
|
179
179
|
syncOrdinal = 0;
|
|
180
|
+
identityJoinOrdinal = 0;
|
|
181
|
+
trustedInventoryJoinsMissing = false;
|
|
180
182
|
completedSyncOrdinal = 0;
|
|
181
183
|
lastDisplayNameSyncAtMs = 0;
|
|
182
184
|
disposed = false;
|
|
@@ -412,20 +414,28 @@ export class SessionChannel {
|
|
|
412
414
|
}
|
|
413
415
|
/**
|
|
414
416
|
* Strict daemon-internal inventory from this channel's current tmux truth.
|
|
415
|
-
* Unlike
|
|
416
|
-
*
|
|
417
|
-
*
|
|
417
|
+
* Unlike background discovery, this query verifies current native membership
|
|
418
|
+
* before projecting and rejects incomplete identity. Stream admission may
|
|
419
|
+
* explicitly join missing pane records after that verification; ordinary
|
|
420
|
+
* inventory readers retain their existing fail-closed no-join contract.
|
|
418
421
|
*/
|
|
419
|
-
describeTrustedInventory(expectedRuntimeSessionId) {
|
|
422
|
+
describeTrustedInventory(expectedRuntimeSessionId, joinMissingPanes = false) {
|
|
420
423
|
if (this.disposed) {
|
|
421
424
|
return Promise.reject(new Error(`mirror session ${this.opts.session} is disposed`));
|
|
422
425
|
}
|
|
423
426
|
if (this.trustedInventoryFlight) {
|
|
427
|
+
// A default read can fail precisely because it does not join new panes.
|
|
428
|
+
// Preserve that failure for its caller, then qualify admission separately.
|
|
429
|
+
if (joinMissingPanes &&
|
|
430
|
+
!this.trustedInventoryJoinsMissing &&
|
|
431
|
+
this.trustedInventoryFlightSessionId === expectedRuntimeSessionId)
|
|
432
|
+
return this.trustedInventoryFlight.then(() => this.describeTrustedInventory(expectedRuntimeSessionId, true), () => this.describeTrustedInventory(expectedRuntimeSessionId, true));
|
|
424
433
|
return this.trustedInventoryFlightSessionId === expectedRuntimeSessionId
|
|
425
434
|
? this.trustedInventoryFlight
|
|
426
435
|
: Promise.reject(new Error(`trusted inventory identity changed for ${this.opts.session}`));
|
|
427
436
|
}
|
|
428
|
-
|
|
437
|
+
this.trustedInventoryJoinsMissing = joinMissingPanes;
|
|
438
|
+
const flight = this.refreshTrustedInventory(expectedRuntimeSessionId, 0, joinMissingPanes)
|
|
429
439
|
.catch((error) => {
|
|
430
440
|
this.windowLinkAuthority?.invalidate();
|
|
431
441
|
this.latestWindowStage = null;
|
|
@@ -2723,7 +2733,7 @@ export class SessionChannel {
|
|
|
2723
2733
|
}
|
|
2724
2734
|
return { listed, movedWindowRuntimeIds };
|
|
2725
2735
|
}
|
|
2726
|
-
async refreshTrustedInventory(expectedRuntimeSessionId, attempt = 0) {
|
|
2736
|
+
async refreshTrustedInventory(expectedRuntimeSessionId, attempt = 0, joinMissingPanes = false) {
|
|
2727
2737
|
const authorityOrdinal = this.windowIdentityOrdinal;
|
|
2728
2738
|
const beforeLines = await this.io.request(`list-panes -s -t "${expectedRuntimeSessionId}" -F "${SESSION_PANE_DESCRIPTOR_FORMAT}"`);
|
|
2729
2739
|
if (this.disposed)
|
|
@@ -2800,7 +2810,7 @@ export class SessionChannel {
|
|
|
2800
2810
|
authorityOrdinal !== this.windowIdentityOrdinal) {
|
|
2801
2811
|
if (attempt >= 1)
|
|
2802
2812
|
throw new Error(`trusted inventory for ${this.opts.session} did not settle`);
|
|
2803
|
-
return await this.refreshTrustedInventory(expectedRuntimeSessionId, attempt + 1);
|
|
2813
|
+
return await this.refreshTrustedInventory(expectedRuntimeSessionId, attempt + 1, joinMissingPanes);
|
|
2804
2814
|
}
|
|
2805
2815
|
if (this.disposed)
|
|
2806
2816
|
throw new Error(`mirror session ${this.opts.session} is disposed`);
|
|
@@ -2832,17 +2842,27 @@ export class SessionChannel {
|
|
|
2832
2842
|
!stagedPaneMembershipExact ||
|
|
2833
2843
|
stagedPaneCount !== descriptors.length ||
|
|
2834
2844
|
stagedPaneIds.size !== descriptors.length ||
|
|
2835
|
-
[...computedWindowCounts.keys()].some((runtimeWindowId) => !confirmedWindowStage.windows.has(runtimeWindowId))
|
|
2836
|
-
|
|
2837
|
-
|
|
2838
|
-
|
|
2839
|
-
|
|
2840
|
-
|
|
2841
|
-
|
|
2842
|
-
|
|
2843
|
-
|
|
2844
|
-
|
|
2845
|
-
})
|
|
2845
|
+
[...computedWindowCounts.keys()].some((runtimeWindowId) => !confirmedWindowStage.windows.has(runtimeWindowId)))
|
|
2846
|
+
throw new Error(`trusted inventory for ${this.opts.session} lacks verified identity`);
|
|
2847
|
+
// Fresh native inventory can beat background discovery after window creation.
|
|
2848
|
+
// Join verified stable membership before projecting semantic records.
|
|
2849
|
+
if (joinMissingPanes &&
|
|
2850
|
+
descriptors.some((pane) => this.panesByRuntime.get(pane.runtimePaneId)?.semanticId !== pane.semanticPaneId)) {
|
|
2851
|
+
this.discovery.invalidate();
|
|
2852
|
+
await this.reconcileIdentity(descriptors, runtimePaneIds, () => authorityOrdinal === this.windowIdentityOrdinal, false);
|
|
2853
|
+
}
|
|
2854
|
+
if (this.disposed || authorityOrdinal !== this.windowIdentityOrdinal)
|
|
2855
|
+
throw new Error(`trusted inventory for ${this.opts.session} changed during identity join`);
|
|
2856
|
+
if (descriptors.some((descriptor) => {
|
|
2857
|
+
const pane = this.panesByRuntime.get(descriptor.runtimePaneId);
|
|
2858
|
+
const window = confirmedWindowStage.windows.get(descriptor.windowId);
|
|
2859
|
+
return (!pane ||
|
|
2860
|
+
!window?.semanticId ||
|
|
2861
|
+
descriptor.semanticPaneId !== pane.semanticId ||
|
|
2862
|
+
descriptor.semanticWindowId !== window.semanticId ||
|
|
2863
|
+
!WorkspaceIdSchemaZ.safeParse(pane.semanticId).success ||
|
|
2864
|
+
!WorkspaceIdSchemaZ.safeParse(window.semanticId).success);
|
|
2865
|
+
})) {
|
|
2846
2866
|
throw new Error(`trusted inventory for ${this.opts.session} lacks verified identity`);
|
|
2847
2867
|
}
|
|
2848
2868
|
const previousCurrentWindow = this.currentWindow;
|
|
@@ -3174,7 +3194,8 @@ export class SessionChannel {
|
|
|
3174
3194
|
})),
|
|
3175
3195
|
};
|
|
3176
3196
|
}
|
|
3177
|
-
async reconcileIdentity(descriptors, listed) {
|
|
3197
|
+
async reconcileIdentity(descriptors, listed, current = () => true, publish = true) {
|
|
3198
|
+
const joinOrdinal = ++this.identityJoinOrdinal;
|
|
3178
3199
|
if (this.disposed)
|
|
3179
3200
|
return false;
|
|
3180
3201
|
if (this.opts.hasSharedWindowConflict?.()) {
|
|
@@ -3205,7 +3226,7 @@ export class SessionChannel {
|
|
|
3205
3226
|
ok: false,
|
|
3206
3227
|
error: cause instanceof Error ? cause.message : String(cause),
|
|
3207
3228
|
}))));
|
|
3208
|
-
if (this.disposed)
|
|
3229
|
+
if (this.disposed || !current() || joinOrdinal !== this.identityJoinOrdinal)
|
|
3209
3230
|
return plan.stampEffects.length > 0;
|
|
3210
3231
|
const reconciliation = finalizeWorkspaceTmuxReconciliation(plan, outcomes);
|
|
3211
3232
|
const descriptorByRuntime = new Map(descriptors.map((d) => [d.runtimePaneId, d]));
|
|
@@ -3311,9 +3332,11 @@ export class SessionChannel {
|
|
|
3311
3332
|
* without this the new pane stays invisible until something else happens to
|
|
3312
3333
|
* move a layout, and a split looks like it did not reach the view.
|
|
3313
3334
|
*/
|
|
3314
|
-
|
|
3315
|
-
this.
|
|
3316
|
-
|
|
3335
|
+
if (publish) {
|
|
3336
|
+
for (const runtimeId of this.layoutByWindow.keys())
|
|
3337
|
+
this.emitLayout(runtimeId);
|
|
3338
|
+
this.emitLayoutAuthority();
|
|
3339
|
+
}
|
|
3317
3340
|
this.settleFirstJoin();
|
|
3318
3341
|
return plan.stampEffects.length > 0;
|
|
3319
3342
|
}
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { PANE_STREAM_INPUT_LIFETIME_CAPABILITY_VERSION, } from "@tmux-ide/contracts";
|
|
1
2
|
import { inflateSync } from "node:zlib";
|
|
2
3
|
import { PaneStreamLayoutSnapshotFrameSchemaZ } from "@tmux-ide/contracts";
|
|
3
4
|
import { z } from "zod";
|
|
@@ -193,7 +194,7 @@ export class PaneStreamAdmissionCoordinator {
|
|
|
193
194
|
try {
|
|
194
195
|
const describeStartedAtMicros = trace ? this.#observability.nowMicros() : 0;
|
|
195
196
|
if (this.#mirror.describeSessionAuthority) {
|
|
196
|
-
const authority = await this.#mirror.describeSessionAuthority(context.sessionName);
|
|
197
|
+
const authority = await this.#mirror.describeSessionAuthority(context.sessionName, request.panes);
|
|
197
198
|
described = authority.description;
|
|
198
199
|
runtimeSessionId = authority.runtimeSessionId;
|
|
199
200
|
}
|
|
@@ -765,6 +766,10 @@ export class PaneStreamLiveConnection {
|
|
|
765
766
|
try {
|
|
766
767
|
sendControl(this.#socket, {
|
|
767
768
|
type: "ready",
|
|
769
|
+
...(this.#sessionRuntimeBinding &&
|
|
770
|
+
this.#descriptor.terminalDelivery?.protocolVersions.includes(PANE_STREAM_INPUT_LIFETIME_CAPABILITY_VERSION)
|
|
771
|
+
? { inputLifetime: true }
|
|
772
|
+
: {}),
|
|
768
773
|
protocolVersion: PANE_STREAM_PROTOCOL_VERSION,
|
|
769
774
|
daemonInstanceId: this.#binding.daemonInstanceId,
|
|
770
775
|
requestId: this.#binding.requestId,
|
|
@@ -1995,7 +2000,7 @@ export class PaneStreamLiveConnection {
|
|
|
1995
2000
|
}
|
|
1996
2001
|
return;
|
|
1997
2002
|
}
|
|
1998
|
-
this.#acceptInput(frame.pane, frame.seq, frame.kind, frame.data, byteLength, frame.performanceTraceId, frame.causalProbe, ingressAtMicros, callbackAtSharedMicros, ingressAtSharedMicros);
|
|
2003
|
+
this.#acceptInput(frame.pane, frame.seq, frame.kind, frame.data, byteLength, frame.performanceTraceId, frame.causalProbe, ingressAtMicros, callbackAtSharedMicros, ingressAtSharedMicros, frame.expectedLifetime);
|
|
1999
2004
|
};
|
|
2000
2005
|
#acceptAuthorityGeneration(generation) {
|
|
2001
2006
|
if (!this.#sessionRuntimeBinding || generation !== this.#sessionRuntimeBinding.generation) {
|
|
@@ -2121,7 +2126,13 @@ export class PaneStreamLiveConnection {
|
|
|
2121
2126
|
this.#ledger.give(this.#clientId, pane, "renderer-backlog", returned);
|
|
2122
2127
|
this.#evaluateResume(pane);
|
|
2123
2128
|
}
|
|
2124
|
-
#acceptInput(pane, seq, kind, data, frameBytes, performanceTraceId, causalProbe, ingressAtMicros = null, callbackAtSharedMicros = null, ingressAtSharedMicros = null) {
|
|
2129
|
+
#acceptInput(pane, seq, kind, data, frameBytes, performanceTraceId, causalProbe, ingressAtMicros = null, callbackAtSharedMicros = null, ingressAtSharedMicros = null, expectedLifetime) {
|
|
2130
|
+
if (expectedLifetime &&
|
|
2131
|
+
(!this.#sessionRuntimeBinding ||
|
|
2132
|
+
!this.#descriptor.terminalDelivery?.protocolVersions.includes(PANE_STREAM_INPUT_LIFETIME_CAPABILITY_VERSION))) {
|
|
2133
|
+
this.#failProtocol("input-rejected");
|
|
2134
|
+
return;
|
|
2135
|
+
}
|
|
2125
2136
|
if (this.#descriptor.viewerMode !== "interactive") {
|
|
2126
2137
|
this.#failProtocol("input-rejected");
|
|
2127
2138
|
return;
|
|
@@ -2213,7 +2224,7 @@ export class PaneStreamLiveConnection {
|
|
|
2213
2224
|
});
|
|
2214
2225
|
}
|
|
2215
2226
|
}
|
|
2216
|
-
: undefined);
|
|
2227
|
+
: undefined, expectedLifetime);
|
|
2217
2228
|
else if (kind === "text")
|
|
2218
2229
|
channel.sub.sendText(data);
|
|
2219
2230
|
else
|
|
@@ -472,10 +472,10 @@ export class SessionRuntimeRegistry {
|
|
|
472
472
|
return { status: "retired" };
|
|
473
473
|
return result;
|
|
474
474
|
}
|
|
475
|
-
async describeSessionAuthority(session) {
|
|
475
|
+
async describeSessionAuthority(session, expectedSemanticPaneIds) {
|
|
476
476
|
const runtime = this.#runtime(session);
|
|
477
477
|
await runtime.whenReady();
|
|
478
|
-
const authority = await this.#mirror.describeSessionAuthority(session);
|
|
478
|
+
const authority = await this.#mirror.describeSessionAuthority(session, expectedSemanticPaneIds);
|
|
479
479
|
if (this.#disposed || this.#sessions.get(session) !== runtime) {
|
|
480
480
|
const error = new Error(`session runtime ${session} changed topology`);
|
|
481
481
|
error.name = "MirrorTopologyChangedError";
|
|
@@ -1003,7 +1003,7 @@ class SessionRuntime {
|
|
|
1003
1003
|
return Promise.reject(error);
|
|
1004
1004
|
}
|
|
1005
1005
|
}
|
|
1006
|
-
sendInput(clientId, lease, semanticPaneId, rawInput, performanceTraceId, rawCausalProbe, onCausalResult) {
|
|
1006
|
+
sendInput(clientId, lease, semanticPaneId, rawInput, performanceTraceId, rawCausalProbe, onCausalResult, expectedLifetime) {
|
|
1007
1007
|
this.assertController(lease, clientId);
|
|
1008
1008
|
const inputLease = this.#authority.leaseFor(clientId, "input");
|
|
1009
1009
|
if (!inputLease) {
|
|
@@ -1037,7 +1037,11 @@ class SessionRuntime {
|
|
|
1037
1037
|
})
|
|
1038
1038
|
: null;
|
|
1039
1039
|
const started = this.#observability.enabled ? this.#observability.nowMicros() : 0;
|
|
1040
|
-
const replicaOwner =
|
|
1040
|
+
const replicaOwner = expectedLifetime
|
|
1041
|
+
? this.#terminalReplicas.get(semanticPaneId)
|
|
1042
|
+
: this.#terminalReplicaOwner(semanticPaneId);
|
|
1043
|
+
if (!replicaOwner)
|
|
1044
|
+
throw new Error("Input target lifetime unavailable");
|
|
1041
1045
|
if (causalProbe) {
|
|
1042
1046
|
this.#activeCausalCellProbes += 1;
|
|
1043
1047
|
let settled = false;
|
|
@@ -1061,6 +1065,13 @@ class SessionRuntime {
|
|
|
1061
1065
|
this.#outputTraces.arm(semanticPaneId, trace);
|
|
1062
1066
|
}
|
|
1063
1067
|
try {
|
|
1068
|
+
// Synchronous final admission: never create a new owner to satisfy a stale
|
|
1069
|
+
// held-input fence, and never await between this check and mirror dispatch.
|
|
1070
|
+
if (expectedLifetime &&
|
|
1071
|
+
(expectedLifetime.generation !== this.generation ||
|
|
1072
|
+
this.#terminalReplicas.get(semanticPaneId) !== replicaOwner ||
|
|
1073
|
+
replicaOwner.inputAdmissionIncarnation() !== expectedLifetime.incarnation))
|
|
1074
|
+
throw new Error("Input target lifetime changed");
|
|
1064
1075
|
if (input.kind === "text")
|
|
1065
1076
|
this.#mirror.sendText(this.session, semanticPaneId, input.data, performanceTraceId, causalProbe !== null);
|
|
1066
1077
|
else if (input.kind === "bytes")
|
|
@@ -1564,9 +1575,9 @@ class SessionRuntimeConsumerImpl {
|
|
|
1564
1575
|
this.#assertOpen();
|
|
1565
1576
|
return this.#runtime.submitIntent(this.clientId, lease, operationId, intent);
|
|
1566
1577
|
}
|
|
1567
|
-
sendInput(lease, semanticPaneId, input, performanceTraceId, causalProbe, onCausalResult) {
|
|
1578
|
+
sendInput(lease, semanticPaneId, input, performanceTraceId, causalProbe, onCausalResult, expectedLifetime) {
|
|
1568
1579
|
this.#assertOpen();
|
|
1569
|
-
this.#runtime.sendInput(this.clientId, lease, semanticPaneId, input, performanceTraceId, causalProbe, onCausalResult);
|
|
1580
|
+
this.#runtime.sendInput(this.clientId, lease, semanticPaneId, input, performanceTraceId, causalProbe, onCausalResult, expectedLifetime);
|
|
1570
1581
|
}
|
|
1571
1582
|
failCausalCellProbe(semanticPaneId, traceId, reason) {
|
|
1572
1583
|
this.#runtime.failCausalCellProbe(semanticPaneId, traceId, reason);
|
|
@@ -7,6 +7,9 @@ import { DISABLED_SESSION_RUNTIME_OBSERVABILITY, } from "./runtime-observability
|
|
|
7
7
|
import { createXtermTerminalInterpreterBackend } from "./xterm-terminal-interpreter-backend.js";
|
|
8
8
|
import { CAUSAL_CELL_OSC, CausalCellLedger, cellsEqual, } from "./causal-cell-ledger.js";
|
|
9
9
|
import { KITTY_PLACEMENT_OSC, KittyApcScanner, KittyImageStore, } from "../graphics/kitty-graphics.js";
|
|
10
|
+
// Bound parser work per turn without changing byte-stream semantics. This is a
|
|
11
|
+
// batch bound, not a producer queue cap: upstream pressure must preserve bytes.
|
|
12
|
+
const MAX_INGEST_BATCH_BYTES = 256 * 1024;
|
|
10
13
|
/** Daemon-owned, sans-I/O VT interpreter with one FIFO for bytes and geometry. */
|
|
11
14
|
export class TerminalReplicaInterpreter {
|
|
12
15
|
#nativeSeedBackingCandidate;
|
|
@@ -35,8 +38,7 @@ export class TerminalReplicaInterpreter {
|
|
|
35
38
|
#needsSeed = true;
|
|
36
39
|
#closed = false;
|
|
37
40
|
#walkCount = 0;
|
|
38
|
-
#pendingWrites =
|
|
39
|
-
#writeFlushScheduled = false;
|
|
41
|
+
#pendingWrites = null;
|
|
40
42
|
#widgetGate = false;
|
|
41
43
|
// kitty graphics: images outlive reseeds; the scanner owns split APCs.
|
|
42
44
|
#kittyScanner = new KittyApcScanner();
|
|
@@ -106,16 +108,25 @@ export class TerminalReplicaInterpreter {
|
|
|
106
108
|
if (operation.type === "write") {
|
|
107
109
|
const data = operation.data.slice();
|
|
108
110
|
const trace = operation.trace ?? null;
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
(pendingTrace?.traceId ?? null) !== (trace?.traceId ?? null))
|
|
111
|
+
if (this.#pendingWrites &&
|
|
112
|
+
(this.#pendingWrites.trace?.traceId ?? null) !== (trace?.traceId ?? null))
|
|
112
113
|
this.#flushWrites();
|
|
114
|
+
const group = (this.#pendingWrites ??= {
|
|
115
|
+
entries: [],
|
|
116
|
+
bytes: 0,
|
|
117
|
+
scheduled: false,
|
|
118
|
+
flushQueued: false,
|
|
119
|
+
trace,
|
|
120
|
+
});
|
|
113
121
|
const promise = new Promise((resolve, reject) => {
|
|
114
|
-
|
|
122
|
+
group.entries.push({ data, offset: 0, resolve, reject });
|
|
123
|
+
group.bytes += data.length;
|
|
115
124
|
});
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
125
|
+
// Leave this group open while the parser awaits its callback. Scheduling
|
|
126
|
+
// it once on the FIFO lets later arrivals join the next bounded batch.
|
|
127
|
+
if (!group.scheduled && !group.flushQueued) {
|
|
128
|
+
group.flushQueued = true;
|
|
129
|
+
this.#scheduler.microtask(() => this.#scheduleWriteGroup(group));
|
|
119
130
|
}
|
|
120
131
|
return promise;
|
|
121
132
|
}
|
|
@@ -361,22 +372,26 @@ export class TerminalReplicaInterpreter {
|
|
|
361
372
|
// serialize dozens of turns and fall behind a 30 fps image stream.
|
|
362
373
|
if (scanned.forward.byteLength === 0)
|
|
363
374
|
return;
|
|
364
|
-
|
|
375
|
+
// Scanning can prepend an escape retained from the previous chunk. Bound
|
|
376
|
+
// the actual parser calls too, and union damage across every fragment.
|
|
377
|
+
for (let offset = 0; offset < scanned.forward.length; offset += MAX_INGEST_BATCH_BYTES) {
|
|
378
|
+
await this.#writeToBackend(this.#backend, scanned.forward.subarray(offset, offset + MAX_INGEST_BATCH_BYTES));
|
|
379
|
+
// The parser resets its dirty tracker per write. Synchronized output and
|
|
380
|
+
// bounded fragments can both span writes before canonical publication.
|
|
381
|
+
const dirty = this.#backend.dirtyRange();
|
|
382
|
+
const pending = this.#pendingWriteDirty;
|
|
383
|
+
this.#pendingWriteDirty =
|
|
384
|
+
pending === null
|
|
385
|
+
? dirty
|
|
386
|
+
: pending === undefined || dirty === undefined
|
|
387
|
+
? undefined
|
|
388
|
+
: {
|
|
389
|
+
start: Math.min(pending.start, dirty.start),
|
|
390
|
+
end: Math.max(pending.end, dirty.end),
|
|
391
|
+
};
|
|
392
|
+
}
|
|
365
393
|
if (this.#observability.enabled)
|
|
366
394
|
this.#observability.recordSpan("parse", "terminal-replica-write", parseStarted, this.#observability.nowMicros(), trace);
|
|
367
|
-
// The parser resets its dirty tracker per write. Synchronized output may
|
|
368
|
-
// span many writes before publication, so retain their union until commit.
|
|
369
|
-
const dirty = this.#backend.dirtyRange();
|
|
370
|
-
const pending = this.#pendingWriteDirty;
|
|
371
|
-
this.#pendingWriteDirty =
|
|
372
|
-
pending === null
|
|
373
|
-
? dirty
|
|
374
|
-
: pending === undefined || dirty === undefined
|
|
375
|
-
? undefined
|
|
376
|
-
: {
|
|
377
|
-
start: Math.min(pending.start, dirty.start),
|
|
378
|
-
end: Math.max(pending.end, dirty.end),
|
|
379
|
-
};
|
|
380
395
|
// DEC synchronized-output is atomic: no intermediate frame leaks.
|
|
381
396
|
if (this.#backend.requiresNativeReseed()) {
|
|
382
397
|
this.#commit(false);
|
|
@@ -402,6 +417,7 @@ export class TerminalReplicaInterpreter {
|
|
|
402
417
|
return;
|
|
403
418
|
this.#syncRecovery = this.#scheduler.timer(() => {
|
|
404
419
|
this.#syncRecovery = null;
|
|
420
|
+
this.#flushWrites();
|
|
405
421
|
const run = this.#tail.then(async () => {
|
|
406
422
|
if (this.#closed || !this.#backend.modes().synchronizedOutput)
|
|
407
423
|
return;
|
|
@@ -661,22 +677,65 @@ export class TerminalReplicaInterpreter {
|
|
|
661
677
|
}
|
|
662
678
|
}
|
|
663
679
|
}
|
|
680
|
+
/** Seal the current group at every trace, control and idle barrier. */
|
|
664
681
|
#flushWrites() {
|
|
665
|
-
|
|
666
|
-
|
|
682
|
+
const group = this.#pendingWrites;
|
|
683
|
+
this.#pendingWrites = null;
|
|
684
|
+
if (group)
|
|
685
|
+
this.#scheduleWriteGroup(group);
|
|
686
|
+
}
|
|
687
|
+
#scheduleWriteGroup(group) {
|
|
688
|
+
if (group.scheduled)
|
|
667
689
|
return;
|
|
668
|
-
|
|
669
|
-
const
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
690
|
+
group.scheduled = true;
|
|
691
|
+
const run = this.#tail.then(async () => {
|
|
692
|
+
try {
|
|
693
|
+
while (group.entries.length > 0) {
|
|
694
|
+
const length = Math.min(group.bytes, MAX_INGEST_BATCH_BYTES);
|
|
695
|
+
const data = new Uint8Array(length);
|
|
696
|
+
const completed = [];
|
|
697
|
+
let offset = 0;
|
|
698
|
+
for (const entry of group.entries) {
|
|
699
|
+
const take = Math.min(entry.data.length - entry.offset, length - offset);
|
|
700
|
+
data.set(entry.data.subarray(entry.offset, entry.offset + take), offset);
|
|
701
|
+
offset += take;
|
|
702
|
+
entry.offset += take;
|
|
703
|
+
if (entry.offset === entry.data.length)
|
|
704
|
+
completed.push(entry);
|
|
705
|
+
else
|
|
706
|
+
break;
|
|
707
|
+
if (offset === length && length > 0)
|
|
708
|
+
break;
|
|
709
|
+
}
|
|
710
|
+
group.entries.splice(0, completed.length);
|
|
711
|
+
group.bytes -= length;
|
|
712
|
+
try {
|
|
713
|
+
await this.#write(data, group.trace);
|
|
714
|
+
}
|
|
715
|
+
catch (error) {
|
|
716
|
+
for (const entry of completed)
|
|
717
|
+
entry.reject(error);
|
|
718
|
+
throw error;
|
|
719
|
+
}
|
|
720
|
+
for (const entry of completed)
|
|
721
|
+
entry.resolve();
|
|
722
|
+
}
|
|
723
|
+
}
|
|
724
|
+
catch (error) {
|
|
725
|
+
// Reject every remaining fragment in the failed group; later FIFO
|
|
726
|
+
// controls (including recovery reseeds and close) still execute.
|
|
727
|
+
for (const entry of group.entries)
|
|
728
|
+
entry.reject(error);
|
|
729
|
+
group.entries.length = 0;
|
|
730
|
+
group.bytes = 0;
|
|
731
|
+
throw error;
|
|
732
|
+
}
|
|
733
|
+
finally {
|
|
734
|
+
if (this.#pendingWrites === group)
|
|
735
|
+
this.#pendingWrites = null;
|
|
736
|
+
}
|
|
737
|
+
});
|
|
678
738
|
this.#tail = run.catch(() => undefined);
|
|
679
|
-
void run.then(() => pending.forEach((entry) => entry.resolve()), (error) => pending.forEach((entry) => entry.reject(error)));
|
|
680
739
|
}
|
|
681
740
|
#observeMarkerBytes(data) {
|
|
682
741
|
if (this.#widgetGate)
|
|
@@ -155,6 +155,12 @@ export class SessionRuntimeTerminalReplicaOwner {
|
|
|
155
155
|
inputDispatchIncarnation() {
|
|
156
156
|
return this.#disposed ? null : this.#publishedIncarnation;
|
|
157
157
|
}
|
|
158
|
+
/** Scalar admission fence; unavailable while canonical replacement is pending. */
|
|
159
|
+
inputAdmissionIncarnation() {
|
|
160
|
+
return this.#disposed || this.#reseed || this.#waitingForGeometryCapture
|
|
161
|
+
? null
|
|
162
|
+
: this.#publishedIncarnation;
|
|
163
|
+
}
|
|
158
164
|
qualificationSnapshot() {
|
|
159
165
|
const seed = this.#interpreter.currentSeed();
|
|
160
166
|
return Object.freeze({
|
|
@@ -275,7 +275,7 @@ export class SessionRuntimeTransportBinding {
|
|
|
275
275
|
authorize();
|
|
276
276
|
return authorize;
|
|
277
277
|
}
|
|
278
|
-
sendInput(semanticPaneId, input, performanceTraceId, causalProbe, onCausalResult) {
|
|
278
|
+
sendInput(semanticPaneId, input, performanceTraceId, causalProbe, onCausalResult, expectedLifetime) {
|
|
279
279
|
this.assertController(semanticPaneId);
|
|
280
280
|
const lease = this.#shared.lease;
|
|
281
281
|
if (!lease) {
|
|
@@ -289,7 +289,7 @@ export class SessionRuntimeTransportBinding {
|
|
|
289
289
|
this.#causalCellProbes.delete(causalProbe.traceId);
|
|
290
290
|
onCausalResult?.(result);
|
|
291
291
|
}
|
|
292
|
-
: onCausalResult);
|
|
292
|
+
: onCausalResult, expectedLifetime);
|
|
293
293
|
}
|
|
294
294
|
catch (error) {
|
|
295
295
|
if (causalProbe)
|
|
@@ -67,8 +67,23 @@ export function createApplicationAgentNavigator(options) {
|
|
|
67
67
|
return async (sessionName, paneId, source = "mouse") => {
|
|
68
68
|
const token = ++navigationToken;
|
|
69
69
|
const currentOwner = options.sessionOwner();
|
|
70
|
+
const snapshot = currentOwner.snapshot();
|
|
71
|
+
// A renderer rebind keeps its semantic control connection. Reserve this
|
|
72
|
+
// exact target synchronously; the controller fences and bounds first send.
|
|
73
|
+
const retainedControl = (() => {
|
|
74
|
+
try {
|
|
75
|
+
return (snapshot?.status === "rebinding" &&
|
|
76
|
+
snapshot.connection?.workspaceName === sessionName &&
|
|
77
|
+
snapshot.daemonGeneration &&
|
|
78
|
+
snapshot.client &&
|
|
79
|
+
Number.isSafeInteger(snapshot.client.getSnapshot().generation));
|
|
80
|
+
}
|
|
81
|
+
catch {
|
|
82
|
+
return false;
|
|
83
|
+
}
|
|
84
|
+
})();
|
|
70
85
|
if (currentOwner.sessionName() === sessionName &&
|
|
71
|
-
applicationGenerationNavigationKey(
|
|
86
|
+
(applicationGenerationNavigationKey(snapshot) !== null || retainedControl)) {
|
|
72
87
|
// Sidebar agents belong to the already-live workspace. Select their pane
|
|
73
88
|
// before returning control to OpenTUI so the very next key cannot leak to
|
|
74
89
|
// the pane that was focused before the click.
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { retainsAgentControl } from "./application-retained-agent.js";
|
|
1
2
|
import { createApplicationHomeAgentNavigator, } from "./application-home-agent-navigation.js";
|
|
2
3
|
import { applicationGenerationNavigationKey } from "./application-generation-starter.js";
|
|
3
4
|
import { waitForHomeAgentSemantic } from "./application-home-agents-owner.js";
|
|
@@ -16,6 +17,18 @@ export function createApplicationMachineAgentNavigator(options) {
|
|
|
16
17
|
opening = true;
|
|
17
18
|
const selected = () => options.selectedMachineId() === machineId;
|
|
18
19
|
const generation = () => selected() && options.generationMachineId() === machineId ? options.generation() : null;
|
|
20
|
+
if (selected() &&
|
|
21
|
+
options.isCurrentTarget(machineId, target) &&
|
|
22
|
+
options.sessionName() === target.sessionName &&
|
|
23
|
+
retainsAgentControl(generation(), target)) {
|
|
24
|
+
// Reserve before the next key; the terminal controller waits for a fresh
|
|
25
|
+
// coherent runtime and remains the only owner of physical input.
|
|
26
|
+
options.selectPane(target.paneId, source);
|
|
27
|
+
options.showTerminals(source);
|
|
28
|
+
options.setNote(null);
|
|
29
|
+
opening = false;
|
|
30
|
+
return Promise.resolve({ opened: true });
|
|
31
|
+
}
|
|
19
32
|
current = createApplicationHomeAgentNavigator({
|
|
20
33
|
isCurrentTarget: (row) => selected() && options.isCurrentTarget(machineId, row),
|
|
21
34
|
currentGeneration() {
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { retainsAgentControl } from "./application-retained-agent.js";
|
|
1
2
|
import { fleetHostColor } from "./fleet-presentation.js";
|
|
2
3
|
import { createFleetTabs } from "./application-fleet-tabs.js";
|
|
3
4
|
import { saveMachineProfiles } from "../../../lib/local-fleet-request.js";
|
|
@@ -64,7 +65,7 @@ export function createApplicationMachineNavigation(options) {
|
|
|
64
65
|
const [tabRevision, setTabRevision] = createSignal(0);
|
|
65
66
|
const history = [...saved().recent].reverse();
|
|
66
67
|
let historyIndex = history.length - 1;
|
|
67
|
-
const select = (id, expectedLiveSessionId, server) => {
|
|
68
|
+
const select = (id, expectedLiveSessionId, server, retainedAgent) => {
|
|
68
69
|
if (!manager.getMachine(id))
|
|
69
70
|
return false;
|
|
70
71
|
if (manager.snapshot().selectedMachineId !== id) {
|
|
@@ -78,8 +79,15 @@ export function createApplicationMachineNavigation(options) {
|
|
|
78
79
|
manager.select(id);
|
|
79
80
|
}
|
|
80
81
|
else if (expectedLiveSessionId) {
|
|
81
|
-
const
|
|
82
|
-
|
|
82
|
+
const active = options.attachedGeneration?.() ?? null;
|
|
83
|
+
const retained = retainedAgent && retainsAgentControl(active, retainedAgent)
|
|
84
|
+
? {
|
|
85
|
+
liveSessionId: active.connection.liveSessionId,
|
|
86
|
+
daemonGeneration: active.daemonGeneration,
|
|
87
|
+
...(active.connection.server ? { server: active.connection.server } : {}),
|
|
88
|
+
}
|
|
89
|
+
: null;
|
|
90
|
+
const attached = options.attachedTarget?.() ?? attachedMachineNavigationTarget(active) ?? retained;
|
|
83
91
|
const sameAuthority = attached?.liveSessionId === expectedLiveSessionId &&
|
|
84
92
|
(server
|
|
85
93
|
? attached.server
|
|
@@ -302,7 +310,7 @@ export function createApplicationMachineNavigation(options) {
|
|
|
302
310
|
return;
|
|
303
311
|
}
|
|
304
312
|
options.cancelOpen();
|
|
305
|
-
if (!select(id, selectedSession?.liveSessionId, selectedSession?.server))
|
|
313
|
+
if (!select(id, selectedSession?.liveSessionId, selectedSession?.server, row))
|
|
306
314
|
return;
|
|
307
315
|
if (selectedSession)
|
|
308
316
|
setActiveSessionKey(selectedSession.id);
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
/** Selection intent may wait on this exact retained control; input cannot use it yet. */
|
|
2
|
+
export function retainsAgentControl(active, target) {
|
|
3
|
+
if (active?.status !== "rebinding" ||
|
|
4
|
+
!active.client ||
|
|
5
|
+
!target.paneId ||
|
|
6
|
+
active.daemonGeneration !== target.daemonInstanceId ||
|
|
7
|
+
active.connection?.workspaceName !== target.sessionName ||
|
|
8
|
+
active.connection.liveSessionId !== target.liveSessionId)
|
|
9
|
+
return false;
|
|
10
|
+
try {
|
|
11
|
+
const state = active.client.getSnapshot();
|
|
12
|
+
return (Number.isSafeInteger(state.generation) &&
|
|
13
|
+
state.semantic?.sidebar.agents.some((agent) => agent.id === target.agentId && agent.paneId === target.paneId) === true);
|
|
14
|
+
}
|
|
15
|
+
catch {
|
|
16
|
+
return false;
|
|
17
|
+
}
|
|
18
|
+
}
|