tmux-ide 2.9.6 → 2.9.7
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 +2597 -1322
- package/package.json +1 -1
- package/packages/contracts/src/index.ts +5 -0
- package/packages/contracts/src/pane-stream.ts +37 -0
- package/packages/contracts/src/terminal-graphics.ts +92 -0
- package/packages/contracts/src/terminal-history-anchor.ts +61 -0
- package/packages/contracts/src/tmux-server-diagnostics.ts +16 -0
- package/packages/core/src/index.ts +1 -0
- package/packages/core/src/kitty-placeholders.ts +190 -0
- package/packages/daemon/dist/command-center/tmux-server-diagnostics.js +24 -0
- package/packages/daemon/dist/command-center/tmux-server-history-anchor.js +48 -0
- package/packages/daemon/dist/command-center/tmux-servers.js +11 -0
- package/packages/daemon/dist/lib/canonical-daemon-bootstrap.js +23 -1
- package/packages/daemon/dist/lib/daemon-embed.js +21 -0
- package/packages/daemon/dist/lib/tmux-server-owner.js +10 -1
- package/packages/daemon/dist/lib/tmux-session-mutation-fence.js +2 -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/native/tmux/darwin-arm64/lib/libevent_core-2.1.7.dylib +0 -0
- package/packages/daemon/dist/native/tmux/darwin-arm64/lib/libutf8proc.3.3.0.dylib +0 -0
- package/packages/daemon/dist/native/tmux/darwin-arm64/manifest.json +8 -4
- package/packages/daemon/dist/native/tmux/darwin-arm64/tmux +0 -0
- package/packages/daemon/dist/native/tmux/linux-arm64/manifest.json +5 -1
- package/packages/daemon/dist/native/tmux/linux-arm64/tmux +0 -0
- package/packages/daemon/dist/native/tmux/linux-x64/manifest.json +5 -1
- package/packages/daemon/dist/native/tmux/linux-x64/tmux +0 -0
- package/packages/daemon/dist/terminal/graphics/kitty-graphics.js +578 -0
- package/packages/daemon/dist/terminal/mirror/control-channel.js +17 -1
- package/packages/daemon/dist/terminal/mirror/mirror-service.js +1 -0
- package/packages/daemon/dist/terminal/mirror/native-grid-capture.js +8 -0
- package/packages/daemon/dist/terminal/mirror/native-grid-reader.js +6 -1
- package/packages/daemon/dist/terminal/mirror/session-channel.js +11 -2
- package/packages/daemon/dist/terminal/pane-stream/pane-stream-websocket.js +78 -1
- package/packages/daemon/dist/terminal/protocol/session-descriptor-discovery.js +3 -2
- package/packages/daemon/dist/terminal/session-runtime/history-anchor-owner.js +171 -0
- package/packages/daemon/dist/terminal/session-runtime/registry.js +20 -0
- package/packages/daemon/dist/terminal/session-runtime/terminal-delivery-hub.js +20 -1
- package/packages/daemon/dist/terminal/session-runtime/terminal-replica-interpreter.js +68 -1
- package/packages/daemon/dist/terminal/session-runtime/terminal-replica-owner.js +30 -0
- package/packages/daemon/dist/terminal/session-runtime/transport-binding.js +49 -21
- package/packages/daemon/dist/terminal/session-runtime/xterm-terminal-interpreter-backend.js +11 -0
- package/packages/daemon/dist/tui/mirror/runtime/application-home-agents-owner.js +7 -1
- package/packages/daemon/dist/tui/mirror/runtime/application-terminal-input-ingress.js +35 -5
- package/packages/daemon/dist/tui/mirror/runtime/application-terminal-paste.js +2 -2
- package/packages/daemon/dist/tui/mirror/runtime/terminal-input-adapter.js +2 -2
- package/packages/daemon/src/command-center/tmux-server-diagnostics.ts +38 -0
- package/packages/daemon/src/command-center/tmux-server-history-anchor.ts +69 -0
- package/packages/daemon/src/command-center/tmux-servers.ts +11 -0
- package/packages/daemon/src/lib/canonical-daemon-bootstrap.ts +20 -2
- package/packages/daemon/src/lib/daemon-embed.ts +26 -0
- package/packages/daemon/src/lib/tmux-server-owner.ts +10 -0
- package/packages/daemon/src/lib/tmux-session-mutation-fence.ts +2 -1
- package/packages/daemon/src/terminal/graphics/kitty-graphics.ts +641 -0
- package/packages/daemon/src/terminal/mirror/control-channel.ts +18 -1
- package/packages/daemon/src/terminal/mirror/mirror-service.ts +3 -0
- package/packages/daemon/src/terminal/mirror/native-grid-capture.ts +11 -0
- package/packages/daemon/src/terminal/mirror/native-grid-reader.ts +10 -1
- package/packages/daemon/src/terminal/mirror/session-channel.ts +14 -1
- package/packages/daemon/src/terminal/pane-stream/pane-stream-websocket.ts +82 -0
- package/packages/daemon/src/terminal/protocol/session-descriptor-discovery.ts +3 -2
- package/packages/daemon/src/terminal/session-runtime/history-anchor-owner.ts +190 -0
- package/packages/daemon/src/terminal/session-runtime/registry.ts +20 -0
- package/packages/daemon/src/terminal/session-runtime/terminal-delivery-hub.ts +38 -0
- package/packages/daemon/src/terminal/session-runtime/terminal-interpreter-backend.ts +4 -0
- package/packages/daemon/src/terminal/session-runtime/terminal-replica-interpreter.ts +83 -1
- package/packages/daemon/src/terminal/session-runtime/terminal-replica-owner.ts +39 -0
- package/packages/daemon/src/terminal/session-runtime/transport-binding.ts +53 -24
- package/packages/daemon/src/terminal/session-runtime/xterm-terminal-interpreter-backend.ts +13 -0
- package/packages/daemon/src/tui/mirror/runtime/application-home-agents-owner.ts +6 -1
- package/packages/daemon/src/tui/mirror/runtime/application-terminal-input-ingress.ts +44 -9
- package/packages/daemon/src/tui/mirror/runtime/application-terminal-paste.ts +2 -1
- package/packages/daemon/src/tui/mirror/runtime/terminal-input-adapter.ts +5 -2
- package/packages/daemon-client/src/fleet-connection-status.ts +1 -1
- package/packages/daemon-client/src/pane-creation-refusal.ts +48 -0
- package/packages/daemon-client/src/pane-stream-client.ts +29 -0
- package/packages/daemon-client/src/tmux-server-client.ts +133 -6
- package/packages/daemon/dist/native/tmux/darwin-arm64/lib/libutf8proc.3.2.3.dylib +0 -0
- /package/packages/daemon/dist/native/tmux/darwin-arm64/licenses/{libutf8proc.3.2.3.dylib.txt → libutf8proc.3.3.0.dylib.txt} +0 -0
|
@@ -29,6 +29,13 @@ function assertLiveControllerPrincipal(shared, lease) {
|
|
|
29
29
|
throw new SessionRuntimeControllerLeaseError("stale-controller-lease", "The host no longer owns session controller authority.");
|
|
30
30
|
}
|
|
31
31
|
}
|
|
32
|
+
function holdsCurrentLease(shared) {
|
|
33
|
+
const lease = shared.lease;
|
|
34
|
+
if (!lease)
|
|
35
|
+
return false;
|
|
36
|
+
const snapshot = shared.consumer.controllerSnapshot();
|
|
37
|
+
return snapshot.controllerClientId === lease.clientId && snapshot.revision === lease.revision;
|
|
38
|
+
}
|
|
32
39
|
const clientsByRegistry = new WeakMap();
|
|
33
40
|
function authenticatedSurface(transport, hostClientId) {
|
|
34
41
|
// The production host and development gateway mint these principals
|
|
@@ -471,18 +478,33 @@ export class SessionRuntimeTransportBinder {
|
|
|
471
478
|
: this.registry.bindExecutionSource(base, sourceSemanticPaneId);
|
|
472
479
|
}
|
|
473
480
|
activateController(target) {
|
|
474
|
-
if (target.lease)
|
|
481
|
+
if (target.lease && holdsCurrentLease(target))
|
|
475
482
|
return;
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
+
target.lease = null;
|
|
484
|
+
const controllerClientId = target.consumer.controllerSnapshot().controllerClientId;
|
|
485
|
+
const current = controllerClientId === null || controllerClientId === target.consumer.clientId
|
|
486
|
+
? undefined
|
|
487
|
+
: [...this.#clients.values()].find((candidate) => candidate !== target &&
|
|
488
|
+
candidate.consumer.session === target.consumer.session &&
|
|
489
|
+
candidate.consumer.clientId === controllerClientId);
|
|
490
|
+
if (!current) {
|
|
491
|
+
// Free, already ours, or held by a principal outside this binder (for
|
|
492
|
+
// example the command-center owner route): plain acquisition decides.
|
|
493
|
+
target.lease = target.consumer.acquireController();
|
|
483
494
|
return;
|
|
484
495
|
}
|
|
485
|
-
|
|
496
|
+
// A transport-bound holder whose binding lost its executable lease can
|
|
497
|
+
// never type again, yet it would keep every other viewer locked out. Re-
|
|
498
|
+
// derive its exact current lease so the handoff below reclaims it; a live
|
|
499
|
+
// holder simply hands off as before. Either way only one client can type.
|
|
500
|
+
const previous = current.lease && holdsCurrentLease(current)
|
|
501
|
+
? current.lease
|
|
502
|
+
: current.consumer.acquireController();
|
|
503
|
+
const handedOff = current.consumer.handoffController(previous, target.consumer.clientId);
|
|
504
|
+
// Drop the holder's lease only after the runtime accepted the handoff; a
|
|
505
|
+
// refusal (e.g. during topology verification) must leave it executable.
|
|
506
|
+
current.lease = null;
|
|
507
|
+
target.lease = handedOff;
|
|
486
508
|
}
|
|
487
509
|
async release(shared, grants, interactive) {
|
|
488
510
|
if (shared.refs <= 0)
|
|
@@ -500,19 +522,25 @@ export class SessionRuntimeTransportBinder {
|
|
|
500
522
|
shared.refs -= 1;
|
|
501
523
|
if (shared.refs > 0)
|
|
502
524
|
return;
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
525
|
+
try {
|
|
526
|
+
if (shared.lease) {
|
|
527
|
+
// A same-host passive session channel keeps the controller PRINCIPAL
|
|
528
|
+
// continuous across interactive pane retargeting. It contributes no
|
|
529
|
+
// source-pane grant and cannot type; the lease retires with the last
|
|
530
|
+
// host/session ref, while a passive-only host never acquires one.
|
|
531
|
+
const retired = shared.lease;
|
|
532
|
+
shared.lease = null;
|
|
533
|
+
shared.consumer.releaseController(retired);
|
|
534
|
+
}
|
|
511
535
|
}
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
536
|
+
finally {
|
|
537
|
+
// A refused surrender must never strand the controller on a client with
|
|
538
|
+
// no transport: closing the consumer retires whatever it still owns.
|
|
539
|
+
for (const [key, candidate] of this.#clients) {
|
|
540
|
+
if (candidate === shared)
|
|
541
|
+
this.#clients.delete(key);
|
|
542
|
+
}
|
|
543
|
+
await shared.consumer.close();
|
|
515
544
|
}
|
|
516
|
-
await shared.consumer.close();
|
|
517
545
|
}
|
|
518
546
|
}
|
|
@@ -82,6 +82,17 @@ export class XtermTerminalInterpreterBackend {
|
|
|
82
82
|
get rows() {
|
|
83
83
|
return this.#terminal.rows;
|
|
84
84
|
}
|
|
85
|
+
graphicsCursor() {
|
|
86
|
+
const buffer = this.#terminal.buffer.active;
|
|
87
|
+
return {
|
|
88
|
+
x: Math.min(buffer.cursorX, this.#terminal.cols - 1),
|
|
89
|
+
line: buffer.baseY + buffer.cursorY,
|
|
90
|
+
alternate: buffer.type === "alternate",
|
|
91
|
+
};
|
|
92
|
+
}
|
|
93
|
+
graphicsViewportTop() {
|
|
94
|
+
return this.#terminal.buffer.active.baseY;
|
|
95
|
+
}
|
|
85
96
|
write(data) {
|
|
86
97
|
return new Promise((resolve) => this.#terminal.write(data, resolve));
|
|
87
98
|
}
|
|
@@ -232,8 +232,14 @@ export function createApplicationHomeNavigationOwner(options) {
|
|
|
232
232
|
command === "whats-new" ||
|
|
233
233
|
command === "switch-session")
|
|
234
234
|
return null;
|
|
235
|
-
|
|
235
|
+
const status = options.sessionOwner()?.snapshot()?.status;
|
|
236
|
+
if (status !== "live") {
|
|
237
|
+
if (status === "rebinding")
|
|
238
|
+
return "Terminal is updating. Try again shortly.";
|
|
239
|
+
if (status === "connecting")
|
|
240
|
+
return "Opening terminal…";
|
|
236
241
|
return "Open a live session first";
|
|
242
|
+
}
|
|
237
243
|
return command !== "new-window" && command !== "new-agent" && !options.focusedPane()
|
|
238
244
|
? "Select a live pane first"
|
|
239
245
|
: null;
|
|
@@ -103,7 +103,14 @@ export function createApplicationTerminalInputIngress(interaction, generation, s
|
|
|
103
103
|
}
|
|
104
104
|
: undefined;
|
|
105
105
|
recovery.inputs.push(async () => {
|
|
106
|
-
|
|
106
|
+
if (version !== recoveryVersion ||
|
|
107
|
+
!sameScope(target, scope()) ||
|
|
108
|
+
generation()?.status !== "live")
|
|
109
|
+
return false;
|
|
110
|
+
const framed = typeof inputs === "function" ? inputs() : inputs;
|
|
111
|
+
if (framed === null)
|
|
112
|
+
return false;
|
|
113
|
+
for (const [index, input] of framed.entries()) {
|
|
107
114
|
if (version !== recoveryVersion ||
|
|
108
115
|
!sameScope(target, scope()) ||
|
|
109
116
|
generation()?.status !== "live" ||
|
|
@@ -155,6 +162,24 @@ export function createApplicationTerminalInputIngress(interaction, generation, s
|
|
|
155
162
|
}
|
|
156
163
|
})();
|
|
157
164
|
};
|
|
165
|
+
const pasteMode = () => {
|
|
166
|
+
const active = generation();
|
|
167
|
+
const pane = focusedPane();
|
|
168
|
+
if (active?.status !== "live" || !active.fastLane || !pane)
|
|
169
|
+
return null;
|
|
170
|
+
const mode = active.fastLane.lane.paneState(pane)?.snapshot?.modes.bracketedPaste;
|
|
171
|
+
return typeof mode === "boolean" ? mode : null;
|
|
172
|
+
};
|
|
173
|
+
const deliverPaste = (bytes) => {
|
|
174
|
+
const mode = pasteMode();
|
|
175
|
+
if (mode === null) {
|
|
176
|
+
// Do not let the successful drain of the surrounding FIFO hide refusal.
|
|
177
|
+
ownsNote = false;
|
|
178
|
+
setNote("terminal unavailable · paste was not sent");
|
|
179
|
+
return;
|
|
180
|
+
}
|
|
181
|
+
sendApplicationTerminalPaste(interaction, bytes, captureOrigin(), mode);
|
|
182
|
+
};
|
|
158
183
|
const captureOrigin = () => Boolean(currentTuiPerformanceEventSink()?.terminalInputOrigin);
|
|
159
184
|
const flush = () => {
|
|
160
185
|
const owner = sessionOwner();
|
|
@@ -243,7 +268,7 @@ export function createApplicationTerminalInputIngress(interaction, generation, s
|
|
|
243
268
|
return;
|
|
244
269
|
const copy = Uint8Array.from(bytes);
|
|
245
270
|
try {
|
|
246
|
-
terminalInputsForPaste(Buffer.from(copy).toString("utf8"));
|
|
271
|
+
terminalInputsForPaste(Buffer.from(copy).toString("utf8"), false);
|
|
247
272
|
}
|
|
248
273
|
catch {
|
|
249
274
|
setNote("terminal unavailable · paste was rejected");
|
|
@@ -255,12 +280,17 @@ export function createApplicationTerminalInputIngress(interaction, generation, s
|
|
|
255
280
|
active?.status === "live" &&
|
|
256
281
|
active.fastLane &&
|
|
257
282
|
focusedPane()) {
|
|
258
|
-
|
|
283
|
+
deliverPaste(copy);
|
|
259
284
|
return;
|
|
260
285
|
}
|
|
261
|
-
if (recover(
|
|
286
|
+
if (recover(() => {
|
|
287
|
+
const mode = pasteMode();
|
|
288
|
+
return mode === null
|
|
289
|
+
? null
|
|
290
|
+
: terminalInputsForPaste(Buffer.from(copy).toString("utf8"), mode);
|
|
291
|
+
}, copy, "paste"))
|
|
262
292
|
return;
|
|
263
|
-
noteAdmission(pending.enqueue(() =>
|
|
293
|
+
noteAdmission(pending.enqueue(() => deliverPaste(copy), copy.byteLength), "paste");
|
|
264
294
|
},
|
|
265
295
|
dispose() {
|
|
266
296
|
cancelInteractionInput();
|
|
@@ -10,9 +10,9 @@ export function sendApplicationTerminalKey(interaction, event, captureOrigin) {
|
|
|
10
10
|
void interaction.sendInput(input, parserOrigin);
|
|
11
11
|
return true;
|
|
12
12
|
}
|
|
13
|
-
export function sendApplicationTerminalPaste(interaction, bytes, captureOrigin) {
|
|
13
|
+
export function sendApplicationTerminalPaste(interaction, bytes, captureOrigin, bracketedPaste) {
|
|
14
14
|
const text = Buffer.from(bytes).toString("utf8");
|
|
15
|
-
const inputs = terminalInputsForPaste(text);
|
|
15
|
+
const inputs = terminalInputsForPaste(text, bracketedPaste);
|
|
16
16
|
for (let index = 0; index < inputs.length; index += 1) {
|
|
17
17
|
const parserOrigin = index === 0 && captureOrigin
|
|
18
18
|
? { origin: "bracketed-paste", payload: Buffer.from(bytes) }
|
|
@@ -33,12 +33,12 @@ export function terminalInputForOpenTuiKey(event) {
|
|
|
33
33
|
return SessionRuntimeTerminalInputSchemaZ.parse({ kind: "text", data });
|
|
34
34
|
}
|
|
35
35
|
/** Preserve paste ordering and bracketed-paste semantics without an unbounded frame. */
|
|
36
|
-
export function terminalInputsForPaste(text) {
|
|
36
|
+
export function terminalInputsForPaste(text, bracketedPaste) {
|
|
37
37
|
if (text.length === 0)
|
|
38
38
|
return [];
|
|
39
39
|
if (text.includes("\0"))
|
|
40
40
|
throw new TypeError("terminal paste must not contain NUL");
|
|
41
|
-
const framed = `\u001b[200~${text}\u001b[201
|
|
41
|
+
const framed = bracketedPaste ? `\u001b[200~${text}\u001b[201~` : text;
|
|
42
42
|
const inputs = [];
|
|
43
43
|
for (let offset = 0; offset < framed.length;) {
|
|
44
44
|
let end = Math.min(offset + SESSION_RUNTIME_MAX_TERMINAL_INPUT_TEXT_CHARS, framed.length);
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import type { Hono } from "hono";
|
|
2
|
+
import { TmuxServerScopeSchemaZ, TmuxServerDiagnosticsSchemaZ } from "@tmux-ide/contracts";
|
|
3
|
+
import { TmuxServerScopeError, type TmuxServerOwners } from "../lib/tmux-server-owners.ts";
|
|
4
|
+
import type { NativeTmuxServerOwner } from "../lib/tmux-server-owner.ts";
|
|
5
|
+
|
|
6
|
+
/** Mount beneath the existing owner-authenticated, no-store server router. */
|
|
7
|
+
export function mountTmuxServerDiagnosticsRoute(
|
|
8
|
+
app: Hono,
|
|
9
|
+
options: {
|
|
10
|
+
scopedPath: string;
|
|
11
|
+
owners: TmuxServerOwners<NativeTmuxServerOwner>;
|
|
12
|
+
},
|
|
13
|
+
) {
|
|
14
|
+
app.get(`${options.scopedPath}/diagnostics`, async (c) => {
|
|
15
|
+
const scope = TmuxServerScopeSchemaZ.safeParse({
|
|
16
|
+
serverId: c.req.param("serverId"),
|
|
17
|
+
generation: c.req.param("generation"),
|
|
18
|
+
});
|
|
19
|
+
if (!scope.success) return c.json({ error: { code: "invalid-request" } }, 400);
|
|
20
|
+
try {
|
|
21
|
+
c.req.raw.signal.throwIfAborted();
|
|
22
|
+
const tmuxVersion = await options.owners.withOwner(scope.data, (owner) =>
|
|
23
|
+
owner.readServerVersion(c.req.raw.signal),
|
|
24
|
+
);
|
|
25
|
+
c.req.raw.signal.throwIfAborted();
|
|
26
|
+
return c.json(
|
|
27
|
+
TmuxServerDiagnosticsSchemaZ.parse({ version: 1, server: scope.data, tmuxVersion }),
|
|
28
|
+
);
|
|
29
|
+
} catch (error) {
|
|
30
|
+
if (error instanceof TmuxServerScopeError)
|
|
31
|
+
return c.json(
|
|
32
|
+
{ error: { code: "scope-unavailable" } },
|
|
33
|
+
error.code === "not-found" ? 404 : 409,
|
|
34
|
+
);
|
|
35
|
+
return c.json({ error: { code: "diagnostics-unavailable" } }, 503);
|
|
36
|
+
}
|
|
37
|
+
});
|
|
38
|
+
}
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import type { Hono } from "hono";
|
|
2
|
+
import {
|
|
3
|
+
HistoryAnchorRequestSchemaZ,
|
|
4
|
+
HistoryAnchorResponseSchemaZ,
|
|
5
|
+
TmuxServerScopeSchemaZ,
|
|
6
|
+
} from "@tmux-ide/contracts";
|
|
7
|
+
import { TmuxServerScopeError, type TmuxServerOwners } from "../lib/tmux-server-owners.ts";
|
|
8
|
+
import type { NativeTmuxServerOwner } from "../lib/tmux-server-owner.ts";
|
|
9
|
+
/** Mounted below owner-authenticated no-store middleware. Never opens a runtime. */
|
|
10
|
+
export function mountTmuxServerHistoryAnchorRoute(
|
|
11
|
+
app: Hono,
|
|
12
|
+
options: {
|
|
13
|
+
scopedPath: string;
|
|
14
|
+
owners: TmuxServerOwners<NativeTmuxServerOwner>;
|
|
15
|
+
readBody: (request: Request) => Promise<unknown>;
|
|
16
|
+
},
|
|
17
|
+
) {
|
|
18
|
+
app.post(`${options.scopedPath}/history-anchor`, async (c) => {
|
|
19
|
+
const scope = TmuxServerScopeSchemaZ.safeParse({
|
|
20
|
+
serverId: c.req.param("serverId"),
|
|
21
|
+
generation: c.req.param("generation"),
|
|
22
|
+
});
|
|
23
|
+
if (!scope.success) return c.json({ error: { code: "invalid-request" } }, 400);
|
|
24
|
+
let body;
|
|
25
|
+
try {
|
|
26
|
+
body = HistoryAnchorRequestSchemaZ.parse(await options.readBody(c.req.raw));
|
|
27
|
+
} catch {
|
|
28
|
+
return c.json({ error: { code: "invalid-request" } }, 400);
|
|
29
|
+
}
|
|
30
|
+
if (body.proof.generation !== scope.data.generation)
|
|
31
|
+
return c.json({ error: { code: "stale-generation" } }, 409);
|
|
32
|
+
try {
|
|
33
|
+
c.req.raw.signal.throwIfAborted();
|
|
34
|
+
const result = await options.owners.withOwner(scope.data, async (owner) => {
|
|
35
|
+
const sessions = await owner.catalog();
|
|
36
|
+
c.req.raw.signal.throwIfAborted();
|
|
37
|
+
const workspace = owner.workspaceRegistry.get(body.proof.workspaceName);
|
|
38
|
+
if (
|
|
39
|
+
!workspace ||
|
|
40
|
+
!sessions.some(
|
|
41
|
+
(row) =>
|
|
42
|
+
row.sessionName === workspace.sessionName && row.liveSessionId === body.liveSessionId,
|
|
43
|
+
)
|
|
44
|
+
)
|
|
45
|
+
return { status: "stale" as const };
|
|
46
|
+
const result = owner.sessionRuntimeRegistry.historyAnchor(
|
|
47
|
+
workspace.sessionName,
|
|
48
|
+
HistoryAnchorRequestSchemaZ.parse({
|
|
49
|
+
...body,
|
|
50
|
+
proof: { ...body.proof, workspaceName: workspace.sessionName },
|
|
51
|
+
}),
|
|
52
|
+
);
|
|
53
|
+
// Delivery aliases are transport addresses, not canonical session names.
|
|
54
|
+
return result.status === "resolved"
|
|
55
|
+
? { ...result, proof: { ...result.proof, workspaceName: body.proof.workspaceName } }
|
|
56
|
+
: result;
|
|
57
|
+
});
|
|
58
|
+
c.req.raw.signal.throwIfAborted();
|
|
59
|
+
return c.json(HistoryAnchorResponseSchemaZ.parse({ version: 1, server: scope.data, result }));
|
|
60
|
+
} catch (error) {
|
|
61
|
+
if (error instanceof TmuxServerScopeError)
|
|
62
|
+
return c.json(
|
|
63
|
+
{ error: { code: "scope-unavailable" } },
|
|
64
|
+
error.code === "not-found" ? 404 : 409,
|
|
65
|
+
);
|
|
66
|
+
return c.json({ error: { code: "history-unavailable" } }, 503);
|
|
67
|
+
}
|
|
68
|
+
});
|
|
69
|
+
}
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { mountTmuxServerHistoryAnchorRoute } from "./tmux-server-history-anchor.ts";
|
|
2
|
+
import { mountTmuxServerDiagnosticsRoute } from "./tmux-server-diagnostics.ts";
|
|
1
3
|
import { WindowSplitLayoutUnavailable } from "../terminal/mirror/window-split-authority.ts";
|
|
2
4
|
import { streamTmuxInteractions } from "./tmux-server-interaction-events.ts";
|
|
3
5
|
import { mountTmuxServerNativeBackingRoute } from "./tmux-server-native-backing.ts";
|
|
@@ -30,6 +32,7 @@ import {
|
|
|
30
32
|
} from "../lib/tmux-server-owners.ts";
|
|
31
33
|
import type { NativeTmuxServerOwner } from "../lib/tmux-server-owner.ts";
|
|
32
34
|
import { WorkspaceMultiplexerError } from "../lib/workspace-multiplexer-verbs.ts";
|
|
35
|
+
import { WorkspacePaneCreationError } from "../lib/workspace-pane-creation.ts";
|
|
33
36
|
import { ownerBearerMatches } from "./owner-authority.ts";
|
|
34
37
|
import { mountPaneStreamIssueRoute } from "./pane-stream-issue.ts";
|
|
35
38
|
import { projectTerminalRuntimeInventory } from "./resources/terminal-runtime-inventory.ts";
|
|
@@ -79,6 +82,8 @@ function failure(c: Context, error: unknown): Response {
|
|
|
79
82
|
{ error: { code: error.code } },
|
|
80
83
|
error.code === "not-found" ? 404 : error.code === "capacity" ? 429 : 409,
|
|
81
84
|
);
|
|
85
|
+
if (error instanceof WorkspacePaneCreationError && error.code === "harness_unavailable")
|
|
86
|
+
return c.json({ error: { code: "harness_unavailable" } }, 409);
|
|
82
87
|
if (error instanceof WorkspaceMultiplexerError)
|
|
83
88
|
return c.json({ error: { code: error.code } }, 409);
|
|
84
89
|
if (error instanceof z.ZodError || error instanceof SyntaxError || error instanceof TypeError)
|
|
@@ -133,6 +138,12 @@ export function mountTmuxServerRoutes(app: Hono, options: TmuxServerRoutesOption
|
|
|
133
138
|
}),
|
|
134
139
|
);
|
|
135
140
|
const scoped = `${base}/:serverId/:generation`;
|
|
141
|
+
mountTmuxServerHistoryAnchorRoute(app, {
|
|
142
|
+
scopedPath: scoped,
|
|
143
|
+
owners: options.owners,
|
|
144
|
+
readBody: (request) => boundedJson(request, 4096),
|
|
145
|
+
});
|
|
146
|
+
mountTmuxServerDiagnosticsRoute(app, { scopedPath: scoped, owners: options.owners });
|
|
136
147
|
mountTmuxServerEditorContextRoute(app, {
|
|
137
148
|
scopedPath: scoped,
|
|
138
149
|
owners: options.owners,
|
|
@@ -100,6 +100,12 @@ function spawnOwner(entryPath: string, cwd: string): Promise<void> {
|
|
|
100
100
|
});
|
|
101
101
|
}
|
|
102
102
|
|
|
103
|
+
class ShutdownTransportError extends Error {
|
|
104
|
+
constructor(readonly original: unknown) {
|
|
105
|
+
super("Canonical daemon shutdown transport failed.", { cause: original });
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
103
109
|
async function shutdownOlderOwner(info: CanonicalDaemonInfo): Promise<void> {
|
|
104
110
|
const headers: Record<string, string> = { "content-type": "application/json" };
|
|
105
111
|
if (info.authToken) headers.authorization = `Bearer ${info.authToken}`;
|
|
@@ -115,7 +121,9 @@ async function shutdownOlderOwner(info: CanonicalDaemonInfo): Promise<void> {
|
|
|
115
121
|
}),
|
|
116
122
|
signal: AbortSignal.timeout(2_000),
|
|
117
123
|
},
|
|
118
|
-
)
|
|
124
|
+
).catch((error: unknown) => {
|
|
125
|
+
throw new ShutdownTransportError(error);
|
|
126
|
+
});
|
|
119
127
|
const envelope = (await response.json().catch(() => null)) as {
|
|
120
128
|
ok?: unknown;
|
|
121
129
|
result?: { stopping?: unknown };
|
|
@@ -248,7 +256,16 @@ async function replaceOlderCanonicalDaemon(
|
|
|
248
256
|
{ reason: "canonical-record-invalid" },
|
|
249
257
|
);
|
|
250
258
|
}
|
|
251
|
-
|
|
259
|
+
let transportFailure: ShutdownTransportError | undefined;
|
|
260
|
+
try {
|
|
261
|
+
await deps.shutdownOlderOwner(info);
|
|
262
|
+
} catch (error) {
|
|
263
|
+
if (!(error instanceof ShutdownTransportError)) throw error;
|
|
264
|
+
transportFailure = error;
|
|
265
|
+
}
|
|
266
|
+
// A competing upgrader may close the authenticated owner's listener before
|
|
267
|
+
// removing its record. Observe retirement, without repeating the mutation.
|
|
268
|
+
// A transport failure alone never authorizes starting a replacement.
|
|
252
269
|
const deadline = deps.now() + timeoutMs;
|
|
253
270
|
while (deps.now() < deadline) {
|
|
254
271
|
const state = deps.inspect();
|
|
@@ -257,6 +274,7 @@ async function replaceOlderCanonicalDaemon(
|
|
|
257
274
|
if (!(await deps.alive(info))) return;
|
|
258
275
|
await deps.sleep(25);
|
|
259
276
|
}
|
|
277
|
+
if (transportFailure) throw transportFailure.original;
|
|
260
278
|
throw new DaemonBootstrapError(
|
|
261
279
|
"control-timeout",
|
|
262
280
|
"The older canonical daemon did not retire after accepting the version upgrade.",
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { TmuxServerVersionSchemaZ } from "@tmux-ide/contracts";
|
|
1
2
|
import {
|
|
2
3
|
createNativeSplitCapabilityProbe,
|
|
3
4
|
createNativeSplitReadiness,
|
|
@@ -1400,6 +1401,7 @@ async function startEmbeddedDaemonGeneration(
|
|
|
1400
1401
|
let paneStreamRuntime: PaneStreamRuntime | null = null;
|
|
1401
1402
|
let serverOwners: Awaited<ReturnType<typeof createEmbeddedTmuxServerOwners>> | null = null;
|
|
1402
1403
|
let defaultEditorContextRetired = false;
|
|
1404
|
+
const defaultDiagnosticsLifetime = new AbortController();
|
|
1403
1405
|
const defaultSessionCreator = createNativeTmuxSessionCreator({
|
|
1404
1406
|
generation: instanceId,
|
|
1405
1407
|
registry: workspaceRegistry,
|
|
@@ -1805,6 +1807,29 @@ async function startEmbeddedDaemonGeneration(
|
|
|
1805
1807
|
stateDirectory: resolveRuntimeNamespace().runtimeDir,
|
|
1806
1808
|
webSocketBaseUrl: canonicalDaemonUrl("ws", bindHostname, port),
|
|
1807
1809
|
defaultOwner: {
|
|
1810
|
+
readServerVersion: async (signal?: AbortSignal) => {
|
|
1811
|
+
const assertCurrent = () => {
|
|
1812
|
+
if (
|
|
1813
|
+
defaultEditorContextRetired ||
|
|
1814
|
+
serverOwners?.defaultRetired ||
|
|
1815
|
+
!initialNativeServerIdentity
|
|
1816
|
+
)
|
|
1817
|
+
throw new Error("Default tmux diagnostics owner is unavailable");
|
|
1818
|
+
};
|
|
1819
|
+
assertCurrent();
|
|
1820
|
+
const combined = AbortSignal.any([
|
|
1821
|
+
defaultDiagnosticsLifetime.signal,
|
|
1822
|
+
...(signal ? [signal] : []),
|
|
1823
|
+
]);
|
|
1824
|
+
combined.throwIfAborted();
|
|
1825
|
+
const output = await fleetFactsTmuxRunner(
|
|
1826
|
+
["display-message", "-p", "#{version}"],
|
|
1827
|
+
combined,
|
|
1828
|
+
);
|
|
1829
|
+
combined.throwIfAborted();
|
|
1830
|
+
assertCurrent();
|
|
1831
|
+
return TmuxServerVersionSchemaZ.parse(output.trim());
|
|
1832
|
+
},
|
|
1808
1833
|
get interactionObservation() {
|
|
1809
1834
|
return interactionObservation;
|
|
1810
1835
|
},
|
|
@@ -1913,6 +1938,7 @@ async function startEmbeddedDaemonGeneration(
|
|
|
1913
1938
|
paneStreamRuntime,
|
|
1914
1939
|
dispose: async () => {
|
|
1915
1940
|
defaultEditorContextRetired = true;
|
|
1941
|
+
defaultDiagnosticsLifetime.abort();
|
|
1916
1942
|
sessionMonitor?.stop();
|
|
1917
1943
|
paneSourceCredentials.dispose();
|
|
1918
1944
|
const transportResults = await Promise.allSettled([
|
|
@@ -30,6 +30,7 @@ import { observedAgentSchema } from "./tmux-server-agent-admission.ts";
|
|
|
30
30
|
import { createPaneEditorContextResolver } from "./tmux-pane-editor-context.ts";
|
|
31
31
|
import { createHash, randomUUID } from "node:crypto";
|
|
32
32
|
import {
|
|
33
|
+
TmuxServerVersionSchemaZ,
|
|
33
34
|
WorkspaceIdSchemaZ,
|
|
34
35
|
WindowLinkTargetSchemaZ,
|
|
35
36
|
type WindowLinkTarget,
|
|
@@ -594,6 +595,15 @@ export async function createNativeTmuxServerOwner(options: NativeTmuxServerOwner
|
|
|
594
595
|
throw error;
|
|
595
596
|
}
|
|
596
597
|
return {
|
|
598
|
+
readServerVersion: async (signal?: AbortSignal) => {
|
|
599
|
+
assertOpen();
|
|
600
|
+
const combined = AbortSignal.any([credentialLifetime.signal, ...(signal ? [signal] : [])]);
|
|
601
|
+
combined.throwIfAborted();
|
|
602
|
+
const output = await generationRunAsync(["display-message", "-p", "#{version}"], combined);
|
|
603
|
+
combined.throwIfAborted();
|
|
604
|
+
assertOpen();
|
|
605
|
+
return TmuxServerVersionSchemaZ.parse(output.trim());
|
|
606
|
+
},
|
|
597
607
|
serverId: options.serverId,
|
|
598
608
|
generation,
|
|
599
609
|
resolvePaneEditorContext,
|
|
@@ -23,7 +23,8 @@ export function createTmuxSessionMutationFence() {
|
|
|
23
23
|
const output = run([
|
|
24
24
|
"if-shell",
|
|
25
25
|
"-t",
|
|
26
|
-
|
|
26
|
+
// if-shell resolves a pane target: the colon selects this exact session.
|
|
27
|
+
`=${selected.name}:`,
|
|
27
28
|
"-F",
|
|
28
29
|
`#{&&:#{==:#{session_id},${selected.id}},#{==:#{session_created},${selected.created}}}`,
|
|
29
30
|
args.map((arg) => (arg === ";" ? ";" : shellEscape(arg))).join(" "),
|