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.
Files changed (80) hide show
  1. package/bin/cli.js +2597 -1322
  2. package/package.json +1 -1
  3. package/packages/contracts/src/index.ts +5 -0
  4. package/packages/contracts/src/pane-stream.ts +37 -0
  5. package/packages/contracts/src/terminal-graphics.ts +92 -0
  6. package/packages/contracts/src/terminal-history-anchor.ts +61 -0
  7. package/packages/contracts/src/tmux-server-diagnostics.ts +16 -0
  8. package/packages/core/src/index.ts +1 -0
  9. package/packages/core/src/kitty-placeholders.ts +190 -0
  10. package/packages/daemon/dist/command-center/tmux-server-diagnostics.js +24 -0
  11. package/packages/daemon/dist/command-center/tmux-server-history-anchor.js +48 -0
  12. package/packages/daemon/dist/command-center/tmux-servers.js +11 -0
  13. package/packages/daemon/dist/lib/canonical-daemon-bootstrap.js +23 -1
  14. package/packages/daemon/dist/lib/daemon-embed.js +21 -0
  15. package/packages/daemon/dist/lib/tmux-server-owner.js +10 -1
  16. package/packages/daemon/dist/lib/tmux-session-mutation-fence.js +2 -1
  17. package/packages/daemon/dist/native/TmuxIdeNotifier.app/Contents/Info.plist +2 -2
  18. package/packages/daemon/dist/native/TmuxIdeNotifier.app/Contents/MacOS/tmux-ide-notifier +0 -0
  19. package/packages/daemon/dist/native/TmuxIdeNotifier.app/Contents/Resources/Assets.car +0 -0
  20. package/packages/daemon/dist/native/TmuxIdeNotifier.app/Contents/_CodeSignature/CodeResources +2 -2
  21. package/packages/daemon/dist/native/tmux/darwin-arm64/lib/libevent_core-2.1.7.dylib +0 -0
  22. package/packages/daemon/dist/native/tmux/darwin-arm64/lib/libutf8proc.3.3.0.dylib +0 -0
  23. package/packages/daemon/dist/native/tmux/darwin-arm64/manifest.json +8 -4
  24. package/packages/daemon/dist/native/tmux/darwin-arm64/tmux +0 -0
  25. package/packages/daemon/dist/native/tmux/linux-arm64/manifest.json +5 -1
  26. package/packages/daemon/dist/native/tmux/linux-arm64/tmux +0 -0
  27. package/packages/daemon/dist/native/tmux/linux-x64/manifest.json +5 -1
  28. package/packages/daemon/dist/native/tmux/linux-x64/tmux +0 -0
  29. package/packages/daemon/dist/terminal/graphics/kitty-graphics.js +578 -0
  30. package/packages/daemon/dist/terminal/mirror/control-channel.js +17 -1
  31. package/packages/daemon/dist/terminal/mirror/mirror-service.js +1 -0
  32. package/packages/daemon/dist/terminal/mirror/native-grid-capture.js +8 -0
  33. package/packages/daemon/dist/terminal/mirror/native-grid-reader.js +6 -1
  34. package/packages/daemon/dist/terminal/mirror/session-channel.js +11 -2
  35. package/packages/daemon/dist/terminal/pane-stream/pane-stream-websocket.js +78 -1
  36. package/packages/daemon/dist/terminal/protocol/session-descriptor-discovery.js +3 -2
  37. package/packages/daemon/dist/terminal/session-runtime/history-anchor-owner.js +171 -0
  38. package/packages/daemon/dist/terminal/session-runtime/registry.js +20 -0
  39. package/packages/daemon/dist/terminal/session-runtime/terminal-delivery-hub.js +20 -1
  40. package/packages/daemon/dist/terminal/session-runtime/terminal-replica-interpreter.js +68 -1
  41. package/packages/daemon/dist/terminal/session-runtime/terminal-replica-owner.js +30 -0
  42. package/packages/daemon/dist/terminal/session-runtime/transport-binding.js +49 -21
  43. package/packages/daemon/dist/terminal/session-runtime/xterm-terminal-interpreter-backend.js +11 -0
  44. package/packages/daemon/dist/tui/mirror/runtime/application-home-agents-owner.js +7 -1
  45. package/packages/daemon/dist/tui/mirror/runtime/application-terminal-input-ingress.js +35 -5
  46. package/packages/daemon/dist/tui/mirror/runtime/application-terminal-paste.js +2 -2
  47. package/packages/daemon/dist/tui/mirror/runtime/terminal-input-adapter.js +2 -2
  48. package/packages/daemon/src/command-center/tmux-server-diagnostics.ts +38 -0
  49. package/packages/daemon/src/command-center/tmux-server-history-anchor.ts +69 -0
  50. package/packages/daemon/src/command-center/tmux-servers.ts +11 -0
  51. package/packages/daemon/src/lib/canonical-daemon-bootstrap.ts +20 -2
  52. package/packages/daemon/src/lib/daemon-embed.ts +26 -0
  53. package/packages/daemon/src/lib/tmux-server-owner.ts +10 -0
  54. package/packages/daemon/src/lib/tmux-session-mutation-fence.ts +2 -1
  55. package/packages/daemon/src/terminal/graphics/kitty-graphics.ts +641 -0
  56. package/packages/daemon/src/terminal/mirror/control-channel.ts +18 -1
  57. package/packages/daemon/src/terminal/mirror/mirror-service.ts +3 -0
  58. package/packages/daemon/src/terminal/mirror/native-grid-capture.ts +11 -0
  59. package/packages/daemon/src/terminal/mirror/native-grid-reader.ts +10 -1
  60. package/packages/daemon/src/terminal/mirror/session-channel.ts +14 -1
  61. package/packages/daemon/src/terminal/pane-stream/pane-stream-websocket.ts +82 -0
  62. package/packages/daemon/src/terminal/protocol/session-descriptor-discovery.ts +3 -2
  63. package/packages/daemon/src/terminal/session-runtime/history-anchor-owner.ts +190 -0
  64. package/packages/daemon/src/terminal/session-runtime/registry.ts +20 -0
  65. package/packages/daemon/src/terminal/session-runtime/terminal-delivery-hub.ts +38 -0
  66. package/packages/daemon/src/terminal/session-runtime/terminal-interpreter-backend.ts +4 -0
  67. package/packages/daemon/src/terminal/session-runtime/terminal-replica-interpreter.ts +83 -1
  68. package/packages/daemon/src/terminal/session-runtime/terminal-replica-owner.ts +39 -0
  69. package/packages/daemon/src/terminal/session-runtime/transport-binding.ts +53 -24
  70. package/packages/daemon/src/terminal/session-runtime/xterm-terminal-interpreter-backend.ts +13 -0
  71. package/packages/daemon/src/tui/mirror/runtime/application-home-agents-owner.ts +6 -1
  72. package/packages/daemon/src/tui/mirror/runtime/application-terminal-input-ingress.ts +44 -9
  73. package/packages/daemon/src/tui/mirror/runtime/application-terminal-paste.ts +2 -1
  74. package/packages/daemon/src/tui/mirror/runtime/terminal-input-adapter.ts +5 -2
  75. package/packages/daemon-client/src/fleet-connection-status.ts +1 -1
  76. package/packages/daemon-client/src/pane-creation-refusal.ts +48 -0
  77. package/packages/daemon-client/src/pane-stream-client.ts +29 -0
  78. package/packages/daemon-client/src/tmux-server-client.ts +133 -6
  79. package/packages/daemon/dist/native/tmux/darwin-arm64/lib/libutf8proc.3.2.3.dylib +0 -0
  80. /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
- const current = [...this.#clients.values()].find((candidate) => candidate !== target &&
477
- candidate.consumer.session === target.consumer.session &&
478
- candidate.lease);
479
- if (current?.lease) {
480
- const previous = current.lease;
481
- current.lease = null;
482
- target.lease = current.consumer.handoffController(previous, target.consumer.clientId);
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
- target.lease = target.consumer.acquireController();
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
- if (shared.lease) {
504
- // A same-host passive session channel keeps the controller PRINCIPAL
505
- // continuous across interactive pane retargeting. It contributes no
506
- // source-pane grant and cannot type; the lease retires with the last
507
- // host/session ref, while a passive-only host never acquires one.
508
- const retired = shared.lease;
509
- shared.lease = null;
510
- shared.consumer.releaseController(retired);
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
- for (const [key, candidate] of this.#clients) {
513
- if (candidate === shared)
514
- this.#clients.delete(key);
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
- if (options.sessionOwner()?.snapshot()?.status !== "live")
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
- for (const [index, input] of inputs.entries()) {
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
- sendApplicationTerminalPaste(interaction, copy, captureOrigin());
283
+ deliverPaste(copy);
259
284
  return;
260
285
  }
261
- if (recover(terminalInputsForPaste(Buffer.from(copy).toString("utf8")), copy, "paste"))
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(() => sendApplicationTerminalPaste(interaction, copy, captureOrigin()), copy.byteLength), "paste");
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
- await deps.shutdownOlderOwner(info);
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
- `=${selected.name}`,
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(" "),