tmux-ide 2.9.14 → 2.9.16

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 (34) hide show
  1. package/README.md +1 -1
  2. package/bin/cli.js +2015 -1428
  3. package/package.json +2 -1
  4. package/packages/contracts/src/index.ts +1 -0
  5. package/packages/contracts/src/native-browser.ts +43 -0
  6. package/packages/contracts/src/tmux-server-scope.ts +54 -0
  7. package/packages/core/src/terminal-replica.ts +32 -10
  8. package/packages/daemon/dist/command-center/daemon-fleet-facts-observer.js +8 -1
  9. package/packages/daemon/dist/command-center/tmux-servers.js +52 -10
  10. package/packages/daemon/dist/lib/daemon-embed.js +15 -0
  11. package/packages/daemon/dist/lib/native-browser-rpc.js +110 -0
  12. package/packages/daemon/dist/lib/session-favicon.js +152 -0
  13. package/packages/daemon/dist/lib/skill-sync.js +3 -3
  14. package/packages/daemon/dist/lib/tmux-native-browser.js +80 -0
  15. package/packages/daemon/dist/lib/tmux-server-owner.js +25 -3
  16. package/packages/daemon/dist/native/TmuxIdeNotifier.app/Contents/Info.plist +2 -2
  17. package/packages/daemon/dist/native/TmuxIdeNotifier.app/Contents/MacOS/tmux-ide-notifier +0 -0
  18. package/packages/daemon/dist/native/TmuxIdeNotifier.app/Contents/Resources/Assets.car +0 -0
  19. package/packages/daemon/dist/native/TmuxIdeNotifier.app/Contents/_CodeSignature/CodeResources +2 -2
  20. package/packages/daemon/dist/open-browser.js +64 -8
  21. package/packages/daemon/dist/terminal/pane-stream/pane-stream-websocket.js +12 -0
  22. package/packages/daemon/dist/terminal/session-runtime/terminal-delivery-hub.js +16 -0
  23. package/packages/daemon/src/command-center/daemon-fleet-facts-observer.ts +9 -1
  24. package/packages/daemon/src/command-center/tmux-servers.ts +66 -11
  25. package/packages/daemon/src/lib/daemon-embed.ts +17 -0
  26. package/packages/daemon/src/lib/native-browser-rpc.ts +118 -0
  27. package/packages/daemon/src/lib/session-favicon.ts +185 -0
  28. package/packages/daemon/src/lib/skill-sync.ts +3 -3
  29. package/packages/daemon/src/lib/tmux-native-browser.ts +100 -0
  30. package/packages/daemon/src/lib/tmux-server-owner.ts +44 -6
  31. package/packages/daemon/src/open-browser.ts +120 -8
  32. package/packages/daemon/src/terminal/pane-stream/pane-stream-websocket.ts +12 -0
  33. package/packages/daemon/src/terminal/session-runtime/terminal-delivery-hub.ts +25 -0
  34. package/skill/SKILL.md +6 -1
@@ -0,0 +1,80 @@
1
+ import { PaneEditorContextTargetSchemaZ, NativeBrowserCommandSchemaZ } from "@tmux-ide/contracts";
2
+ import { z } from "zod";
3
+ import { decodeTmuxArgument } from "../terminal/protocol/session-descriptor-discovery.js";
4
+ import { NativeBrowserEndpointSchema, NativeBrowserUnavailable, nativeBrowserRpc, } from "./native-browser-rpc.js";
5
+ export const NativeBrowserRequestSchema = z
6
+ .object({
7
+ target: PaneEditorContextTargetSchemaZ,
8
+ command: NativeBrowserCommandSchemaZ.nullable(),
9
+ })
10
+ .strict();
11
+ export function createNativeBrowserResolver(options) {
12
+ let readers = 0;
13
+ return async (input, cancellation) => {
14
+ const { target, command } = NativeBrowserRequestSchema.parse(input);
15
+ const signal = AbortSignal.any([cancellation, AbortSignal.timeout(2500)]);
16
+ const workspace = options.registry.get(target.workspaceName);
17
+ const assertCurrent = () => {
18
+ options.assertOpen();
19
+ signal.throwIfAborted();
20
+ if (target.generation !== options.generation ||
21
+ !workspace ||
22
+ options.registry.get(target.workspaceName) !== workspace)
23
+ throw new NativeBrowserUnavailable();
24
+ };
25
+ assertCurrent();
26
+ if (readers >= 8)
27
+ throw new NativeBrowserUnavailable();
28
+ readers++;
29
+ try {
30
+ const sessions = await options.catalog();
31
+ assertCurrent();
32
+ const matching = sessions.filter((s) => s.liveSessionId === target.liveSessionId);
33
+ if (matching.length !== 1 || matching[0].sessionName !== workspace.sessionName)
34
+ throw new NativeBrowserUnavailable();
35
+ const snapshot = await options.inventory.discoverTerminalInventory(signal);
36
+ assertCurrent();
37
+ if (snapshot.catalog.invalidRuntimeProof ||
38
+ snapshot.catalog.duplicateSemanticStamp ||
39
+ snapshot.catalog.duplicateRuntimePaneBinding)
40
+ throw new NativeBrowserUnavailable();
41
+ const matches = snapshot.panes.filter((p) => p.workspaceName === target.workspaceName &&
42
+ p.semanticPaneId === target.semanticPaneId &&
43
+ p.sessionName === workspace.sessionName);
44
+ if (matches.length !== 1)
45
+ throw new NativeBrowserUnavailable();
46
+ const pane = { ...matches[0] };
47
+ if (!pane.nativePaneBirthId || !/^%[0-9]+$/u.test(pane.runtimePaneId))
48
+ throw new NativeBrowserUnavailable();
49
+ const observe = async () => {
50
+ const raw = await options.run([
51
+ "display-message",
52
+ "-p",
53
+ "-t",
54
+ pane.runtimePaneId,
55
+ "#{qa:@tmux_ide_pane_id}\t#{qa:pane_birth_id}\t#{qa:@tmux_ide_browser_control}",
56
+ ], signal);
57
+ assertCurrent();
58
+ if (raw.length > 8192)
59
+ throw new NativeBrowserUnavailable();
60
+ const [semantic, birth, endpoint] = raw.trimEnd().split("\t").map(decodeTmuxArgument);
61
+ if (semantic !== target.semanticPaneId || birth !== pane.nativePaneBirthId)
62
+ throw new NativeBrowserUnavailable();
63
+ const parsed = NativeBrowserEndpointSchema.parse(JSON.parse(endpoint ?? "null"));
64
+ if (parsed.pane !== pane.runtimePaneId)
65
+ throw new NativeBrowserUnavailable();
66
+ return parsed;
67
+ };
68
+ const endpoint = await observe();
69
+ const result = await nativeBrowserRpc(endpoint, command, signal);
70
+ assertCurrent();
71
+ const current = await observe();
72
+ if (JSON.stringify(current) !== JSON.stringify(endpoint))
73
+ throw new NativeBrowserUnavailable();
74
+ return result;
75
+ }
76
+ finally {
77
+ readers--;
78
+ }
79
+ };
80
+ }
@@ -1,3 +1,4 @@
1
+ import { createNativeBrowserResolver } from "./tmux-native-browser.js";
1
2
  import { createNativeSurfaceContextResolver } from "./native-surface-context.js";
2
3
  import { shellEscape } from "./shell.js";
3
4
  import { createNativeSplitCapabilityProbe, createNativeSplitReadiness, } from "./native-split-capability.js";
@@ -68,7 +69,8 @@ export function createNativeTmuxServerCatalog(workspaceRegistry, run, assertOpen
68
69
  "list-panes",
69
70
  "-a",
70
71
  "-F",
71
- "#{pid}\t#{session_id}\t#{session_created}\t#{session_name}\t#{pane_id}\t#{session_path}",
72
+ // session_path stays last: tmux rejects tabs in session names but not in paths.
73
+ "#{pid}\t#{session_id}\t#{session_created}\t#{session_name}\t#{pane_id}\t#{window_id}\t#{session_activity}\t#{session_path}",
72
74
  ]
73
75
  .map(shellEscape)
74
76
  .join(" "),
@@ -79,7 +81,8 @@ export function createNativeTmuxServerCatalog(workspaceRegistry, run, assertOpen
79
81
  // Command errors, unsupported formats and unwitnessed blank output stay failures.
80
82
  const lines = raw.trim() === "tmux-ide-empty-catalog-v1" ? [] : raw.trim().split("\n");
81
83
  for (const line of lines) {
82
- const [pid, id, created, name, pane, dir] = line.split("\t");
84
+ const [pid, id, created, name, pane, window, activity, ...pathParts] = line.split("\t");
85
+ const dir = pathParts.join("\t");
83
86
  if (!pid ||
84
87
  !/^\d+$/.test(pid) ||
85
88
  !id ||
@@ -89,6 +92,8 @@ export function createNativeTmuxServerCatalog(workspaceRegistry, run, assertOpen
89
92
  !name ||
90
93
  !pane ||
91
94
  !/^%\d+$/.test(pane) ||
95
+ !window ||
96
+ !/^@\d+$/.test(window) ||
92
97
  !dir)
93
98
  throw new Error("Invalid native server catalog");
94
99
  if (!isVisibleFleetSession(name))
@@ -97,9 +102,12 @@ export function createNativeTmuxServerCatalog(workspaceRegistry, run, assertOpen
97
102
  const row = rows.get(liveSessionId) ?? {
98
103
  summary: { liveSessionId, sessionName: name, paneCount: 0 },
99
104
  panes: new Set(),
105
+ windows: new Set(),
100
106
  dir,
107
+ activity: activity && /^\d+$/.test(activity) ? Number(activity) : null,
101
108
  };
102
109
  row.panes.add(pane);
110
+ row.windows.add(window);
103
111
  rows.set(liveSessionId, row);
104
112
  }
105
113
  const names = new Set([...rows.values()].map(({ summary }) => summary.sessionName));
@@ -114,7 +122,13 @@ export function createNativeTmuxServerCatalog(workspaceRegistry, run, assertOpen
114
122
  projectDir: dir,
115
123
  persistence: "volatile",
116
124
  });
117
- return [...rows.values()].map(({ summary, panes }) => ({ ...summary, paneCount: panes.size }));
125
+ return [...rows.values()].map(({ summary, panes, windows, dir, activity }) => ({
126
+ ...summary,
127
+ paneCount: panes.size,
128
+ windowCount: windows.size,
129
+ lastActivity: activity,
130
+ sessionPath: dir,
131
+ }));
118
132
  };
119
133
  }
120
134
  /** One proven server incarnation. Never elects an endpoint or changes ambient tmux authority. */
@@ -505,6 +519,14 @@ export async function createNativeTmuxServerOwner(options) {
505
519
  run: (args, signal) => generationRunAsync(args, signal),
506
520
  }),
507
521
  resolvePaneEditorContext,
522
+ nativeBrowser: createNativeBrowserResolver({
523
+ generation,
524
+ assertOpen,
525
+ registry: workspaceRegistry,
526
+ catalog,
527
+ inventory: terminalInventoryRuntime,
528
+ run: generationRunAsync,
529
+ }),
508
530
  catalog,
509
531
  readWindowSplitLayout: async (workspaceName, input) => {
510
532
  assertOpen();
@@ -21,9 +21,9 @@
21
21
  <key>CFBundlePackageType</key>
22
22
  <string>APPL</string>
23
23
  <key>CFBundleShortVersionString</key>
24
- <string>2.9.14</string>
24
+ <string>2.9.16</string>
25
25
  <key>CFBundleVersion</key>
26
- <string>2.9.14</string>
26
+ <string>2.9.16</string>
27
27
  <key>LSMinimumSystemVersion</key>
28
28
  <string>11.0</string>
29
29
  <key>LSUIElement</key>
@@ -10,7 +10,7 @@
10
10
  </data>
11
11
  <key>Resources/Assets.car</key>
12
12
  <data>
13
- vWBHvsqTbQOxNEIBI9tGJCW1B9s=
13
+ 80sBn8yzZzR4ZHE3L4qFvRPqA+A=
14
14
  </data>
15
15
  </dict>
16
16
  <key>files2</key>
@@ -26,7 +26,7 @@
26
26
  <dict>
27
27
  <key>hash2</key>
28
28
  <data>
29
- 9TKARXnkH7u4v7AEsGxJfv64Q3jE3NOm+N2tQHYvvIE=
29
+ PKQV15gCKuH8bIwdxGZgHEhWb0Rwq62t/R9Ozfnv/BA=
30
30
  </data>
31
31
  </dict>
32
32
  </dict>
@@ -1,17 +1,29 @@
1
1
  import { accessSync, constants, realpathSync, statSync } from "node:fs";
2
2
  import { delimiter, isAbsolute, join, resolve } from "node:path";
3
+ import { homedir } from "node:os";
3
4
  import { IdeError } from "./lib/errors.js";
4
5
  import { resolveRuntimeNamespace, runtimeNamespaceEnvironment } from "./lib/runtime-namespace.js";
5
6
  import { createPinnedWorkspaceTmuxRunner, resolveWorkspacePaneTmuxAuthority, } from "./lib/workspace-pane-creation.js";
6
7
  /** Resolve once before creating a pane; ignore relative PATH entries. */
7
- export function resolveTerminalBrowser(environment = process.env) {
8
+ export function resolveTerminalBrowser(environment = process.env, platform = process.platform) {
8
9
  const configured = environment.TMUX_IDE_TERMINAL_BROWSER_BIN;
10
+ const home = environment.HOME ?? homedir();
11
+ // Resolve the managed alias on every invocation so an app update selects its
12
+ // matching browser. Native assets remain outside the universal npm package.
13
+ const bundled = platform === "darwin" && isAbsolute(home)
14
+ ? [
15
+ join(home, "Library", "Application Support", "tmux-ide", "native-alpha", "TmuxIDE.app", "Contents", "Resources", "browser", "terminal-browser"),
16
+ ]
17
+ : [];
9
18
  const candidates = configured
10
19
  ? [configured]
11
- : (environment.PATH ?? "")
12
- .split(delimiter)
13
- .filter(isAbsolute)
14
- .map((dir) => join(dir, "terminal-browser"));
20
+ : [
21
+ ...bundled,
22
+ ...(environment.PATH ?? "")
23
+ .split(delimiter)
24
+ .filter(isAbsolute)
25
+ .map((dir) => join(dir, "terminal-browser")),
26
+ ];
15
27
  for (const candidate of candidates) {
16
28
  try {
17
29
  if (!isAbsolute(candidate))
@@ -64,6 +76,42 @@ export function validateBrowserOpen(url, options) {
64
76
  exitCode: 2,
65
77
  });
66
78
  }
79
+ /** Printed when the opened browser never publishes tmux-ide's native controls. */
80
+ export const NO_NATIVE_CONTROLS_NOTICE = "This browser has no tmux-ide controls; install the native app for back/forward/reload.";
81
+ /** A browser bundled in a native app publishes its controls; a standalone one may not. */
82
+ function isBundledBrowser(executable) {
83
+ return /\/[^/]+\.app\/Contents\/Resources\/browser\//u.test(executable);
84
+ }
85
+ /** How long a standalone browser has to publish its controls. */
86
+ const STANDALONE_WAIT_MS = 2000;
87
+ /**
88
+ * Watches the new pane for @tmux_ide_browser_control, which only a browser with
89
+ * native controls sets. A browser bundled in an app is never reported: if it is
90
+ * slow, its controls appear when it is ready. A standalone browser gets 2 s of
91
+ * wall-clock time, however slow tmux answers, and then the notice. Returns
92
+ * null once the option appears or when the pane is already gone.
93
+ */
94
+ export async function nativeControlsNotice(run, pane, executable, wait = {}) {
95
+ if (isBundledBrowser(executable))
96
+ return null;
97
+ const sleep = wait.sleep ?? ((ms) => new Promise((r) => setTimeout(r, ms)));
98
+ const now = wait.now ?? Date.now;
99
+ const deadline = now() + STANDALONE_WAIT_MS;
100
+ for (;;) {
101
+ let control;
102
+ try {
103
+ control = run(["display-message", "-p", "-t", pane, "#{@tmux_ide_browser_control}"]).trim();
104
+ }
105
+ catch {
106
+ return null;
107
+ }
108
+ if (control)
109
+ return null;
110
+ if (now() >= deadline)
111
+ return NO_NATIVE_CONTROLS_NOTICE;
112
+ await sleep(250);
113
+ }
114
+ }
67
115
  export function createBrowserPane(run, executable, url, options, environment, cwd, namespace) {
68
116
  validateBrowserOpen(url, options);
69
117
  const inheritedPane = /^%\d+$/u.test(environment.TMUX_PANE ?? "")
@@ -151,9 +199,16 @@ export function createBrowserPane(run, executable, url, options, environment, cw
151
199
  });
152
200
  return { pane: pane, session: createdSession, window: createdWindow, status: "created" };
153
201
  }
154
- export function openBrowser(url, options = {}) {
202
+ export async function openBrowser(url, options = {}, dependencies = {}) {
155
203
  validateBrowserOpen(url, options);
156
- const executable = resolveTerminalBrowser();
204
+ const executable = (dependencies.executable ?? resolveTerminalBrowser)();
205
+ const { result, run } = (dependencies.create ?? createPinnedBrowserPane)(executable, url, options);
206
+ const message = await nativeControlsNotice(run, result.pane, executable, dependencies.wait);
207
+ if (message)
208
+ (dependencies.notice ?? ((line) => process.stderr.write(`${line}\n`)))(message);
209
+ return result;
210
+ }
211
+ function createPinnedBrowserPane(executable, url, options) {
157
212
  try {
158
213
  const namespace = { ...runtimeNamespaceEnvironment(resolveRuntimeNamespace()) };
159
214
  const authority = resolveWorkspacePaneTmuxAuthority();
@@ -161,7 +216,8 @@ export function openBrowser(url, options = {}) {
161
216
  const run = createPinnedWorkspaceTmuxRunner(authority, {
162
217
  timeoutMs: 10_000,
163
218
  });
164
- return createBrowserPane(run, executable, url, options, process.env, process.cwd(), namespace);
219
+ const result = createBrowserPane(run, executable, url, options, process.env, process.cwd(), namespace);
220
+ return { result, run };
165
221
  }
166
222
  catch (error) {
167
223
  if (error instanceof IdeError)
@@ -6,6 +6,7 @@ import { PANE_STREAM_INPUT_LIFETIME_CAPABILITY_VERSION, PANE_STREAM_INPUT_BYTES_
6
6
  import { inflateSync } from "node:zlib";
7
7
  import { PaneStreamLayoutSnapshotFrameSchemaZ } from "@tmux-ide/contracts";
8
8
  import { z } from "zod";
9
+ import { logger } from "../../lib/log.js";
9
10
  import { PANE_STREAM_MAX_HELD_DELTAS, PANE_STREAM_MAX_OUTPUT_BYTES, PANE_STREAM_MAX_PANES, PANE_STREAM_MAX_SEED_BYTES, PANE_STREAM_PROTOCOL_VERSION, PANE_STREAM_REDEEM_PATH, PANE_STREAM_WEBSOCKET_SUBPROTOCOL, TERMINAL_GRAPHICS_BLOB_CHUNK_BYTES, TERMINAL_DELIVERY_BINARY_CAPABILITY_VERSION, TERMINAL_DELIVERY_BINARY_LOCAL_CAPABILITY_VERSION, TERMINAL_GRAPHICS_BINARY_CAPABILITY_VERSION, TERMINAL_GRAPHICS_LOCAL_CAPABILITY_VERSION, encodeTerminalGraphicsBinary, encodeTerminalDeliveryBinaryChunk, TERMINAL_GRAPHICS_CELL_PIXELS_CAPABILITY_VERSION, TERMINAL_GRAPHICS_PRECISE_PIXELS_CAPABILITY_VERSION, TERMINAL_GRAPHICS_LEGACY_MAX_IMAGE_BYTES, TERMINAL_GRAPHICS_ZLIB_CAPABILITY_VERSION, PaneStreamClientFrameSchemaZ, PaneStreamLoopbackWebSocketUrlSchemaZ, PaneStreamRedeemFrameSchemaZ, PaneStreamServerFrameSchemaZ, sharedMonotonicMicros, } from "@tmux-ide/contracts";
10
11
  import { canonicalOriginOrNull, digestSecret, digestsEqual, rawDataByteLength, rawDataToBuffer, safeCloseSocket, strictJsonParse, } from "../attachments/admission-util.js";
11
12
  import { SessionRuntimeControllerLeaseError } from "../session-runtime/registry.js";
@@ -547,6 +548,11 @@ class PreAuthAdmission {
547
548
  this.#open = false;
548
549
  this.#cancelDeadline();
549
550
  const socket = this.#socket;
551
+ logger.info("pane-stream", "admission closed", {
552
+ code,
553
+ reason,
554
+ hostClientId: this.hostClientId,
555
+ });
550
556
  this.#detach();
551
557
  this.#onRelease(this);
552
558
  if (socket)
@@ -825,6 +831,12 @@ export class PaneStreamLiveConnection {
825
831
  if (this.#closed)
826
832
  return;
827
833
  this.#recordDiagnosticLifecycle("pane-stream-terminal", code, reason);
834
+ logger.info("pane-stream", "stream closed", {
835
+ code,
836
+ reason,
837
+ session: this.#descriptor.sessionName,
838
+ clientId: this.#clientId,
839
+ });
828
840
  this.#closed = true;
829
841
  for (const demand of this.#surfaceDemands.values())
830
842
  demand.close();
@@ -2,6 +2,7 @@ import { prepareProjection } from "./terminal-projection-delivery.js";
2
2
  import { TerminalProjectionEnvelopeSchemaZ, TerminalProjectionReceiptSchemaZ, TerminalProjectionMaterializeSchemaZ, } from "@tmux-ide/contracts";
3
3
  import { encodeTerminalProjectionCooperatively } from "@tmux-ide/core";
4
4
  import { StockCaptureTabUnavailableError } from "./stock-capture-fidelity.js";
5
+ import { logger } from "../../lib/log.js";
5
6
  import { MAX_RETAINED_NATIVE_BACKING_BYTES, takeNativeSeedBacking, } from "./native-seed-backing.js";
6
7
  import { WorkspaceIdSchemaZ, TERMINAL_GRAPHICS_CAPABILITY_VERSION, TERMINAL_DELIVERY_PATCH_TO_SEED_BYTES, TERMINAL_DELIVERY_MAX_REPRESENTATION_BYTES, TerminalDeliveryEnvelopeSchemaZ, TerminalDeliveryAckSchemaZ, TerminalDeliveryNackSchemaZ, TerminalDeliveryOfferSchemaZ, TerminalDeliveryVisibilitySchemaZ, } from "@tmux-ide/contracts";
7
8
  import { TerminalDeliveryStateTooLargeError, terminalReplicaRowsEqual, TERMINAL_COMPACT_COOPERATIVE_SEED_CELLS, applyTerminalReplicaUpdate, applyTerminalReplicaUpdateCooperatively, terminalReplicaUpdateNeedsCooperativeReduction, encodeBinarySemanticTerminalUpdate, encodeBinarySemanticTerminalUpdateCooperatively, encodeCompactSemanticTerminalUpdate, encodeCompactSemanticTerminalUpdateCooperatively, terminalSemanticUpdateNeedsCooperativeEncoding, encodeAnsiTerminalRepresentation, encodeSemanticTerminalUpdate, hashTerminalDeliveryRepresentation, negotiateTerminalDelivery, preaccountSemanticTerminalUpdateBytes, } from "@tmux-ide/core";
@@ -196,6 +197,7 @@ export class SessionRuntimeTerminalDeliveryHub {
196
197
  if (!(error instanceof StockCaptureTabUnavailableError))
197
198
  throw error;
198
199
  // Negotiation still settles this pane; no invented canonical seed is published.
200
+ logDeliveryFault({ paneId: semanticPaneId, deliveryWorkspaceName: address }, "source-closed", "capture-unavailable");
199
201
  await accept({
200
202
  type: "terminal.delivery.fault",
201
203
  reason: "source-closed",
@@ -350,6 +352,7 @@ export class SessionRuntimeTerminalDeliveryHub {
350
352
  this.#cancelEncoding(client);
351
353
  client.outgoing.length = 0;
352
354
  client.inFlight = null;
355
+ logDeliveryFault(client, "source-closed", "source-restarted");
353
356
  this.#enqueue(client, {
354
357
  type: "terminal.delivery.fault",
355
358
  reason: "source-closed",
@@ -1442,6 +1445,7 @@ export class SessionRuntimeTerminalDeliveryHub {
1442
1445
  this.#recordDeliveryStatus(client, this.#panes.get(client.paneId));
1443
1446
  }
1444
1447
  #fault(client, reason, message, selectionObservation = null) {
1448
+ logDeliveryFault(client, reason);
1445
1449
  if (this.#observability.enabled)
1446
1450
  try {
1447
1451
  const atMicros = this.#observability.nowMicros();
@@ -2038,3 +2042,15 @@ function joinBytes(chunks) {
2038
2042
  }
2039
2043
  return joined;
2040
2044
  }
2045
+ /**
2046
+ * One info line per fault a client is sent, so a client's drop has a daemon-side
2047
+ * record. Ids and fixed reasons only: the fault message can carry source text.
2048
+ */
2049
+ function logDeliveryFault(client, reason, cause) {
2050
+ logger.info("terminal-delivery", "fault sent to client", {
2051
+ reason,
2052
+ ...(cause ? { cause } : {}),
2053
+ pane: client.paneId,
2054
+ workspace: client.deliveryWorkspaceName,
2055
+ });
2056
+ }
@@ -90,7 +90,7 @@ export class DaemonFleetFactsObserver {
90
90
 
91
91
  constructor(options: DaemonFleetFactsObserverOptions) {
92
92
  this.#options = options;
93
- this.#intervalMs = options.intervalMs ?? 2_000;
93
+ this.#intervalMs = options.intervalMs ?? FLEET_FACTS_POLL_MS;
94
94
  this.#setTimer =
95
95
  options.setTimer ??
96
96
  ((callback, delayMs) => {
@@ -549,6 +549,14 @@ export function createDefaultFleetFactsReaders(): Pick<
549
549
  return createSharedFleetFactsReaders(execute);
550
550
  }
551
551
 
552
+ /**
553
+ * How often subscribed clients' tmux facts (sessions, panes, agent stamps) are
554
+ * read. Hook-stamped agent changes reach clients within one tick, so 500 ms
555
+ * keeps a state flip visible within 1 s (#716). One `list-panes -a` per tick,
556
+ * only while a client is subscribed.
557
+ */
558
+ export const FLEET_FACTS_POLL_MS = 500;
559
+
552
560
  export const SESSION_COMPOSITION_TMUX_ARGS = [
553
561
  "list-panes",
554
562
  "-a",
@@ -22,6 +22,7 @@ import {
22
22
  TmuxServerScopeSchemaZ,
23
23
  TmuxServersResourceSchemaZ,
24
24
  TmuxServerSessionsResourceSchemaZ,
25
+ TmuxServerSessionsResourceV2SchemaZ,
25
26
  WorkspaceMultiplexerMutationRequestSchemaZ,
26
27
  WorkspacePaneCreateMutationRequestSchemaZ,
27
28
  WorkspaceSessionCreateArgumentsSchemaZ,
@@ -32,6 +33,7 @@ import {
32
33
  TmuxServerScopeError,
33
34
  } from "../lib/tmux-server-owners.ts";
34
35
  import type { NativeTmuxServerOwner } from "../lib/tmux-server-owner.ts";
36
+ import { createSessionFaviconResolver, type SessionFavicon } from "../lib/session-favicon.ts";
35
37
  import { WorkspaceMultiplexerError } from "../lib/workspace-multiplexer-verbs.ts";
36
38
  import { WorkspacePaneCreationError } from "../lib/workspace-pane-creation.ts";
37
39
  import { ownerBearerMatches } from "./owner-authority.ts";
@@ -41,7 +43,11 @@ import { projectTerminalRuntimeInventory } from "./resources/terminal-runtime-in
41
43
  export interface TmuxServerRoutesOptions {
42
44
  readonly ownerToken: string | null;
43
45
  readonly owners: TmuxServerOwners<NativeTmuxServerOwner>;
46
+ /** Project favicon lookup for the version 2 sessions resource. */
47
+ readonly resolveSessionFavicon?: (sessionPath: string) => Promise<SessionFavicon | null>;
44
48
  }
49
+ /** Total raw favicon bytes one version 2 sessions response may carry. */
50
+ export const SESSIONS_RESOURCE_FAVICON_BUDGET_BYTES = 512 * 1024;
45
51
  const registrationSchema = TmuxServerRegistrationSchemaZ.omit({ serverId: true }).strict();
46
52
  const workspaceSchema = z
47
53
  .string()
@@ -94,6 +100,7 @@ function failure(c: Context, error: unknown): Response {
94
100
  /** Owner capability is mandatory even when the ordinary local HTTP surface is open. */
95
101
  export function mountTmuxServerRoutes(app: Hono, options: TmuxServerRoutesOptions): void {
96
102
  const base = TMUX_SERVERS_API_PATH;
103
+ const resolveSessionFavicon = options.resolveSessionFavicon ?? createSessionFaviconResolver();
97
104
  const authorize = async (c: Context, next: () => Promise<void>) => {
98
105
  c.header("Cache-Control", "no-store");
99
106
  if (!ownerBearerMatches(c.req.header("Authorization"), options.ownerToken))
@@ -150,6 +157,17 @@ export function mountTmuxServerRoutes(app: Hono, options: TmuxServerRoutesOption
150
157
  owners: options.owners,
151
158
  readBody: (request) => boundedJson(request, 8 * 1024),
152
159
  });
160
+ app.post(
161
+ `${scoped}/native-browser`,
162
+ route(async (c) => {
163
+ const server = scope(c);
164
+ const input = await boundedJson(c.req.raw, 24 * 1024);
165
+ const state = await options.owners.withOwner(server, (owner) =>
166
+ owner.nativeBrowser(input, c.req.raw.signal),
167
+ );
168
+ return c.json({ version: 1, state });
169
+ }),
170
+ );
153
171
  // Structured read only: this never acquires a channel or changes tmux geometry.
154
172
  app.post(
155
173
  `${scoped}/split-layout/:workspaceName`,
@@ -260,19 +278,56 @@ export function mountTmuxServerRoutes(app: Hono, options: TmuxServerRoutesOption
260
278
  `${scoped}/sessions`,
261
279
  route(async (c) => {
262
280
  const server = scope(c);
281
+ const v2 = c.req.query("version") === "2";
282
+ const { rows, favicons } = await options.owners.withOwner(server, async (owner) => {
283
+ const sessions = await owner.catalog();
284
+ const workspaces = owner.workspaceRegistry.list();
285
+ const rows = sessions.map((session) => {
286
+ const aliases = workspaces.filter(
287
+ (workspace) => workspace.sessionName === session.sessionName,
288
+ );
289
+ return { session, workspaceName: aliases.length === 1 ? aliases[0]!.name : null };
290
+ });
291
+ const favicons = v2
292
+ ? await Promise.all(
293
+ sessions.map((session) =>
294
+ resolveSessionFavicon(session.sessionPath).catch(() => null),
295
+ ),
296
+ )
297
+ : [];
298
+ return { rows, favicons };
299
+ });
300
+ if (!v2)
301
+ return c.json(
302
+ TmuxServerSessionsResourceSchemaZ.parse({
303
+ version: 1,
304
+ server,
305
+ sessions: rows.map(({ session, workspaceName }) => ({
306
+ liveSessionId: session.liveSessionId,
307
+ sessionName: session.sessionName,
308
+ paneCount: session.paneCount,
309
+ workspaceName,
310
+ })),
311
+ }),
312
+ );
313
+ let budget = SESSIONS_RESOURCE_FAVICON_BUDGET_BYTES;
263
314
  return c.json(
264
- TmuxServerSessionsResourceSchemaZ.parse({
265
- version: 1,
315
+ TmuxServerSessionsResourceV2SchemaZ.parse({
316
+ version: 2,
266
317
  server,
267
- sessions: await options.owners.withOwner(server, async (owner) => {
268
- const sessions = await owner.catalog();
269
- const workspaces = owner.workspaceRegistry.list();
270
- return sessions.map((session) => {
271
- const aliases = workspaces.filter(
272
- (workspace) => workspace.sessionName === session.sessionName,
273
- );
274
- return { ...session, workspaceName: aliases.length === 1 ? aliases[0]!.name : null };
275
- });
318
+ sessions: rows.map(({ session, workspaceName }, index) => {
319
+ const found = favicons[index] ?? null;
320
+ const favicon = found && found.size <= budget ? found : null;
321
+ if (favicon) budget -= favicon.size;
322
+ return {
323
+ liveSessionId: session.liveSessionId,
324
+ sessionName: session.sessionName,
325
+ workspaceName,
326
+ paneCount: session.paneCount,
327
+ windowCount: session.windowCount,
328
+ lastActivity: session.lastActivity,
329
+ favicon: favicon ? { mime: favicon.mime, data: favicon.data } : null,
330
+ };
276
331
  }),
277
332
  }),
278
333
  );
@@ -1,3 +1,4 @@
1
+ import { createNativeBrowserResolver } from "./tmux-native-browser.ts";
1
2
  import { createNativeSurfaceContextResolver } from "./native-surface-context.ts";
2
3
  import { NativeSurfaceProviderRegistry } from "../terminal/graphics/native-surface-provider.ts";
3
4
  import { TmuxServerVersionSchemaZ } from "@tmux-ide/contracts";
@@ -1937,6 +1938,22 @@ async function startEmbeddedDaemonGeneration(
1937
1938
  throw new Error("Default tmux editor context owner is unavailable");
1938
1939
  },
1939
1940
  }),
1941
+ nativeBrowser: createNativeBrowserResolver({
1942
+ generation: instanceId,
1943
+ run: fleetFactsTmuxRunner,
1944
+ registry: workspaceRegistry,
1945
+ catalog: createNativeTmuxServerCatalog(workspaceRegistry, fleetFactsTmuxRunner),
1946
+ inventory: terminalInventoryRuntime,
1947
+ assertOpen: () => {
1948
+ if (
1949
+ defaultEditorContextRetired ||
1950
+ serverOwners?.defaultRetired ||
1951
+ !initialNativeServerIdentity ||
1952
+ terminalInventoryRuntime?.lifecycleState() !== "ready"
1953
+ )
1954
+ throw new Error("Default native browser owner is unavailable");
1955
+ },
1956
+ }),
1940
1957
  catalog: createNativeTmuxServerCatalog(workspaceRegistry, fleetFactsTmuxRunner),
1941
1958
  readWindowSplitLayout: async (workspaceName, input) => {
1942
1959
  const target = WindowLinkTargetSchemaZ.parse(input);