tmux-ide 2.9.15 → 2.9.17

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 (41) hide show
  1. package/README.md +1 -1
  2. package/bin/cli.js +740 -185
  3. package/package.json +2 -1
  4. package/packages/contracts/src/tmux-server-scope.ts +54 -0
  5. package/packages/core/src/terminal-fnv64-wasm.ts +8 -1
  6. package/packages/core/src/terminal-replica-hash-cache.ts +108 -1
  7. package/packages/core/src/terminal-replica.ts +46 -10
  8. package/packages/daemon/dist/command-center/daemon-fleet-facts-observer.js +8 -1
  9. package/packages/daemon/dist/command-center/discovery.js +39 -6
  10. package/packages/daemon/dist/command-center/tmux-servers.js +46 -10
  11. package/packages/daemon/dist/lib/catalog-fleet-read.js +10 -0
  12. package/packages/daemon/dist/lib/daemon-embed.js +14 -3
  13. package/packages/daemon/dist/lib/daemon-service.js +13 -5
  14. package/packages/daemon/dist/lib/session-favicon.js +152 -0
  15. package/packages/daemon/dist/lib/skill-sync.js +3 -3
  16. package/packages/daemon/dist/lib/tmux-server-owner.js +16 -3
  17. package/packages/daemon/dist/lib/tmux-server-owners.js +20 -1
  18. package/packages/daemon/dist/lib/tui-binary.js +83 -17
  19. package/packages/daemon/dist/native/TmuxIdeNotifier.app/Contents/Info.plist +2 -2
  20. package/packages/daemon/dist/native/TmuxIdeNotifier.app/Contents/MacOS/tmux-ide-notifier +0 -0
  21. package/packages/daemon/dist/native/TmuxIdeNotifier.app/Contents/Resources/AppIcon.icns +0 -0
  22. package/packages/daemon/dist/native/TmuxIdeNotifier.app/Contents/Resources/Assets.car +0 -0
  23. package/packages/daemon/dist/native/TmuxIdeNotifier.app/Contents/_CodeSignature/CodeResources +4 -4
  24. package/packages/daemon/dist/open-browser.js +47 -3
  25. package/packages/daemon/dist/terminal/pane-stream/pane-stream-websocket.js +12 -0
  26. package/packages/daemon/dist/terminal/session-runtime/terminal-delivery-hub.js +16 -0
  27. package/packages/daemon/src/command-center/daemon-fleet-facts-observer.ts +9 -1
  28. package/packages/daemon/src/command-center/discovery.ts +72 -7
  29. package/packages/daemon/src/command-center/tmux-servers.ts +55 -11
  30. package/packages/daemon/src/lib/catalog-fleet-read.ts +40 -0
  31. package/packages/daemon/src/lib/daemon-embed.ts +27 -12
  32. package/packages/daemon/src/lib/daemon-service.ts +17 -4
  33. package/packages/daemon/src/lib/session-favicon.ts +185 -0
  34. package/packages/daemon/src/lib/skill-sync.ts +3 -3
  35. package/packages/daemon/src/lib/tmux-server-owner.ts +35 -6
  36. package/packages/daemon/src/lib/tmux-server-owners.ts +17 -1
  37. package/packages/daemon/src/lib/tui-binary.ts +114 -22
  38. package/packages/daemon/src/open-browser.ts +88 -3
  39. package/packages/daemon/src/terminal/pane-stream/pane-stream-websocket.ts +12 -0
  40. package/packages/daemon/src/terminal/session-runtime/terminal-delivery-hub.ts +25 -0
  41. package/skill/SKILL.md +6 -1
@@ -0,0 +1,152 @@
1
+ import { constants } from "node:fs";
2
+ import { lstat, open } from "node:fs/promises";
3
+ import { extname, isAbsolute, join } from "node:path";
4
+ import { TMUX_SERVER_SESSION_FAVICON_MAX_BYTES, } from "@tmux-ide/contracts";
5
+ /** Project-relative favicon locations, first match wins. */
6
+ export const SESSION_FAVICON_CANDIDATES = [
7
+ "favicon.svg",
8
+ "favicon.png",
9
+ "favicon.ico",
10
+ "public/favicon.svg",
11
+ "public/favicon.png",
12
+ "public/favicon.ico",
13
+ "app/icon.svg",
14
+ "app/icon.png",
15
+ "app/favicon.ico",
16
+ "src/app/icon.svg",
17
+ "src/app/icon.png",
18
+ "src/app/favicon.ico",
19
+ "static/favicon.svg",
20
+ "static/favicon.png",
21
+ "static/favicon.ico",
22
+ "assets/favicon.svg",
23
+ "assets/favicon.png",
24
+ "assets/favicon.ico",
25
+ "apple-touch-icon.png",
26
+ "public/apple-touch-icon.png",
27
+ "icon.svg",
28
+ "icon.png",
29
+ ];
30
+ const MIME_BY_EXTENSION = {
31
+ ".png": "image/png",
32
+ ".svg": "image/svg+xml",
33
+ ".ico": "image/x-icon",
34
+ ".jpg": "image/jpeg",
35
+ ".jpeg": "image/jpeg",
36
+ ".webp": "image/webp",
37
+ ".gif": "image/gif",
38
+ };
39
+ export const defaultSessionFaviconIo = {
40
+ lstat: (path) => lstat(path),
41
+ readNoFollow: async (path, maxBytes) => {
42
+ let handle;
43
+ try {
44
+ handle = await open(path, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK);
45
+ }
46
+ catch {
47
+ return null;
48
+ }
49
+ try {
50
+ const stats = await handle.stat();
51
+ if (!stats.isFile() || stats.size > maxBytes)
52
+ return null;
53
+ const bytes = await handle.readFile();
54
+ return bytes.byteLength > maxBytes ? null : bytes;
55
+ }
56
+ catch {
57
+ return null;
58
+ }
59
+ finally {
60
+ await handle.close().catch(() => undefined);
61
+ }
62
+ },
63
+ };
64
+ async function lstatOrNull(io, path) {
65
+ try {
66
+ return await io.lstat(path);
67
+ }
68
+ catch {
69
+ return null;
70
+ }
71
+ }
72
+ /**
73
+ * Finds a session's project favicon from its working directory. Only regular
74
+ * files and real directories inside the session path are considered; symlinks
75
+ * are never followed. Hits are revalidated by mtime and size, misses are
76
+ * remembered briefly, and the cache is bounded (least recently used first).
77
+ */
78
+ export function createSessionFaviconResolver(options = {}) {
79
+ const io = options.io ?? defaultSessionFaviconIo;
80
+ const now = options.now ?? Date.now;
81
+ const missTtlMs = options.missTtlMs ?? 30_000;
82
+ const maxEntries = options.maxEntries ?? 256;
83
+ const cache = new Map();
84
+ const remember = (sessionPath, entry) => {
85
+ cache.delete(sessionPath);
86
+ cache.set(sessionPath, entry);
87
+ while (cache.size > maxEntries)
88
+ cache.delete(cache.keys().next().value);
89
+ };
90
+ const scan = async (sessionPath) => {
91
+ const directories = new Map();
92
+ for (const candidate of SESSION_FAVICON_CANDIDATES) {
93
+ const mime = MIME_BY_EXTENSION[extname(candidate)];
94
+ if (!mime)
95
+ continue;
96
+ const slash = candidate.lastIndexOf("/");
97
+ if (slash >= 0) {
98
+ const parts = candidate.slice(0, slash).split("/");
99
+ let real = true;
100
+ for (let index = 1; index <= parts.length && real; index += 1) {
101
+ const directory = parts.slice(0, index).join("/");
102
+ let known = directories.get(directory);
103
+ if (known === undefined) {
104
+ const stats = await lstatOrNull(io, join(sessionPath, directory));
105
+ known = stats !== null && stats.isDirectory() && !stats.isSymbolicLink();
106
+ directories.set(directory, known);
107
+ }
108
+ real = known;
109
+ }
110
+ if (!real)
111
+ continue;
112
+ }
113
+ const file = join(sessionPath, candidate);
114
+ const stats = await lstatOrNull(io, file);
115
+ if (!stats || !stats.isFile() || stats.size > TMUX_SERVER_SESSION_FAVICON_MAX_BYTES)
116
+ continue;
117
+ const bytes = await io.readNoFollow(file, TMUX_SERVER_SESSION_FAVICON_MAX_BYTES);
118
+ if (!bytes)
119
+ continue;
120
+ return {
121
+ kind: "hit",
122
+ file,
123
+ mtimeMs: stats.mtimeMs,
124
+ size: stats.size,
125
+ favicon: { mime, data: bytes.toString("base64"), size: bytes.byteLength },
126
+ };
127
+ }
128
+ return { kind: "miss", at: now() };
129
+ };
130
+ return async (sessionPath) => {
131
+ if (!sessionPath || !isAbsolute(sessionPath) || sessionPath.includes("\0"))
132
+ return null;
133
+ const cached = cache.get(sessionPath);
134
+ if (cached?.kind === "miss" && now() - cached.at < missTtlMs) {
135
+ remember(sessionPath, cached);
136
+ return null;
137
+ }
138
+ if (cached?.kind === "hit") {
139
+ const stats = await lstatOrNull(io, cached.file);
140
+ if (stats &&
141
+ stats.isFile() &&
142
+ stats.mtimeMs === cached.mtimeMs &&
143
+ stats.size === cached.size) {
144
+ remember(sessionPath, cached);
145
+ return cached.favicon;
146
+ }
147
+ }
148
+ const entry = await scan(sessionPath);
149
+ remember(sessionPath, entry);
150
+ return entry.kind === "hit" ? entry.favicon : null;
151
+ };
152
+ }
@@ -13,8 +13,8 @@ import { resolveRuntimeNamespace } from "./runtime-namespace.js";
13
13
  *
14
14
  * The dir is FULLY MANAGED: overwriting our own `~/.claude/skills/tmux-ide` is
15
15
  * correct, and this NEVER touches anything outside it (user-authored skills in
16
- * sibling dirs are safe). The version marker is line 2 of SKILL.md, an HTML
17
- * comment ({@link VERSION_MARKER_RE}); {@link parseSkillVersion} reads it and
16
+ * sibling dirs are safe). The version marker is an HTML comment under
17
+ * SKILL.md's frontmatter ({@link VERSION_MARKER_RE}); {@link parseSkillVersion} reads it and
18
18
  * {@link rewriteVersionMarker} substitutes it at copy time.
19
19
  *
20
20
  * The parse/render helpers are PURE and tested against fixtures; {@link syncSkill}
@@ -66,7 +66,7 @@ export function defaultSkillSource() {
66
66
  // Pure — version marker
67
67
  // ---------------------------------------------------------------------------
68
68
  /**
69
- * The version marker line (an HTML comment on line 2 of SKILL.md). The sync
69
+ * The version marker line (an HTML comment under SKILL.md's frontmatter). The sync
70
70
  * machinery reads it to know which version an installed copy is, and rewrites it
71
71
  * to the installed package version at copy time.
72
72
  */
@@ -69,7 +69,8 @@ export function createNativeTmuxServerCatalog(workspaceRegistry, run, assertOpen
69
69
  "list-panes",
70
70
  "-a",
71
71
  "-F",
72
- "#{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}",
73
74
  ]
74
75
  .map(shellEscape)
75
76
  .join(" "),
@@ -80,7 +81,8 @@ export function createNativeTmuxServerCatalog(workspaceRegistry, run, assertOpen
80
81
  // Command errors, unsupported formats and unwitnessed blank output stay failures.
81
82
  const lines = raw.trim() === "tmux-ide-empty-catalog-v1" ? [] : raw.trim().split("\n");
82
83
  for (const line of lines) {
83
- 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");
84
86
  if (!pid ||
85
87
  !/^\d+$/.test(pid) ||
86
88
  !id ||
@@ -90,6 +92,8 @@ export function createNativeTmuxServerCatalog(workspaceRegistry, run, assertOpen
90
92
  !name ||
91
93
  !pane ||
92
94
  !/^%\d+$/.test(pane) ||
95
+ !window ||
96
+ !/^@\d+$/.test(window) ||
93
97
  !dir)
94
98
  throw new Error("Invalid native server catalog");
95
99
  if (!isVisibleFleetSession(name))
@@ -98,9 +102,12 @@ export function createNativeTmuxServerCatalog(workspaceRegistry, run, assertOpen
98
102
  const row = rows.get(liveSessionId) ?? {
99
103
  summary: { liveSessionId, sessionName: name, paneCount: 0 },
100
104
  panes: new Set(),
105
+ windows: new Set(),
101
106
  dir,
107
+ activity: activity && /^\d+$/.test(activity) ? Number(activity) : null,
102
108
  };
103
109
  row.panes.add(pane);
110
+ row.windows.add(window);
104
111
  rows.set(liveSessionId, row);
105
112
  }
106
113
  const names = new Set([...rows.values()].map(({ summary }) => summary.sessionName));
@@ -115,7 +122,13 @@ export function createNativeTmuxServerCatalog(workspaceRegistry, run, assertOpen
115
122
  projectDir: dir,
116
123
  persistence: "volatile",
117
124
  });
118
- 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
+ }));
119
132
  };
120
133
  }
121
134
  /** One proven server incarnation. Never elects an endpoint or changes ambient tmux authority. */
@@ -24,6 +24,8 @@ export class TmuxServerOwners {
24
24
  constructor(options) {
25
25
  this.options = options;
26
26
  }
27
+ #queuedRefresh = null;
28
+ #queuedRefreshTail = null;
27
29
  #serial(work) {
28
30
  const run = this.#tail.then(() => {
29
31
  if (this.#disposed)
@@ -134,12 +136,29 @@ export class TmuxServerOwners {
134
136
  this.#entries.set(scope.serverId, { registration, scope, observation, owner });
135
137
  }
136
138
  refresh() {
137
- return this.#serial(async () => {
139
+ // A refresh still waiting its turn has not probed anything yet, so it is
140
+ // as fresh for a later caller as a new one: share it instead of queuing
141
+ // another full refresh behind it (#709). Once it starts, callers queue anew.
142
+ // Share it only while nothing has been queued behind it, so a refresh
143
+ // stays ordered after any mutation queued before the caller asked.
144
+ if (this.#queuedRefresh && this.#tail === this.#queuedRefreshTail)
145
+ return this.#queuedRefresh;
146
+ const queued = this.#serial(async () => {
147
+ if (this.#queuedRefresh === queued)
148
+ this.#queuedRefresh = null;
138
149
  // Sequential admission bounds subprocess work and avoids alias races.
139
150
  for (const entry of this.#entries.values())
140
151
  await this.#refresh(entry);
141
152
  return this.list();
142
153
  });
154
+ this.#queuedRefresh = queued;
155
+ this.#queuedRefreshTail = this.#tail;
156
+ const settle = () => {
157
+ if (this.#queuedRefresh === queued)
158
+ this.#queuedRefresh = null;
159
+ };
160
+ queued.then(settle, settle);
161
+ return queued;
143
162
  }
144
163
  current(scopeInput) {
145
164
  const scope = TmuxServerScopeSchemaZ.parse(scopeInput);
@@ -26,8 +26,18 @@ export const MIN_TUI_BINARY_BYTES = 10 * 1024 * 1024;
26
26
  /** Hard download/decompression ceilings: release binaries are currently ~70MB. */
27
27
  export const MAX_TUI_COMPRESSED_BYTES = 128 * 1024 * 1024;
28
28
  export const MAX_TUI_BINARY_BYTES = 256 * 1024 * 1024;
29
- export const TUI_DOWNLOAD_TIMEOUT_MS = 30_000;
29
+ /** Abort a request when no bytes arrive for this long; a slow transfer never trips it. */
30
+ export const TUI_DOWNLOAD_STALL_TIMEOUT_MS = 30_000;
31
+ /** One budget for the manifest, the binary, and every retry: ~145 KB/s at the size ceiling. */
32
+ export const TUI_DOWNLOAD_TOTAL_TIMEOUT_MS = 15 * 60_000;
33
+ /** Attempts per file; only transient failures are retried. */
34
+ export const TUI_DOWNLOAD_MAX_ATTEMPTS = 3;
35
+ export const TUI_DOWNLOAD_RETRY_DELAY_MS = 1_000;
30
36
  const MAX_CHECKSUM_MANIFEST_BYTES = 4 * 1024;
37
+ /** A second installer says why it is quiet once the wait is longer than a lock handover. */
38
+ const TUI_DOWNLOAD_LOCK_NOTICE_MS = 2_000;
39
+ /** Lets a lock waiter outlast the holder's verify and install after its download budget. */
40
+ const TUI_DOWNLOAD_LOCK_MARGIN_MS = 5_000;
31
41
  const SUPPORTED = {
32
42
  "darwin-arm64": "darwin-arm64",
33
43
  "linux-x64": "linux-x64",
@@ -109,6 +119,9 @@ export function findDownloadedTui(version = getCurrentVersion(), options = {}) {
109
119
  purgeCachedBinary(path);
110
120
  return null;
111
121
  }
122
+ /** A failure a retry cannot fix: the release asset is missing or breaks a hard limit. */
123
+ class PermanentDownloadError extends Error {
124
+ }
112
125
  function checksumPath(path) {
113
126
  return `${path}.sha256`;
114
127
  }
@@ -198,32 +211,53 @@ function purgeCachedBinary(path) {
198
211
  }
199
212
  }
200
213
  }
201
- async function readBoundedResponse(fetchImpl, url, maxBytes, timeoutMs) {
214
+ function isTransientHttpStatus(status) {
215
+ return status === 408 || status === 429 || status >= 500;
216
+ }
217
+ async function readBoundedResponse(fetchImpl, url, maxBytes, policy) {
202
218
  const controller = new AbortController();
203
- const timer = setTimeout(() => controller.abort(), timeoutMs);
219
+ let total = 0;
220
+ let timeoutMessage = null;
221
+ const abortWith = (message) => () => {
222
+ timeoutMessage = message();
223
+ controller.abort();
224
+ };
225
+ const overallTimer = setTimeout(abortWith(() => `download did not finish within ${policy.totalTimeoutMs}ms`), Math.max(0, policy.deadline - Date.now()));
226
+ const onStall = abortWith(() => `download stalled: no data for ${policy.stallTimeoutMs}ms after ${total} bytes`);
227
+ let stallTimer = setTimeout(onStall, policy.stallTimeoutMs);
228
+ const markProgress = () => {
229
+ clearTimeout(stallTimer);
230
+ stallTimer = setTimeout(onStall, policy.stallTimeoutMs);
231
+ };
204
232
  try {
205
233
  const response = await fetchImpl(url, { signal: controller.signal });
206
234
  if (!response.ok) {
207
- throw new Error(`HTTP ${response.status} ${response.statusText}`);
235
+ const message = `HTTP ${response.status} ${response.statusText}`;
236
+ if (isTransientHttpStatus(response.status))
237
+ throw new Error(message);
238
+ throw new PermanentDownloadError(message);
208
239
  }
240
+ markProgress();
209
241
  const declaredLength = Number(response.headers.get("content-length"));
210
242
  if (Number.isFinite(declaredLength) && declaredLength > maxBytes) {
211
- throw new Error(`declared size ${declaredLength} exceeds ${maxBytes} bytes`);
243
+ throw new PermanentDownloadError(`declared size ${declaredLength} exceeds ${maxBytes} bytes`);
212
244
  }
213
245
  if (!response.body)
214
246
  throw new Error("response has no body");
215
247
  const chunks = [];
216
- let total = 0;
217
248
  const reader = response.body.getReader();
218
249
  try {
219
250
  while (true) {
220
251
  const { done, value } = await reader.read();
221
252
  if (done)
222
253
  break;
254
+ if (value.byteLength === 0)
255
+ continue;
256
+ markProgress();
223
257
  total += value.byteLength;
224
258
  if (total > maxBytes) {
225
259
  await reader.cancel();
226
- throw new Error(`download exceeds ${maxBytes} bytes`);
260
+ throw new PermanentDownloadError(`download exceeds ${maxBytes} bytes`);
227
261
  }
228
262
  chunks.push(Buffer.from(value));
229
263
  }
@@ -234,13 +268,33 @@ async function readBoundedResponse(fetchImpl, url, maxBytes, timeoutMs) {
234
268
  return Buffer.concat(chunks, total);
235
269
  }
236
270
  catch (error) {
237
- if (controller.signal.aborted) {
238
- throw new Error(`download timed out after ${timeoutMs}ms`, { cause: error });
239
- }
271
+ if (timeoutMessage)
272
+ throw new Error(timeoutMessage, { cause: error });
240
273
  throw error;
241
274
  }
242
275
  finally {
243
- clearTimeout(timer);
276
+ clearTimeout(overallTimer);
277
+ clearTimeout(stallTimer);
278
+ // Releases the connection of any response whose body was not fully read.
279
+ controller.abort();
280
+ }
281
+ }
282
+ async function readWithRetries(fetchImpl, url, maxBytes, policy, log) {
283
+ for (let attempt = 1;; attempt++) {
284
+ try {
285
+ return await readBoundedResponse(fetchImpl, url, maxBytes, policy);
286
+ }
287
+ catch (error) {
288
+ const delayMs = policy.retryDelayMs * attempt;
289
+ if (error instanceof PermanentDownloadError ||
290
+ attempt >= policy.maxAttempts ||
291
+ Date.now() + delayMs >= policy.deadline) {
292
+ throw error;
293
+ }
294
+ const message = error instanceof Error ? error.message : String(error);
295
+ log(`download attempt ${attempt}/${policy.maxAttempts} failed (${message}); retrying`);
296
+ await new Promise((resolve) => setTimeout(resolve, delayMs));
297
+ }
244
298
  }
245
299
  }
246
300
  /**
@@ -270,10 +324,11 @@ export async function downloadTuiBinary(opts = {}) {
270
324
  minBinaryBytes: opts.limits?.minBinaryBytes ?? MIN_TUI_BINARY_BYTES,
271
325
  maxCompressedBytes: opts.limits?.maxCompressedBytes ?? MAX_TUI_COMPRESSED_BYTES,
272
326
  maxBinaryBytes: opts.limits?.maxBinaryBytes ?? MAX_TUI_BINARY_BYTES,
273
- timeoutMs: opts.timeoutMs ?? TUI_DOWNLOAD_TIMEOUT_MS,
274
327
  };
328
+ const totalTimeoutMs = opts.totalTimeoutMs ?? TUI_DOWNLOAD_TOTAL_TIMEOUT_MS;
275
329
  mkdirSync(dirname(dest), { recursive: true });
276
- const releaseLock = await acquirePrivateOperationLock(downloadLockPath(dest), limits.timeoutMs * 2 + 5_000);
330
+ const lockNotice = setTimeout(() => log("waiting for another tmux-ide download of this version to finish"), TUI_DOWNLOAD_LOCK_NOTICE_MS);
331
+ const releaseLock = await acquirePrivateOperationLock(downloadLockPath(dest), totalTimeoutMs + TUI_DOWNLOAD_LOCK_MARGIN_MS).finally(() => clearTimeout(lockNotice));
277
332
  const suffix = `${process.pid}.${randomUUID()}.tmp`;
278
333
  const binaryTmp = `${dest}.${suffix}`;
279
334
  const checksumTmp = `${checksumPath(dest)}.${suffix}`;
@@ -283,17 +338,28 @@ export async function downloadTuiBinary(opts = {}) {
283
338
  return { path: dest, bytes: cached.bytes };
284
339
  purgeCachedBinary(dest);
285
340
  const fetchImpl = opts.fetch ?? globalThis.fetch;
341
+ // Starts after the lock so the budget matches what a lock waiter allows for.
342
+ const policy = {
343
+ stallTimeoutMs: opts.stallTimeoutMs ?? TUI_DOWNLOAD_STALL_TIMEOUT_MS,
344
+ deadline: Date.now() + totalTimeoutMs,
345
+ totalTimeoutMs,
346
+ maxAttempts: opts.maxAttempts ?? TUI_DOWNLOAD_MAX_ATTEMPTS,
347
+ retryDelayMs: opts.retryDelayMs ?? TUI_DOWNLOAD_RETRY_DELAY_MS,
348
+ };
286
349
  log(`downloading checksum manifest ${checksumUrl}`);
287
350
  let manifestBytes;
288
351
  let gz;
289
352
  try {
290
- manifestBytes = await readBoundedResponse(fetchImpl, checksumUrl, MAX_CHECKSUM_MANIFEST_BYTES, limits.timeoutMs);
291
- gz = await readBoundedResponse(fetchImpl, url, limits.maxCompressedBytes, limits.timeoutMs);
353
+ manifestBytes = await readWithRetries(fetchImpl, checksumUrl, MAX_CHECKSUM_MANIFEST_BYTES, policy, log);
354
+ log(`downloading ${url}`);
355
+ gz = await readWithRetries(fetchImpl, url, limits.maxCompressedBytes, policy, log);
292
356
  }
293
357
  catch (error) {
294
358
  const message = error instanceof Error ? error.message : String(error);
295
- throw new Error(`could not download the TUI binary and checksum for v${version} (${message}). ` +
296
- `Check that the release published both exact-version assets.`, { cause: error });
359
+ const hint = error instanceof PermanentDownloadError
360
+ ? "Check that the release published both exact-version assets."
361
+ : "Check your network connection, then retry with `tmux-ide update --tui-binary`, or run the installer again if tmux-ide is not installed yet.";
362
+ throw new Error(`could not download the TUI binary and checksum for v${version} (${message}). ${hint}`, { cause: error });
297
363
  }
298
364
  const manifest = parseChecksumManifest(manifestBytes, { version, tag });
299
365
  if (sha256(gz) !== manifest.compressedSha256) {
@@ -21,9 +21,9 @@
21
21
  <key>CFBundlePackageType</key>
22
22
  <string>APPL</string>
23
23
  <key>CFBundleShortVersionString</key>
24
- <string>2.9.15</string>
24
+ <string>2.9.17</string>
25
25
  <key>CFBundleVersion</key>
26
- <string>2.9.15</string>
26
+ <string>2.9.17</string>
27
27
  <key>LSMinimumSystemVersion</key>
28
28
  <string>11.0</string>
29
29
  <key>LSUIElement</key>
@@ -6,11 +6,11 @@
6
6
  <dict>
7
7
  <key>Resources/AppIcon.icns</key>
8
8
  <data>
9
- 0kJOhKI63m7RQF/lR1nMjIIiACY=
9
+ /fan3KhjkAUR40oxTxGrk4b03wY=
10
10
  </data>
11
11
  <key>Resources/Assets.car</key>
12
12
  <data>
13
- g6ppKlYeW830G3DwzRm9o0xINW8=
13
+ YZSYPnzkfPLVemp3jiRDnR5WhhQ=
14
14
  </data>
15
15
  </dict>
16
16
  <key>files2</key>
@@ -19,14 +19,14 @@
19
19
  <dict>
20
20
  <key>hash2</key>
21
21
  <data>
22
- YXdi2fa2PvtRaeXueLC7n1If41ofraB5MVZULmdCcC8=
22
+ zDoKzf7n2cp6cQedgfmNiTPwTj+gON7DS7H8ihtNVCE=
23
23
  </data>
24
24
  </dict>
25
25
  <key>Resources/Assets.car</key>
26
26
  <dict>
27
27
  <key>hash2</key>
28
28
  <data>
29
- lo9t3TSww0mSpC4nN+YoeeUgaXGcFCrv53qBXtKpzEY=
29
+ zV9+zCqfvUhHrz90MuHUjqBvx93oA7g6BxgO8hDUlN8=
30
30
  </data>
31
31
  </dict>
32
32
  </dict>
@@ -76,6 +76,42 @@ export function validateBrowserOpen(url, options) {
76
76
  exitCode: 2,
77
77
  });
78
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
+ }
79
115
  export function createBrowserPane(run, executable, url, options, environment, cwd, namespace) {
80
116
  validateBrowserOpen(url, options);
81
117
  const inheritedPane = /^%\d+$/u.test(environment.TMUX_PANE ?? "")
@@ -163,9 +199,16 @@ export function createBrowserPane(run, executable, url, options, environment, cw
163
199
  });
164
200
  return { pane: pane, session: createdSession, window: createdWindow, status: "created" };
165
201
  }
166
- export function openBrowser(url, options = {}) {
202
+ export async function openBrowser(url, options = {}, dependencies = {}) {
167
203
  validateBrowserOpen(url, options);
168
- 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) {
169
212
  try {
170
213
  const namespace = { ...runtimeNamespaceEnvironment(resolveRuntimeNamespace()) };
171
214
  const authority = resolveWorkspacePaneTmuxAuthority();
@@ -173,7 +216,8 @@ export function openBrowser(url, options = {}) {
173
216
  const run = createPinnedWorkspaceTmuxRunner(authority, {
174
217
  timeoutMs: 10_000,
175
218
  });
176
- 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 };
177
221
  }
178
222
  catch (error) {
179
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
+ }