tmux-ide 2.9.0-beta.21 → 2.9.0-beta.26
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 +46791 -43589
- package/package.json +2 -2
- package/packages/contracts/src/__tests__/daemon-wire.test.ts +2 -2
- package/packages/contracts/src/__tests__/fixtures/daemon-wire-v3.json +35 -0
- package/packages/contracts/src/__tests__/issue-error.test.ts +1 -1
- package/packages/contracts/src/__tests__/multiplexer-verbs.test.ts +3 -1
- package/packages/contracts/src/__tests__/pane-stream.test.ts +17 -5
- package/packages/contracts/src/__tests__/tmux-server-scope.test.ts +166 -0
- package/packages/contracts/src/__tests__/window-links.test.ts +243 -0
- package/packages/contracts/src/actions-contract.ts +12 -0
- package/packages/contracts/src/daemon-resource-request.test.ts +1 -1
- package/packages/contracts/src/daemon-wire.ts +10 -5
- package/packages/contracts/src/fleet-client-state.ts +3 -0
- package/packages/contracts/src/fleet-lifecycle.ts +5 -0
- package/packages/contracts/src/index.ts +2 -0
- package/packages/contracts/src/interaction-receipts.ts +39 -0
- package/packages/contracts/src/issue-error.ts +1 -1
- package/packages/contracts/src/multiplexer-verbs.ts +25 -1
- package/packages/contracts/src/pane-stream.ts +51 -8
- package/packages/contracts/src/tmux-server-scope.ts +172 -0
- package/packages/contracts/src/window-links.ts +76 -0
- package/packages/contracts/src/workspace-multiplexer.ts +54 -0
- package/packages/core/src/interaction-receipts.ts +4 -0
- package/packages/daemon/dist/command-center/actions/command-definitions.js +6 -0
- package/packages/daemon/dist/command-center/actions/handlers/workspace-multiplexer.js +2 -0
- package/packages/daemon/dist/command-center/actions/registry.js +13 -1
- package/packages/daemon/dist/command-center/discovery.js +2 -9
- package/packages/daemon/dist/command-center/server.js +21 -1
- package/packages/daemon/dist/command-center/tmux-server-native-backing.js +37 -0
- package/packages/daemon/dist/command-center/tmux-servers.js +351 -0
- package/packages/daemon/dist/lib/canonical-daemon-bootstrap.js +64 -8
- package/packages/daemon/dist/lib/canonical-daemon.js +5 -2
- package/packages/daemon/dist/lib/daemon-embed.js +198 -30
- package/packages/daemon/dist/lib/embedded-tmux-server-owners.js +147 -0
- package/packages/daemon/dist/lib/fleet-lifecycle-authority.js +31 -11
- package/packages/daemon/dist/lib/headless-daemon.js +10 -1
- package/packages/daemon/dist/lib/runtime-namespace.js +17 -3
- package/packages/daemon/dist/lib/tmux-client-execution.js +59 -0
- package/packages/daemon/dist/lib/tmux-named-socket-fence.js +4 -0
- package/packages/daemon/dist/lib/tmux-server-generation-runner.js +62 -0
- package/packages/daemon/dist/lib/tmux-server-owner.js +285 -0
- package/packages/daemon/dist/lib/tmux-server-owners.js +192 -0
- package/packages/daemon/dist/lib/tmux-server-proof.js +82 -0
- package/packages/daemon/dist/lib/tmux-server-registration.js +85 -0
- package/packages/daemon/dist/lib/tmux-server-session-create.js +43 -0
- package/packages/daemon/dist/lib/tmux-server-session-open.js +101 -0
- package/packages/daemon/dist/lib/tmux-servers-cli.js +134 -0
- package/packages/daemon/dist/lib/tmux-session-mutation-fence.js +55 -0
- package/packages/daemon/dist/lib/tmux-window-link-guard.js +88 -0
- package/packages/daemon/dist/lib/workspace-config-loader.js +3 -0
- package/packages/daemon/dist/lib/workspace-multiplexer-verbs.js +82 -1
- package/packages/daemon/dist/lib/workspace-pane-creation.js +13 -57
- package/packages/daemon/dist/lib/workspace-promotion.js +91 -12
- package/packages/daemon/dist/lib/workspace-registry.js +4 -0
- package/packages/daemon/dist/server/pane-stream-upgrade.js +20 -6
- package/packages/daemon/dist/terminal/attachments/native-runtime.js +61 -17
- package/packages/daemon/dist/terminal/mirror/__tests__/scripted-channel.js +9 -1
- package/packages/daemon/dist/terminal/mirror/__tests__/simulated-channel.js +11 -2
- package/packages/daemon/dist/terminal/mirror/control-channel.js +65 -6
- package/packages/daemon/dist/terminal/mirror/mirror-service.js +44 -0
- package/packages/daemon/dist/terminal/mirror/session-channel.js +210 -50
- package/packages/daemon/dist/terminal/mirror/window-link-authority.js +160 -0
- package/packages/daemon/dist/terminal/pane-stream/lease-manager.js +3 -3
- package/packages/daemon/dist/terminal/pane-stream/pane-stream-websocket.js +40 -6
- package/packages/daemon/dist/terminal/protocol/live-session-identity.js +9 -0
- package/packages/daemon/dist/terminal/protocol/native-backing-client.js +2 -1
- package/packages/daemon/dist/terminal/session-runtime/interaction-receipt-facts.js +9 -0
- package/packages/daemon/dist/terminal/session-runtime/registry.js +6 -0
- package/packages/daemon/dist/terminal/session-runtime/semantic-mutation-executor.js +1 -1
- package/packages/daemon/dist/terminal/session-runtime/semantic-mutation-resource-changes.js +1 -0
- package/packages/daemon/dist/tui/mirror/application-shell-daemon-connection.js +7 -2
- package/packages/daemon/dist/tui/mirror/application-shell-server-connection.js +114 -0
- package/packages/daemon/dist/tui/mirror/menu-model.js +1 -1
- package/packages/daemon/dist/tui/mirror/open-tui-verified-routing.js +34 -4
- package/packages/daemon/dist/tui/mirror/open-tui-workspace-runtime-port.js +29 -8
- package/packages/daemon/dist/tui/mirror/runtime/application-guided-tour-integration.jsx +119 -0
- package/packages/daemon/dist/tui/mirror/runtime/application-guided-tour-owner.js +132 -0
- package/packages/daemon/dist/tui/mirror/runtime/application-home-agent-roster.jsx +21 -9
- package/packages/daemon/dist/tui/mirror/runtime/application-home-agent-transport.js +63 -1
- package/packages/daemon/dist/tui/mirror/runtime/application-home-agents-owner.js +8 -2
- package/packages/daemon/dist/tui/mirror/runtime/application-home-agents.js +3 -1
- package/packages/daemon/dist/tui/mirror/runtime/application-home-catalog-owner.js +11 -5
- package/packages/daemon/dist/tui/mirror/runtime/application-home-catalog.js +140 -75
- package/packages/daemon/dist/tui/mirror/runtime/application-home-experience.js +40 -0
- package/packages/daemon/dist/tui/mirror/runtime/application-home-fleet.js +132 -0
- package/packages/daemon/dist/tui/mirror/runtime/application-machine-agents.js +9 -0
- package/packages/daemon/dist/tui/mirror/runtime/application-machine-catalog.js +12 -2
- package/packages/daemon/dist/tui/mirror/runtime/application-machine-navigation.js +211 -31
- package/packages/daemon/dist/tui/mirror/runtime/application-machine-sidebar.jsx +91 -37
- package/packages/daemon/dist/tui/mirror/runtime/application-performance-log.js +20 -7
- package/packages/daemon/dist/tui/mirror/runtime/application-root-configuration.js +9 -3
- package/packages/daemon/dist/tui/mirror/runtime/application-route-connection.js +95 -2
- package/packages/daemon/dist/tui/mirror/runtime/application-shell-home.jsx +6 -2
- package/packages/daemon/dist/tui/mirror/runtime/application-terminal-interaction-controller.js +158 -2
- package/packages/daemon/dist/tui/mirror/runtime/application-terminal-workspace-policy.js +43 -0
- package/packages/daemon/dist/tui/mirror/runtime/application-terminal-workspace.jsx +78 -25
- package/packages/daemon/dist/tui/mirror/runtime/application-theme-repaint.js +9 -0
- package/packages/daemon/dist/tui/mirror/runtime/fleet-lifecycle-client.js +44 -1
- package/packages/daemon/dist/tui/mirror/runtime/guided-tour-coach.jsx +54 -0
- package/packages/daemon/dist/tui/mirror/runtime/guided-tour-storage.js +67 -0
- package/packages/daemon/dist/tui/mirror/runtime/guided-tour.js +113 -0
- package/packages/daemon/dist/tui/mirror/runtime/open-tui-generation-host.js +1 -1
- package/packages/daemon/dist/tui/mirror/runtime/resize-terminal-pane.js +4 -0
- package/packages/daemon/dist/tui/mirror/runtime/select-terminal-pane.js +2 -1
- package/packages/daemon/dist/tui/mirror/runtime/semantic-shell-viewport-resize.js +12 -4
- package/packages/daemon/dist/tui/mirror/runtime/terminal-pane-input-router.js +6 -3
- package/packages/daemon/dist/tui/mirror/workspace/application-command-description.js +11 -2
- package/packages/daemon/dist/tui/mirror/workspace/terminal-window-strip.jsx +2 -1
- package/packages/daemon/src/command-center/actions/command-definitions.ts +6 -0
- package/packages/daemon/src/command-center/actions/errors.ts +4 -0
- package/packages/daemon/src/command-center/actions/handlers/workspace-multiplexer.ts +2 -0
- package/packages/daemon/src/command-center/actions/registry.ts +14 -0
- package/packages/daemon/src/command-center/discovery.ts +2 -10
- package/packages/daemon/src/command-center/server.ts +29 -1
- package/packages/daemon/src/command-center/tmux-server-native-backing.ts +49 -0
- package/packages/daemon/src/command-center/tmux-servers.ts +479 -0
- package/packages/daemon/src/lib/canonical-daemon-bootstrap.ts +111 -4
- package/packages/daemon/src/lib/canonical-daemon.ts +15 -3
- package/packages/daemon/src/lib/daemon-embed.ts +241 -28
- package/packages/daemon/src/lib/embedded-tmux-server-owners.ts +190 -0
- package/packages/daemon/src/lib/fleet-lifecycle-authority.ts +33 -9
- package/packages/daemon/src/lib/headless-daemon.ts +18 -1
- package/packages/daemon/src/lib/runtime-namespace.ts +24 -3
- package/packages/daemon/src/lib/tmux-client-execution.ts +56 -0
- package/packages/daemon/src/lib/tmux-named-socket-fence.ts +5 -0
- package/packages/daemon/src/lib/tmux-server-generation-runner.ts +81 -0
- package/packages/daemon/src/lib/tmux-server-owner.ts +336 -0
- package/packages/daemon/src/lib/tmux-server-owners.ts +228 -0
- package/packages/daemon/src/lib/tmux-server-proof.ts +95 -0
- package/packages/daemon/src/lib/tmux-server-registration.ts +105 -0
- package/packages/daemon/src/lib/tmux-server-session-create.ts +52 -0
- package/packages/daemon/src/lib/tmux-server-session-open.ts +106 -0
- package/packages/daemon/src/lib/tmux-servers-cli.ts +186 -0
- package/packages/daemon/src/lib/tmux-session-mutation-fence.ts +62 -0
- package/packages/daemon/src/lib/tmux-window-link-guard.ts +112 -0
- package/packages/daemon/src/lib/workspace-config-loader.ts +3 -0
- package/packages/daemon/src/lib/workspace-multiplexer-verbs.ts +121 -1
- package/packages/daemon/src/lib/workspace-pane-creation.ts +16 -55
- package/packages/daemon/src/lib/workspace-promotion.ts +98 -12
- package/packages/daemon/src/lib/workspace-registry.ts +5 -0
- package/packages/daemon/src/server/pane-stream-upgrade.ts +25 -5
- package/packages/daemon/src/terminal/attachments/native-runtime.ts +70 -16
- package/packages/daemon/src/terminal/mirror/control-channel.ts +64 -7
- package/packages/daemon/src/terminal/mirror/events.ts +2 -0
- package/packages/daemon/src/terminal/mirror/mirror-service.ts +56 -0
- package/packages/daemon/src/terminal/mirror/session-channel.ts +245 -50
- package/packages/daemon/src/terminal/mirror/window-link-authority.ts +203 -0
- package/packages/daemon/src/terminal/pane-stream/lease-manager.ts +3 -3
- package/packages/daemon/src/terminal/pane-stream/pane-stream-websocket.ts +50 -6
- package/packages/daemon/src/terminal/protocol/live-session-identity.ts +14 -0
- package/packages/daemon/src/terminal/protocol/native-backing-client.ts +4 -1
- package/packages/daemon/src/terminal/session-runtime/interaction-receipt-facts.ts +9 -0
- package/packages/daemon/src/terminal/session-runtime/registry.ts +9 -0
- package/packages/daemon/src/terminal/session-runtime/semantic-mutation-executor.ts +2 -2
- package/packages/daemon/src/terminal/session-runtime/semantic-mutation-resource-changes.ts +1 -0
- package/packages/daemon/src/tui/mirror/application-shell-daemon-connection.ts +28 -2
- package/packages/daemon/src/tui/mirror/application-shell-server-connection.ts +154 -0
- package/packages/daemon/src/tui/mirror/menu-model.ts +1 -1
- package/packages/daemon/src/tui/mirror/open-tui-verified-routing.ts +41 -3
- package/packages/daemon/src/tui/mirror/open-tui-workspace-runtime-port.ts +47 -6
- package/packages/daemon/src/tui/mirror/runtime/application-entry.ts +2 -1
- package/packages/daemon/src/tui/mirror/runtime/application-fleet-session-actions.tsx +10 -4
- package/packages/daemon/src/tui/mirror/runtime/application-fleet-tabs.ts +2 -0
- package/packages/daemon/src/tui/mirror/runtime/application-guided-tour-integration.tsx +199 -0
- package/packages/daemon/src/tui/mirror/runtime/application-guided-tour-owner.ts +162 -0
- package/packages/daemon/src/tui/mirror/runtime/application-home-agent-roster.tsx +36 -10
- package/packages/daemon/src/tui/mirror/runtime/application-home-agent-transport.ts +69 -0
- package/packages/daemon/src/tui/mirror/runtime/application-home-agents-owner.ts +12 -2
- package/packages/daemon/src/tui/mirror/runtime/application-home-agents.ts +13 -2
- package/packages/daemon/src/tui/mirror/runtime/application-home-catalog-owner.ts +13 -3
- package/packages/daemon/src/tui/mirror/runtime/application-home-catalog.ts +172 -100
- package/packages/daemon/src/tui/mirror/runtime/application-home-experience.ts +59 -0
- package/packages/daemon/src/tui/mirror/runtime/application-home-fleet.ts +175 -0
- package/packages/daemon/src/tui/mirror/runtime/application-machine-agents.ts +9 -1
- package/packages/daemon/src/tui/mirror/runtime/application-machine-catalog.ts +19 -7
- package/packages/daemon/src/tui/mirror/runtime/application-machine-navigation.ts +285 -36
- package/packages/daemon/src/tui/mirror/runtime/application-machine-overlays.tsx +1 -1
- package/packages/daemon/src/tui/mirror/runtime/application-machine-sidebar.tsx +124 -40
- package/packages/daemon/src/tui/mirror/runtime/application-palette-preview.tsx +3 -0
- package/packages/daemon/src/tui/mirror/runtime/application-performance-log.ts +18 -6
- package/packages/daemon/src/tui/mirror/runtime/application-root-configuration.ts +12 -3
- package/packages/daemon/src/tui/mirror/runtime/application-root-v2.tsx +40 -40
- package/packages/daemon/src/tui/mirror/runtime/application-route-connection.ts +93 -1
- package/packages/daemon/src/tui/mirror/runtime/application-shell-catalog.tsx +4 -0
- package/packages/daemon/src/tui/mirror/runtime/application-shell-home.tsx +20 -1
- package/packages/daemon/src/tui/mirror/runtime/application-shell-view.tsx +10 -0
- package/packages/daemon/src/tui/mirror/runtime/application-terminal-interaction-controller.ts +177 -2
- package/packages/daemon/src/tui/mirror/runtime/application-terminal-workspace-policy.ts +62 -0
- package/packages/daemon/src/tui/mirror/runtime/application-terminal-workspace.tsx +87 -30
- package/packages/daemon/src/tui/mirror/runtime/application-theme-repaint.ts +19 -0
- package/packages/daemon/src/tui/mirror/runtime/fleet-lifecycle-client.ts +55 -0
- package/packages/daemon/src/tui/mirror/runtime/guided-tour-coach.tsx +118 -0
- package/packages/daemon/src/tui/mirror/runtime/guided-tour-storage.ts +72 -0
- package/packages/daemon/src/tui/mirror/runtime/guided-tour.ts +147 -0
- package/packages/daemon/src/tui/mirror/runtime/open-tui-generation-host.ts +1 -1
- package/packages/daemon/src/tui/mirror/runtime/resize-terminal-pane.ts +3 -0
- package/packages/daemon/src/tui/mirror/runtime/select-terminal-pane.ts +3 -0
- package/packages/daemon/src/tui/mirror/runtime/semantic-shell-viewport-resize.ts +11 -4
- package/packages/daemon/src/tui/mirror/runtime/terminal-pane-input-router.ts +15 -4
- package/packages/daemon/src/tui/mirror/workspace/application-command-description.ts +13 -2
- package/packages/daemon/src/tui/mirror/workspace/terminal-window-strip.tsx +5 -1
- package/packages/daemon-client/package.json +3 -1
- package/packages/daemon-client/src/index.ts +2 -0
- package/packages/daemon-client/src/pane-stream-client.test.ts +108 -38
- package/packages/daemon-client/src/pane-stream-client.ts +26 -0
- package/packages/daemon-client/src/scoped-tmux-server-transport.test.ts +114 -0
- package/packages/daemon-client/src/scoped-tmux-server-transport.ts +385 -0
- package/packages/daemon-client/src/tmux-server-client.test.ts +279 -0
- package/packages/daemon-client/src/tmux-server-client.ts +287 -0
|
@@ -67,6 +67,7 @@ const PANE_VERIFY_FORMAT = [
|
|
|
67
67
|
"#{pane_id}",
|
|
68
68
|
`#{${SEMANTIC_PANE_OPTION}}`,
|
|
69
69
|
`#{${SEMANTIC_WINDOW_OPTION}}`,
|
|
70
|
+
"#{window_index}",
|
|
70
71
|
SENTINEL,
|
|
71
72
|
].join(FIELD);
|
|
72
73
|
const ERROR_MESSAGES = {
|
|
@@ -223,8 +224,10 @@ function parseVerifyPanes(output) {
|
|
|
223
224
|
const fields = line.split(FIELD);
|
|
224
225
|
const windowPaneCount = positiveInteger(fields[3] ?? "");
|
|
225
226
|
const sessionWindowCount = positiveInteger(fields[4] ?? "");
|
|
226
|
-
if (fields.length !==
|
|
227
|
-
fields[
|
|
227
|
+
if (fields.length !== 10 ||
|
|
228
|
+
fields[9] !== SENTINEL ||
|
|
229
|
+
!/^(0|[1-9][0-9]*)$/u.test(fields[8]) ||
|
|
230
|
+
!Number.isSafeInteger(Number(fields[8])) ||
|
|
228
231
|
!/^\$[0-9]+$/u.test(fields[0]) ||
|
|
229
232
|
!/^@[0-9]+$/u.test(fields[1]) ||
|
|
230
233
|
!/^%[0-9]+$/u.test(fields[5]) ||
|
|
@@ -243,6 +246,7 @@ function parseVerifyPanes(output) {
|
|
|
243
246
|
paneId: fields[5],
|
|
244
247
|
semanticPaneId: fields[6],
|
|
245
248
|
semanticWindowId: fields[7],
|
|
249
|
+
windowIndex: Number(fields[8]),
|
|
246
250
|
});
|
|
247
251
|
}
|
|
248
252
|
return panes;
|
|
@@ -368,9 +372,7 @@ export class WorkspacePromotionAuthority {
|
|
|
368
372
|
// promotion is the app's only repair verb — so reconcile the stamps here
|
|
369
373
|
// before resolving to a `replayed` outcome. Stamping is additive and never
|
|
370
374
|
// overwrites a valid stamp, so a healthy session is untouched.
|
|
371
|
-
const alreadyRegistered = this.#
|
|
372
|
-
.list()
|
|
373
|
-
.find((workspace) => workspace.sessionName === session.sessionName);
|
|
375
|
+
const alreadyRegistered = this.#registeredSession(session.sessionName);
|
|
374
376
|
if (alreadyRegistered) {
|
|
375
377
|
const registeredIdentity = {
|
|
376
378
|
workspaceName: alreadyRegistered.name,
|
|
@@ -390,6 +392,39 @@ export class WorkspacePromotionAuthority {
|
|
|
390
392
|
this.#assertActive(request.operationId);
|
|
391
393
|
await this.#verifyPromotedInventory(session.sessionId, identity);
|
|
392
394
|
await this.#publishFleetEnrollment(request, session, identity);
|
|
395
|
+
// Catalog discovery can admit this session while the native stamping
|
|
396
|
+
// and verification awaits run. Reuse that exact registry identity instead
|
|
397
|
+
// of publishing a second name for the same session at the commit point.
|
|
398
|
+
const discovered = this.#registeredSession(session.sessionName);
|
|
399
|
+
if (discovered) {
|
|
400
|
+
if (discovered.name !== identity.workspaceName) {
|
|
401
|
+
try {
|
|
402
|
+
await this.#io.runTmux([
|
|
403
|
+
"set-option",
|
|
404
|
+
"-t",
|
|
405
|
+
session.sessionId,
|
|
406
|
+
SESSION_WORKSPACE_OPTION,
|
|
407
|
+
discovered.name,
|
|
408
|
+
]);
|
|
409
|
+
}
|
|
410
|
+
catch (error) {
|
|
411
|
+
throw new WorkspacePromotionError("stamp_failed", {
|
|
412
|
+
operationId: request.operationId,
|
|
413
|
+
workspaceName: discovered.name,
|
|
414
|
+
}, error);
|
|
415
|
+
}
|
|
416
|
+
}
|
|
417
|
+
this.#assertActive(request.operationId);
|
|
418
|
+
if (this.#registeredSession(session.sessionName)?.name !== discovered.name) {
|
|
419
|
+
throw new WorkspacePromotionError("workspace_conflict", {
|
|
420
|
+
operationId: request.operationId,
|
|
421
|
+
workspaceName: discovered.name,
|
|
422
|
+
});
|
|
423
|
+
}
|
|
424
|
+
return this.#succeed(request, fingerprint, discovered.name, session.sessionName, {
|
|
425
|
+
replayed: true,
|
|
426
|
+
});
|
|
427
|
+
}
|
|
393
428
|
let registered;
|
|
394
429
|
try {
|
|
395
430
|
registered = this.#registry.add({
|
|
@@ -449,6 +484,15 @@ export class WorkspacePromotionAuthority {
|
|
|
449
484
|
throw error;
|
|
450
485
|
}
|
|
451
486
|
}
|
|
487
|
+
#registeredSession(sessionName) {
|
|
488
|
+
const candidates = this.#registry
|
|
489
|
+
.list()
|
|
490
|
+
.filter((workspace) => workspace.sessionName === sessionName);
|
|
491
|
+
if (candidates.length > 1) {
|
|
492
|
+
throw new WorkspacePromotionError("workspace_conflict", { sessionName });
|
|
493
|
+
}
|
|
494
|
+
return candidates[0];
|
|
495
|
+
}
|
|
452
496
|
#assertConflictFreeIdentity(identity) {
|
|
453
497
|
for (const workspace of this.#registry.list()) {
|
|
454
498
|
if (workspace.name === identity.workspaceName &&
|
|
@@ -531,6 +575,19 @@ export class WorkspacePromotionAuthority {
|
|
|
531
575
|
reason: "empty_or_foreign_pane_inventory",
|
|
532
576
|
});
|
|
533
577
|
}
|
|
578
|
+
// list-panes -s repeats each backing pane once per window link. Stamp the
|
|
579
|
+
// backing only once, and reject contradictory observations before writes.
|
|
580
|
+
const uniqueScan = new Map();
|
|
581
|
+
for (const pane of scanned) {
|
|
582
|
+
const previous = uniqueScan.get(pane.paneId);
|
|
583
|
+
if (previous && JSON.stringify(previous) !== JSON.stringify(pane)) {
|
|
584
|
+
throw new WorkspacePromotionError("promotion_verification_failed", {
|
|
585
|
+
reason: "inconsistent_tmux_topology",
|
|
586
|
+
});
|
|
587
|
+
}
|
|
588
|
+
uniqueScan.set(pane.paneId, pane);
|
|
589
|
+
}
|
|
590
|
+
scanned = [...uniqueScan.values()];
|
|
534
591
|
const nowSec = Math.floor(this.#io.now() / 1000);
|
|
535
592
|
const reconciledWindows = new Set();
|
|
536
593
|
try {
|
|
@@ -699,7 +756,29 @@ export class WorkspacePromotionAuthority {
|
|
|
699
756
|
reason: "missing_window_stamp",
|
|
700
757
|
});
|
|
701
758
|
}
|
|
702
|
-
|
|
759
|
+
// Validate links independently from their shared backing. Repeated native
|
|
760
|
+
// panes are legal only across distinct links with identical backing facts.
|
|
761
|
+
const links = new Map();
|
|
762
|
+
const backingPanes = new Map();
|
|
763
|
+
const semanticWindows = new Map();
|
|
764
|
+
for (const pane of panes) {
|
|
765
|
+
const { windowIndex, ...backing } = pane;
|
|
766
|
+
const previous = backingPanes.get(pane.paneId);
|
|
767
|
+
const semanticOwner = semanticWindows.get(pane.semanticWindowId);
|
|
768
|
+
const rows = links.get(windowIndex) ?? [];
|
|
769
|
+
if ((previous && JSON.stringify(previous) !== JSON.stringify(backing)) ||
|
|
770
|
+
(semanticOwner && semanticOwner !== pane.windowId) ||
|
|
771
|
+
rows.some((row) => row.paneId === pane.paneId || row.windowId !== pane.windowId)) {
|
|
772
|
+
throw new WorkspacePromotionError("promotion_verification_failed", {
|
|
773
|
+
reason: "inconsistent_tmux_topology",
|
|
774
|
+
});
|
|
775
|
+
}
|
|
776
|
+
backingPanes.set(pane.paneId, backing);
|
|
777
|
+
semanticWindows.set(pane.semanticWindowId, pane.windowId);
|
|
778
|
+
rows.push(pane);
|
|
779
|
+
links.set(windowIndex, rows);
|
|
780
|
+
}
|
|
781
|
+
const catalog = analyzeTrustedSemanticPaneCatalog([...backingPanes.values()].map((pane) => ({
|
|
703
782
|
workspaceName: identity.workspaceName,
|
|
704
783
|
semanticPaneId: pane.semanticPaneId === "" ? null : pane.semanticPaneId,
|
|
705
784
|
sessionId: pane.sessionId,
|
|
@@ -716,16 +795,16 @@ export class WorkspacePromotionAuthority {
|
|
|
716
795
|
reason: "semantic_pane_catalog_rejected_inventory",
|
|
717
796
|
});
|
|
718
797
|
}
|
|
719
|
-
const
|
|
720
|
-
for (const pane of
|
|
721
|
-
const rows =
|
|
798
|
+
const backings = new Map();
|
|
799
|
+
for (const pane of backingPanes.values()) {
|
|
800
|
+
const rows = backings.get(pane.windowId) ?? [];
|
|
722
801
|
rows.push(pane);
|
|
723
|
-
|
|
802
|
+
backings.set(pane.windowId, rows);
|
|
724
803
|
}
|
|
725
804
|
const sessionWindowCount = panes[0].sessionWindowCount;
|
|
726
|
-
if (
|
|
805
|
+
if (links.size !== sessionWindowCount ||
|
|
727
806
|
panes.some((pane) => pane.sessionWindowCount !== sessionWindowCount) ||
|
|
728
|
-
[...
|
|
807
|
+
[...links.values(), ...backings.values()].some((rows) => rows.length !== rows[0].windowPaneCount ||
|
|
729
808
|
rows.some((row) => row.windowPaneCount !== rows.length ||
|
|
730
809
|
row.windowName !== rows[0].windowName ||
|
|
731
810
|
row.semanticWindowId !== rows[0].semanticWindowId))) {
|
|
@@ -101,6 +101,10 @@ export class WorkspaceRegistry {
|
|
|
101
101
|
has(name) {
|
|
102
102
|
return this.workspaces.some((w) => w.name === name);
|
|
103
103
|
}
|
|
104
|
+
/** Whether this entry is discovered live state, never durable user intent. */
|
|
105
|
+
isVolatile(name) {
|
|
106
|
+
return this.volatileNames.has(name);
|
|
107
|
+
}
|
|
104
108
|
add(input) {
|
|
105
109
|
if (this.has(input.name)) {
|
|
106
110
|
throw new WorkspaceAlreadyExistsError(input.name);
|
|
@@ -20,9 +20,11 @@ function rejectUpgrade(socket, status) {
|
|
|
20
20
|
? "Forbidden"
|
|
21
21
|
: status === 404
|
|
22
22
|
? "Not Found"
|
|
23
|
-
: status ===
|
|
24
|
-
? "
|
|
25
|
-
:
|
|
23
|
+
: status === 410
|
|
24
|
+
? "Gone"
|
|
25
|
+
: status === 426
|
|
26
|
+
? "Upgrade Required"
|
|
27
|
+
: "Service Unavailable";
|
|
26
28
|
try {
|
|
27
29
|
socket.end(`HTTP/1.1 ${status} ${phrase}\r\nConnection: close\r\nContent-Length: 0\r\n\r\n`);
|
|
28
30
|
}
|
|
@@ -36,7 +38,7 @@ function rejectUpgrade(socket, status) {
|
|
|
36
38
|
* Origin, and pre-auth capacity decisions happen before `handleUpgrade`, so
|
|
37
39
|
* rejected requests never become WebSockets.
|
|
38
40
|
*/
|
|
39
|
-
export function attachPaneStreamWebSocket(server, coordinator) {
|
|
41
|
+
export function attachPaneStreamWebSocket(server, coordinator, redemptionPath = PANE_STREAM_REDEEM_PATH, isCurrent = () => true) {
|
|
40
42
|
const wss = new WebSocketServer({
|
|
41
43
|
noServer: true,
|
|
42
44
|
clientTracking: false,
|
|
@@ -52,9 +54,17 @@ export function attachPaneStreamWebSocket(server, coordinator) {
|
|
|
52
54
|
const upgrade = (request, socket, head) => {
|
|
53
55
|
const rawPath = request.url ?? "";
|
|
54
56
|
const pathname = rawPath.split("?", 1)[0] ?? "";
|
|
55
|
-
if (
|
|
57
|
+
if (redemptionPath === PANE_STREAM_REDEEM_PATH &&
|
|
58
|
+
pathname.startsWith("/v1/terminal/pane-streams/")) {
|
|
59
|
+
rejectUpgrade(socket, 426);
|
|
60
|
+
return;
|
|
61
|
+
}
|
|
62
|
+
if (redemptionPath !== PANE_STREAM_REDEEM_PATH && pathname !== redemptionPath)
|
|
56
63
|
return;
|
|
57
|
-
if (
|
|
64
|
+
if (redemptionPath === PANE_STREAM_REDEEM_PATH &&
|
|
65
|
+
!pathname.startsWith("/v2/terminal/pane-streams/"))
|
|
66
|
+
return;
|
|
67
|
+
if (pathname !== redemptionPath) {
|
|
58
68
|
rejectUpgrade(socket, 404);
|
|
59
69
|
return;
|
|
60
70
|
}
|
|
@@ -78,6 +88,10 @@ export function attachPaneStreamWebSocket(server, coordinator) {
|
|
|
78
88
|
rejectUpgrade(socket, 403);
|
|
79
89
|
return;
|
|
80
90
|
}
|
|
91
|
+
if (!isCurrent()) {
|
|
92
|
+
rejectUpgrade(socket, 410);
|
|
93
|
+
return;
|
|
94
|
+
}
|
|
81
95
|
const decision = coordinator.reserveUpgrade({
|
|
82
96
|
path: rawPath,
|
|
83
97
|
protocols: protocols(protocolHeaders[0]),
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { fenceNativeTmuxCommand, } from "../../lib/tmux-server-generation-runner.js";
|
|
1
2
|
import { accessSync, constants, realpathSync, statSync } from "node:fs";
|
|
2
3
|
import { execFile } from "node:child_process";
|
|
3
4
|
import { isAbsolute } from "node:path";
|
|
@@ -91,6 +92,7 @@ function canonicalAuthority(input) {
|
|
|
91
92
|
socketArgv = ["-L", input.socketSelector.name];
|
|
92
93
|
}
|
|
93
94
|
return Object.freeze({
|
|
95
|
+
nativeServerIdentity: input.nativeServerIdentity,
|
|
94
96
|
namedSocketFence: socketSelector.kind === "name" ? (input.namedSocketFence ?? null) : null,
|
|
95
97
|
executablePath,
|
|
96
98
|
socketSelector: Object.freeze(socketSelector),
|
|
@@ -144,13 +146,16 @@ function pinnedRunner(authority, execute, startupPolicy) {
|
|
|
144
146
|
if (command.executable !== "tmux")
|
|
145
147
|
return { status: "failed" };
|
|
146
148
|
try {
|
|
147
|
-
const
|
|
149
|
+
const guarded = authority.nativeServerIdentity
|
|
150
|
+
? fenceNativeTmuxCommand(command.argv, authority.nativeServerIdentity)
|
|
151
|
+
: null;
|
|
152
|
+
const stdout = execute(authority.executablePath, [...currentSocketArgv(authority), ...(guarded?.argv ?? command.argv)], {
|
|
148
153
|
cwd: authority.trustedCwd,
|
|
149
154
|
env: authority.environment,
|
|
150
155
|
maxBuffer: MAX_TMUX_OUTPUT_BYTES,
|
|
151
156
|
timeoutMs: TERMINAL_ATTACHMENT_TMUX_COMMAND_TIMEOUT_MS,
|
|
152
157
|
});
|
|
153
|
-
const value = String(stdout);
|
|
158
|
+
const value = guarded ? guarded.verify(String(stdout)) : String(stdout);
|
|
154
159
|
if (authority.namedSocketFence && !authority.namedSocketFence.isPinned())
|
|
155
160
|
authority.namedSocketFence.resolve();
|
|
156
161
|
if (value.includes("\0") || Buffer.byteLength(value, "utf8") > MAX_TMUX_OUTPUT_BYTES) {
|
|
@@ -209,11 +214,14 @@ function pinnedReadRunner(authority, execute) {
|
|
|
209
214
|
if (command.executable !== "tmux" || signal?.aborted)
|
|
210
215
|
return { status: "failed" };
|
|
211
216
|
try {
|
|
217
|
+
const guarded = authority.nativeServerIdentity
|
|
218
|
+
? fenceNativeTmuxCommand(command.argv, authority.nativeServerIdentity)
|
|
219
|
+
: null;
|
|
212
220
|
const stdout = await execute(authority.executablePath, [
|
|
213
221
|
...(authority.namedSocketFence
|
|
214
222
|
? await authority.namedSocketFence.resolveAsync(signal)
|
|
215
223
|
: currentSocketArgv(authority)),
|
|
216
|
-
...command.argv,
|
|
224
|
+
...(guarded?.argv ?? command.argv),
|
|
217
225
|
], {
|
|
218
226
|
cwd: authority.trustedCwd,
|
|
219
227
|
env: authority.environment,
|
|
@@ -221,7 +229,7 @@ function pinnedReadRunner(authority, execute) {
|
|
|
221
229
|
timeoutMs: TERMINAL_ATTACHMENT_TMUX_COMMAND_TIMEOUT_MS,
|
|
222
230
|
...(signal ? { signal } : {}),
|
|
223
231
|
});
|
|
224
|
-
const value = String(stdout);
|
|
232
|
+
const value = guarded ? guarded.verify(String(stdout)) : String(stdout);
|
|
225
233
|
if (authority.namedSocketFence && !authority.namedSocketFence.isPinned())
|
|
226
234
|
await authority.namedSocketFence.resolveAsync(signal);
|
|
227
235
|
if (value.includes("\0") || Buffer.byteLength(value, "utf8") > MAX_TMUX_OUTPUT_BYTES)
|
|
@@ -443,6 +451,7 @@ const PANE_FORMAT = [
|
|
|
443
451
|
// window reports the same value; the catalog requires it before a multi-pane
|
|
444
452
|
// window is attachable.
|
|
445
453
|
"#{@tmux_ide_window_id}",
|
|
454
|
+
"#{window_index}",
|
|
446
455
|
PANE_WIRE_SENTINEL,
|
|
447
456
|
].join(WIRE_SEPARATOR);
|
|
448
457
|
async function requiredTmuxResult(runner, argv, signal) {
|
|
@@ -479,25 +488,24 @@ async function liveSessionIdentities(runner, signal) {
|
|
|
479
488
|
}
|
|
480
489
|
function parsePaneSnapshot(stdout, expected) {
|
|
481
490
|
const panes = [];
|
|
482
|
-
const runtimeIds = new
|
|
491
|
+
const runtimeIds = new Map();
|
|
492
|
+
const links = new Map();
|
|
483
493
|
for (const line of strictLines(stdout, MAX_DISCOVERED_PANES)) {
|
|
484
494
|
const fields = line.split(WIRE_SEPARATOR);
|
|
485
|
-
if (fields.length !==
|
|
495
|
+
if (fields.length !== 20 || fields[19] !== PANE_WIRE_SENTINEL) {
|
|
486
496
|
throw new NativeTerminalAttachmentRuntimeError("invalid-tmux-output");
|
|
487
497
|
}
|
|
488
|
-
const [sessionName, sessionId, windowId, runtimePaneId, paneCountValue, windowCountValue, stamp, indexValue, title, currentCommand, windowActive, paneActive, role, name, type, missionStamp, dir, windowStampValue,] = fields;
|
|
498
|
+
const [sessionName, sessionId, windowId, runtimePaneId, paneCountValue, windowCountValue, stamp, indexValue, title, currentCommand, windowActive, paneActive, role, name, type, missionStamp, dir, windowStampValue, windowIndexValue,] = fields;
|
|
489
499
|
if (sessionName !== expected.name ||
|
|
490
500
|
sessionId !== expected.id ||
|
|
491
501
|
!RUNTIME_WINDOW_ID.test(windowId) ||
|
|
492
502
|
!RUNTIME_PANE_ID.test(runtimePaneId) ||
|
|
493
|
-
runtimeIds.has(runtimePaneId) ||
|
|
494
503
|
!["0", "1"].includes(windowActive) ||
|
|
495
504
|
!["0", "1"].includes(paneActive)) {
|
|
496
505
|
throw new NativeTerminalAttachmentRuntimeError("invalid-tmux-output");
|
|
497
506
|
}
|
|
498
|
-
runtimeIds.add(runtimePaneId);
|
|
499
507
|
const nullable = (value) => boundedWireValue(value, 256).length === 0 ? null : value;
|
|
500
|
-
|
|
508
|
+
const facts = {
|
|
501
509
|
sessionName,
|
|
502
510
|
sessionId,
|
|
503
511
|
windowId,
|
|
@@ -519,18 +527,51 @@ function parsePaneSnapshot(stdout, expected) {
|
|
|
519
527
|
// registered workspace remains the trusted application-shell root, so
|
|
520
528
|
// keep discovery available and carry the empty presentation value.
|
|
521
529
|
dir: boundedWireValue(dir, 4_096),
|
|
522
|
-
}
|
|
530
|
+
};
|
|
531
|
+
const windowIndex = nonnegativeInteger(windowIndexValue);
|
|
532
|
+
const link = links.get(windowIndex) ?? {
|
|
533
|
+
windowId,
|
|
534
|
+
active: windowActive === "1",
|
|
535
|
+
panes: new Set(),
|
|
536
|
+
};
|
|
537
|
+
if (link.windowId !== windowId ||
|
|
538
|
+
link.active !== (windowActive === "1") ||
|
|
539
|
+
link.panes.has(runtimePaneId)) {
|
|
540
|
+
throw new NativeTerminalAttachmentRuntimeError("invalid-tmux-output");
|
|
541
|
+
}
|
|
542
|
+
link.panes.add(runtimePaneId);
|
|
543
|
+
links.set(windowIndex, link);
|
|
544
|
+
// Only link index and current-link state may differ for a shared backing.
|
|
545
|
+
const proof = JSON.stringify(fields.filter((_value, index) => index !== 10 && index !== 18));
|
|
546
|
+
const previous = runtimeIds.get(runtimePaneId);
|
|
547
|
+
if (previous) {
|
|
548
|
+
if (previous.proof !== proof)
|
|
549
|
+
throw new NativeTerminalAttachmentRuntimeError("invalid-tmux-output");
|
|
550
|
+
if (facts.active && !previous.facts.active) {
|
|
551
|
+
const merged = { ...previous.facts, active: true };
|
|
552
|
+
panes[panes.indexOf(previous.facts)] = merged;
|
|
553
|
+
previous.facts = merged;
|
|
554
|
+
}
|
|
555
|
+
}
|
|
556
|
+
else {
|
|
557
|
+
runtimeIds.set(runtimePaneId, { facts, proof });
|
|
558
|
+
panes.push(facts);
|
|
559
|
+
}
|
|
523
560
|
}
|
|
524
561
|
const counts = new Map();
|
|
525
562
|
for (const pane of panes)
|
|
526
563
|
counts.set(pane.windowId, (counts.get(pane.windowId) ?? 0) + 1);
|
|
527
|
-
const windows = new Set(panes.map((pane) => pane.windowId));
|
|
528
564
|
if (panes.some((pane) => counts.get(pane.windowId) !== pane.windowPaneCount ||
|
|
529
|
-
|
|
565
|
+
links.size !== pane.sessionWindowCount) ||
|
|
566
|
+
[...links.values()].some((link) => link.panes.size !== counts.get(link.windowId)) ||
|
|
567
|
+
[...links.values()].filter((link) => link.active).length > 1 ||
|
|
530
568
|
panes.filter((pane) => pane.active).length > 1) {
|
|
531
569
|
throw new NativeTerminalAttachmentRuntimeError("invalid-tmux-output");
|
|
532
570
|
}
|
|
533
|
-
return
|
|
571
|
+
return {
|
|
572
|
+
panes,
|
|
573
|
+
linksProof: JSON.stringify([...links].map(([index, link]) => [index, link.windowId, [...link.panes]])),
|
|
574
|
+
};
|
|
534
575
|
}
|
|
535
576
|
/**
|
|
536
577
|
* One bounded, old-tmux-safe discovery path for both live attachment and the
|
|
@@ -558,11 +599,13 @@ export async function discoverWorkspaceRegistryTerminalInventory(registry, runne
|
|
|
558
599
|
const before = await requiredTmuxResult(runner, argv, signal);
|
|
559
600
|
if (before === null)
|
|
560
601
|
continue;
|
|
561
|
-
const
|
|
602
|
+
const first = parsePaneSnapshot(before, identity);
|
|
603
|
+
const panes = first.panes;
|
|
562
604
|
const after = await requiredTmuxResult(runner, argv, signal);
|
|
563
605
|
if (after === null)
|
|
564
606
|
throw new NativeTerminalAttachmentRuntimeError("discovery-failed");
|
|
565
|
-
const
|
|
607
|
+
const second = parsePaneSnapshot(after, identity);
|
|
608
|
+
const latest = second.panes;
|
|
566
609
|
// Foreground commands, titles, focus and paths can change while the same
|
|
567
610
|
// pane remains valid. Fence binding/topology only, then publish one complete
|
|
568
611
|
// latest snapshot rather than mixing presentation values across reads.
|
|
@@ -577,7 +620,8 @@ export async function discoverWorkspaceRegistryTerminalInventory(registry, runne
|
|
|
577
620
|
"windowStamp",
|
|
578
621
|
"index",
|
|
579
622
|
];
|
|
580
|
-
if (
|
|
623
|
+
if (first.linksProof !== second.linksProof ||
|
|
624
|
+
panes.length !== latest.length ||
|
|
581
625
|
panes.some((pane, index) => proofKeys.some((key) => pane[key] !== latest[index][key]))) {
|
|
582
626
|
throw new NativeTerminalAttachmentRuntimeError("discovery-failed");
|
|
583
627
|
}
|
|
@@ -23,6 +23,10 @@ export class ScriptedChannelDriver {
|
|
|
23
23
|
if (command.startsWith('display-message -p "#{qa:session_name}')) {
|
|
24
24
|
return basic(command) ?? [];
|
|
25
25
|
}
|
|
26
|
+
// This scalar observation is not a deferred cursor probe. Answer it
|
|
27
|
+
// before an automatic inventory reply can consume its pending FIFO slot.
|
|
28
|
+
if (isHistorySizeProbe(command))
|
|
29
|
+
return ["0"];
|
|
26
30
|
if (command.includes("capture-pane") || command.startsWith("display-message"))
|
|
27
31
|
return null;
|
|
28
32
|
return basic(command) ?? [];
|
|
@@ -32,7 +36,8 @@ export class ScriptedChannelDriver {
|
|
|
32
36
|
pump() {
|
|
33
37
|
for (let index = this.#handledWrites; index < this.channel.written.length; index += 1) {
|
|
34
38
|
const command = this.channel.written[index];
|
|
35
|
-
if (command.startsWith('display-message -p "#{qa:session_name}')
|
|
39
|
+
if (command.startsWith('display-message -p "#{qa:session_name}') ||
|
|
40
|
+
isHistorySizeProbe(command))
|
|
36
41
|
continue;
|
|
37
42
|
if (command.includes("capture-pane") || command.startsWith("display-message")) {
|
|
38
43
|
this.deferredCommands.push(command);
|
|
@@ -88,3 +93,6 @@ export function standardScriptedChannel(handlers, options) {
|
|
|
88
93
|
...options,
|
|
89
94
|
});
|
|
90
95
|
}
|
|
96
|
+
function isHistorySizeProbe(command) {
|
|
97
|
+
return /^display-message -p -t %\d+ "#\{history_size\}"$/u.test(command);
|
|
98
|
+
}
|
|
@@ -116,13 +116,22 @@ export const FIXTURE = {
|
|
|
116
116
|
export function fixtureAutoReply(state) {
|
|
117
117
|
return (cmd) => {
|
|
118
118
|
if (cmd.startsWith('display-message -p "#{qa:session_name}'))
|
|
119
|
-
return ["zz-sim\t$1"];
|
|
119
|
+
return ["zz-sim\t$1\t1234\t1700000000"];
|
|
120
120
|
if (cmd.includes("qa:@tmux_ide_pane_id"))
|
|
121
121
|
return state.descriptorRows;
|
|
122
122
|
if (cmd.startsWith("list-panes -s"))
|
|
123
123
|
return state.truthRows;
|
|
124
124
|
if (cmd.startsWith("list-windows"))
|
|
125
|
-
return state.windowRows
|
|
125
|
+
return state.windowRows.map((row, index) => {
|
|
126
|
+
const parts = row.split("\t");
|
|
127
|
+
if (parts.length === 7)
|
|
128
|
+
parts.push(parts[4]);
|
|
129
|
+
if (parts.length === 8)
|
|
130
|
+
parts.push("emacs");
|
|
131
|
+
if (parts.length === 9)
|
|
132
|
+
parts.push(String(index));
|
|
133
|
+
return parts.join("\t");
|
|
134
|
+
});
|
|
126
135
|
// Product-owned seeds are one atomic `set-option ; capture-pane` command
|
|
127
136
|
// list. The capture reply remains manual just like a bare capture probe.
|
|
128
137
|
if (cmd.includes("capture-pane"))
|
|
@@ -20,6 +20,7 @@
|
|
|
20
20
|
* chance to detach us — killing the reader first can wedge the server.
|
|
21
21
|
*/
|
|
22
22
|
import { spawn } from "node:child_process";
|
|
23
|
+
import { shellEscape } from "../../lib/shell.js";
|
|
23
24
|
import { parseControlLine } from "../protocol/control.js";
|
|
24
25
|
const ATOMIC_CAPTURE_BYTE_HARD_CAP = 16 * 1024 * 1024;
|
|
25
26
|
// Budget row bookkeeping separately from wire bytes (64 bytes per retained
|
|
@@ -538,6 +539,17 @@ export class ControlChannelCore {
|
|
|
538
539
|
}
|
|
539
540
|
}
|
|
540
541
|
export function mirrorControlAttachArgs(options, pauseAfterSeconds = DEFAULT_PAUSE_AFTER_SECONDS) {
|
|
542
|
+
const attach = [
|
|
543
|
+
"attach",
|
|
544
|
+
"-t",
|
|
545
|
+
options.session,
|
|
546
|
+
"-f",
|
|
547
|
+
`ignore-size,pause-after=${pauseAfterSeconds},active-pane`,
|
|
548
|
+
];
|
|
549
|
+
const expected = options.nativeServerIdentity;
|
|
550
|
+
if (expected &&
|
|
551
|
+
(!/^[1-9][0-9]*$/u.test(expected.pid) || !/^[1-9][0-9]*$/u.test(expected.startTime)))
|
|
552
|
+
throw new Error("Invalid expected native server identity");
|
|
541
553
|
return [
|
|
542
554
|
...(options.socketPath
|
|
543
555
|
? ["-S", options.socketPath]
|
|
@@ -546,11 +558,15 @@ export function mirrorControlAttachArgs(options, pauseAfterSeconds = DEFAULT_PAU
|
|
|
546
558
|
: []),
|
|
547
559
|
...(options.configFile ? ["-f", options.configFile] : []),
|
|
548
560
|
"-C",
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
561
|
+
...(expected
|
|
562
|
+
? [
|
|
563
|
+
"if-shell",
|
|
564
|
+
"-F",
|
|
565
|
+
`#{&&:#{==:#{pid},${expected.pid}},#{==:#{start_time},${expected.startTime}}}`,
|
|
566
|
+
attach.map(shellEscape).join(" "),
|
|
567
|
+
"display-message -p 'tmux-server-generation-refused'",
|
|
568
|
+
]
|
|
569
|
+
: attach),
|
|
554
570
|
];
|
|
555
571
|
}
|
|
556
572
|
/** Spike-verified default: bounds a stalled reader's server-side buffering
|
|
@@ -561,11 +577,25 @@ export class MirrorControlChannel {
|
|
|
561
577
|
core;
|
|
562
578
|
opts;
|
|
563
579
|
exited = false;
|
|
580
|
+
verifiedAttach = false;
|
|
581
|
+
guardedStart = null;
|
|
564
582
|
atomicCollectorTimer = null;
|
|
565
583
|
constructor(opts) {
|
|
566
584
|
this.opts = opts;
|
|
567
585
|
this.core = new ControlChannelCore({
|
|
568
586
|
...opts.handlers,
|
|
587
|
+
onOutput: (...args) => {
|
|
588
|
+
if (!opts.nativeServerIdentity || this.verifiedAttach)
|
|
589
|
+
opts.handlers.onOutput(...args);
|
|
590
|
+
},
|
|
591
|
+
onNotify: (name, rest) => {
|
|
592
|
+
if (opts.nativeServerIdentity && name === "session-changed") {
|
|
593
|
+
this.verifiedAttach = true;
|
|
594
|
+
this.guardedStart?.resolve();
|
|
595
|
+
}
|
|
596
|
+
if (!opts.nativeServerIdentity || this.verifiedAttach)
|
|
597
|
+
opts.handlers.onNotify(name, rest);
|
|
598
|
+
},
|
|
569
599
|
onExit: (reason) => this.noteExit(reason),
|
|
570
600
|
}, opts.nowMicros);
|
|
571
601
|
}
|
|
@@ -604,7 +634,35 @@ export class MirrorControlChannel {
|
|
|
604
634
|
// tmux opens with an unsolicited %begin/%end greeting block; a queued
|
|
605
635
|
// resolver makes start() settle when the protocol is actually live.
|
|
606
636
|
return new Promise((resolve, reject) => {
|
|
607
|
-
|
|
637
|
+
let timer;
|
|
638
|
+
const settle = (error) => {
|
|
639
|
+
if (timer)
|
|
640
|
+
clearTimeout(timer);
|
|
641
|
+
this.guardedStart = null;
|
|
642
|
+
if (error)
|
|
643
|
+
reject(error);
|
|
644
|
+
else
|
|
645
|
+
resolve();
|
|
646
|
+
};
|
|
647
|
+
if (this.opts.nativeServerIdentity) {
|
|
648
|
+
this.guardedStart = { resolve: () => settle(), reject: (error) => settle(error) };
|
|
649
|
+
timer = setTimeout(() => {
|
|
650
|
+
settle(new Error("Tmux generation-guarded attach timed out"));
|
|
651
|
+
void this.dispose();
|
|
652
|
+
}, 5_000);
|
|
653
|
+
timer.unref?.();
|
|
654
|
+
}
|
|
655
|
+
// The outer if-shell greeting precedes its attach branch. Only native
|
|
656
|
+
// session-changed proves that the guarded attach actually succeeded.
|
|
657
|
+
this.core.push({
|
|
658
|
+
kind: "promise",
|
|
659
|
+
resolve: () => {
|
|
660
|
+
if (!this.opts.nativeServerIdentity)
|
|
661
|
+
settle();
|
|
662
|
+
},
|
|
663
|
+
reject: (error) => settle(error),
|
|
664
|
+
lines: [],
|
|
665
|
+
});
|
|
608
666
|
proc.on("error", (err) => {
|
|
609
667
|
this.core.fail(String(err));
|
|
610
668
|
reject(err);
|
|
@@ -747,6 +805,7 @@ export class MirrorControlChannel {
|
|
|
747
805
|
if (this.exited)
|
|
748
806
|
return;
|
|
749
807
|
this.exited = true;
|
|
808
|
+
this.guardedStart?.reject(new Error(reason ?? "Control channel exited before guarded attach"));
|
|
750
809
|
this.opts.handlers.onExit(reason);
|
|
751
810
|
}
|
|
752
811
|
}
|
|
@@ -1,3 +1,8 @@
|
|
|
1
|
+
import { accessSync, constants, realpathSync, statSync } from "node:fs";
|
|
2
|
+
import { isAbsolute } from "node:path";
|
|
3
|
+
import { resolveTmuxExecutable, tmuxClientEnvironment } from "../../lib/tmux-client-execution.js";
|
|
4
|
+
import { captureUnixSocketIdentity, revalidateUnixSocketIdentity, } from "../../lib/unix-socket-authority.js";
|
|
5
|
+
import { execFile } from "node:child_process";
|
|
1
6
|
/**
|
|
2
7
|
* MirrorService — the daemon-side shared mirror layer (m43 card 1).
|
|
3
8
|
*
|
|
@@ -144,6 +149,12 @@ export class MirrorService {
|
|
|
144
149
|
},
|
|
145
150
|
};
|
|
146
151
|
}
|
|
152
|
+
async executeWindowLinkAction(session, request) {
|
|
153
|
+
const entry = this.channels.get(session);
|
|
154
|
+
if (!entry || entry.retired)
|
|
155
|
+
throw new Error(`Mirror session ${session} is unavailable`);
|
|
156
|
+
return entry.channel.executeWindowLinkAction(request);
|
|
157
|
+
}
|
|
147
158
|
/** Synchronous hot input on an already-retained SessionRuntime channel. */
|
|
148
159
|
sendText(session, semanticPaneId, text, performanceTraceId, isolated = false) {
|
|
149
160
|
const entry = this.channels.get(session);
|
|
@@ -254,6 +265,21 @@ export class MirrorService {
|
|
|
254
265
|
}), this.owner);
|
|
255
266
|
let channel;
|
|
256
267
|
try {
|
|
268
|
+
const initialSocket = this.opts.resolveSocketPath
|
|
269
|
+
? this.opts.resolveSocketPath()
|
|
270
|
+
: this.opts.socketPath;
|
|
271
|
+
const mutationSocketIdentity = initialSocket
|
|
272
|
+
? captureUnixSocketIdentity(initialSocket)
|
|
273
|
+
: null;
|
|
274
|
+
const executable = mutationSocketIdentity
|
|
275
|
+
? realpathSync(this.opts.executable ?? resolveTmuxExecutable())
|
|
276
|
+
: null;
|
|
277
|
+
if (executable) {
|
|
278
|
+
accessSync(executable, constants.X_OK);
|
|
279
|
+
if (!isAbsolute(executable) || !statSync(executable).isFile())
|
|
280
|
+
throw new Error("Invalid pinned tmux executable");
|
|
281
|
+
}
|
|
282
|
+
const environment = Object.freeze(tmuxClientEnvironment(process.env));
|
|
257
283
|
const channelOptions = {
|
|
258
284
|
session,
|
|
259
285
|
createIo: (handlers) => this.opts.createIo?.(session, handlers) ??
|
|
@@ -264,10 +290,28 @@ export class MirrorService {
|
|
|
264
290
|
socketPath: this.opts.socketPath,
|
|
265
291
|
resolveSocketPath: this.opts.resolveSocketPath,
|
|
266
292
|
executable: this.opts.executable,
|
|
293
|
+
nativeServerIdentity: this.opts.nativeServerIdentity,
|
|
267
294
|
configFile: this.opts.configFile,
|
|
268
295
|
pauseAfterSeconds: this.opts.pauseAfterSeconds,
|
|
269
296
|
nowMicros: this.opts.nowMicros,
|
|
270
297
|
}),
|
|
298
|
+
executeWindowLinkGuard: (args) => {
|
|
299
|
+
if (!mutationSocketIdentity || !executable)
|
|
300
|
+
throw new Error("Window link mutation requires a pinned socket authority");
|
|
301
|
+
if (this.opts.resolveSocketPath)
|
|
302
|
+
this.opts.resolveSocketPath();
|
|
303
|
+
const socket = revalidateUnixSocketIdentity(mutationSocketIdentity);
|
|
304
|
+
return new Promise((resolve) => {
|
|
305
|
+
execFile(executable, ["-N", "-u", "-S", socket, ...args], { timeout: 5000, maxBuffer: 65536, encoding: "utf8", env: environment }, (error, stdout) => {
|
|
306
|
+
const status = !error
|
|
307
|
+
? 0
|
|
308
|
+
: typeof error.code === "number" && !error.killed
|
|
309
|
+
? error.code
|
|
310
|
+
: null;
|
|
311
|
+
resolve({ status, stdout });
|
|
312
|
+
});
|
|
313
|
+
});
|
|
314
|
+
},
|
|
271
315
|
historyLines: this.opts.historyLines,
|
|
272
316
|
internalReadHookEmission: this.opts.internalReadHookEmission,
|
|
273
317
|
generatePaneId: this.opts.generatePaneId,
|