tmux-ide 2.9.15 → 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 (26) hide show
  1. package/README.md +1 -1
  2. package/bin/cli.js +460 -142
  3. package/package.json +2 -1
  4. package/packages/contracts/src/tmux-server-scope.ts +54 -0
  5. package/packages/core/src/terminal-replica.ts +32 -10
  6. package/packages/daemon/dist/command-center/daemon-fleet-facts-observer.js +8 -1
  7. package/packages/daemon/dist/command-center/tmux-servers.js +46 -10
  8. package/packages/daemon/dist/lib/session-favicon.js +152 -0
  9. package/packages/daemon/dist/lib/skill-sync.js +3 -3
  10. package/packages/daemon/dist/lib/tmux-server-owner.js +16 -3
  11. package/packages/daemon/dist/native/TmuxIdeNotifier.app/Contents/Info.plist +2 -2
  12. package/packages/daemon/dist/native/TmuxIdeNotifier.app/Contents/MacOS/tmux-ide-notifier +0 -0
  13. package/packages/daemon/dist/native/TmuxIdeNotifier.app/Contents/Resources/Assets.car +0 -0
  14. package/packages/daemon/dist/native/TmuxIdeNotifier.app/Contents/_CodeSignature/CodeResources +2 -2
  15. package/packages/daemon/dist/open-browser.js +47 -3
  16. package/packages/daemon/dist/terminal/pane-stream/pane-stream-websocket.js +12 -0
  17. package/packages/daemon/dist/terminal/session-runtime/terminal-delivery-hub.js +16 -0
  18. package/packages/daemon/src/command-center/daemon-fleet-facts-observer.ts +9 -1
  19. package/packages/daemon/src/command-center/tmux-servers.ts +55 -11
  20. package/packages/daemon/src/lib/session-favicon.ts +185 -0
  21. package/packages/daemon/src/lib/skill-sync.ts +3 -3
  22. package/packages/daemon/src/lib/tmux-server-owner.ts +35 -6
  23. package/packages/daemon/src/open-browser.ts +88 -3
  24. package/packages/daemon/src/terminal/pane-stream/pane-stream-websocket.ts +12 -0
  25. package/packages/daemon/src/terminal/session-runtime/terminal-delivery-hub.ts +25 -0
  26. package/skill/SKILL.md +6 -1
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "tmux-ide",
3
- "version": "2.9.15",
3
+ "version": "2.9.16",
4
4
  "description": "The open-source workspace for coding agents: run Claude Code, Codex and other agents in tmux with live status",
5
5
  "type": "module",
6
6
  "bin": {
@@ -70,6 +70,7 @@
70
70
  "postinstall": "node scripts/postinstall.js",
71
71
  "docs": "turbo run dev --filter=@tmux-ide/docs",
72
72
  "demo:tui": "bun --preload @opentui/solid/preload docs/scripts/render-tui-demo.tsx",
73
+ "demo:themes": "bun --preload @opentui/solid/preload docs/scripts/render-theme-gallery.tsx && node docs/scripts/rasterize-theme-gallery.mjs",
73
74
  "demo:font": "node docs/scripts/tui-demo-font/build.mjs",
74
75
  "readme:media": "node docs/scripts/readme-media.mjs",
75
76
  "test:tui-renderer": "bun test --preload @opentui/solid/preload --preload ./packages/daemon/test-support/opentui-renderer-preload.ts ./packages/daemon/src/tui/mirror/automatic-contrast-renderer.test.tsx ./packages/daemon/src/tui/mirror/pane-surface-renderer.test.tsx ./packages/daemon/src/tui/mirror/native-grid-projection-renderer.test.tsx ./packages/daemon/src/tui/mirror/widget-surface-renderer.test.tsx ./packages/daemon/src/tui/mirror/missions-surface-renderer.test.tsx ./packages/daemon/src/tui/mirror/recipes-gallery-renderer.test.tsx ./packages/daemon/src/tui/mirror/shell-chrome-renderer.test.tsx ./packages/daemon/src/tui/mirror/sidebar-renderer.test.tsx ./packages/daemon/src/tui/mirror/home-files-surface-renderer.test.tsx ./packages/daemon/src/tui/mirror/changes-terminal-surface-renderer.test.tsx ./packages/daemon/src/tui/mirror/activity-surface-renderer.test.tsx ./packages/daemon/src/tui/mirror/features/files/session-renderer.test.tsx ./packages/daemon/src/tui/mirror/runtime/application-terminal-workspace-renderer.test.tsx ./packages/daemon/src/tui/mirror/testing/styled-frame-oracle.test.tsx ./packages/daemon/src/tui/mirror/runtime/terminal-transient-frame-renderer.test.tsx ./packages/daemon/src/tui/mirror/runtime/terminal-draw-failure-renderer.test.tsx ./packages/daemon/src/tui/mirror/runtime/application-shell-view-renderer.test.tsx ./packages/daemon/src/tui/mirror/runtime/application-root-error-renderer.test.tsx ./packages/daemon/src/tui/mirror/runtime/application-backpressure-renderer.test.tsx ./packages/daemon/src/tui/mirror/runtime/application-demand-cadence-renderer.test.tsx ./packages/daemon/src/tui/mirror/runtime/files-optional-feature-renderer.test.tsx ./packages/daemon/src/tui/mirror/runtime/changes-optional-feature-renderer.test.tsx ./packages/daemon/src/tui/mirror/runtime/missions-activity-optional-feature-renderer.test.tsx ./packages/daemon/src/tui/mirror/runtime/dialogs-optional-feature-renderer.test.tsx ./packages/daemon/src/tui/mirror/runtime/optional-feature-registry-renderer.test.tsx ./packages/daemon/src/tui/mirror/runtime/palette-optional-feature-renderer.test.tsx ./packages/daemon/src/tui/mirror/runtime/pane-scoped-terminal-surface-renderer.test.tsx ./packages/daemon/src/tui/mirror/features/rich-preview/feature.test.ts ./packages/daemon/src/tui/mirror/runtime/rich-preview-optional-feature-renderer.test.tsx ./packages/daemon/src/tui/mirror/workspace/application-shell-renderer.test.tsx ./packages/daemon/src/tui/mirror/workspace/pane-frame-renderer.test.tsx ./packages/daemon/src/tui/mirror/workspace/terminal-pane-chrome-view.test.tsx ./packages/daemon/src/tui/mirror/workspace/terminal-window-strip-renderer.test.tsx ./packages/daemon/src/tui/mirror/workspace/workbench-shell-renderer.test.tsx ./packages/daemon/src/tui/mirror/workspace/workbench-dock-dual-host-renderer.test.tsx ./packages/daemon/src/tui/mirror/workspace/agent-terminal-canvas-renderer.test.tsx ./packages/daemon/src/tui/mirror/workspace/command-palette-surface-renderer.test.tsx ./packages/daemon/src/tui/mirror/workspace/opentui-insertion-stability-renderer.test.tsx ./packages/daemon/src/tui/mirror/runtime/application-shell-home-renderer.test.tsx ./packages/daemon/src/tui/mirror/workspace/terminal-pane-header-polish-renderer.test.tsx ./packages/daemon/src/tui/mirror/runtime/application-home-agent-roster-renderer.test.tsx ./packages/daemon/src/tui/mirror/runtime/application-home-agent-flow-renderer.test.tsx ./packages/daemon/src/tui/mirror/runtime/guided-tour-renderer.test.tsx ./packages/daemon/src/tui/mirror/runtime/application-shell-sidebar-catalog-renderer.test.tsx ./packages/daemon/src/tui/mirror/ui/ui-renderer.test.tsx ./packages/daemon/src/tui/mirror/ui/pane-interaction-renderer.test.tsx ./packages/daemon/src/tui/mirror/runtime/application-machine-sidebar-renderer.test.tsx ./packages/daemon/src/tui/mirror/runtime/application-add-machine-dialog-renderer.test.tsx ./packages/daemon/src/tui/mirror/runtime/application-new-agent-dialog-renderer.test.tsx ./packages/daemon/src/tui/mirror/runtime/application-fleet-switcher-renderer.test.tsx ./packages/daemon/src/tui/mirror/runtime/application-palette-preview-renderer.test.tsx ./packages/daemon/src/tui/mirror/runtime/application-fleet-palette-renderer.test.tsx ./packages/daemon/src/tui/mirror/runtime/application-fleet-session-actions-renderer.test.tsx ./packages/daemon/src/tui/mirror/ui/agent-team-renderer.test.tsx ./scripts/solid-conditional-props.test.tsx",
@@ -151,6 +151,60 @@ export const TmuxServerSessionsResourceSchemaZ = z
151
151
  })
152
152
  .strict();
153
153
 
154
+ /** Raw (decoded) byte limit for one session favicon on the sessions resource. */
155
+ export const TMUX_SERVER_SESSION_FAVICON_MAX_BYTES = 32_768;
156
+ export const TmuxServerSessionFaviconMimeSchemaZ = z.enum([
157
+ "image/png",
158
+ "image/svg+xml",
159
+ "image/x-icon",
160
+ "image/jpeg",
161
+ "image/webp",
162
+ "image/gif",
163
+ ]);
164
+ export type TmuxServerSessionFaviconMime = z.infer<typeof TmuxServerSessionFaviconMimeSchemaZ>;
165
+ function base64DecodedLength(data: string): number {
166
+ const padding = data.endsWith("==") ? 2 : data.endsWith("=") ? 1 : 0;
167
+ return (data.length / 4) * 3 - padding;
168
+ }
169
+ export const TmuxServerSessionFaviconSchemaZ = z
170
+ .object({
171
+ mime: TmuxServerSessionFaviconMimeSchemaZ,
172
+ data: z
173
+ .string()
174
+ .regex(/^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/u)
175
+ .refine(
176
+ (data) => base64DecodedLength(data) <= TMUX_SERVER_SESSION_FAVICON_MAX_BYTES,
177
+ "favicon exceeds 32 KiB",
178
+ ),
179
+ })
180
+ .strict();
181
+ export type TmuxServerSessionFavicon = z.infer<typeof TmuxServerSessionFaviconSchemaZ>;
182
+
183
+ /**
184
+ * Version 2 of the sessions resource, served only for `?version=2`. Version 1
185
+ * stays byte-for-byte for clients that parse it with unknown fields denied.
186
+ */
187
+ export const TmuxServerSessionsResourceV2SchemaZ = z
188
+ .object({
189
+ version: z.literal(2),
190
+ server: TmuxServerScopeSchemaZ,
191
+ sessions: z.array(
192
+ z
193
+ .object({
194
+ liveSessionId: WorkspaceCatalogLiveSessionIdSchemaZ,
195
+ sessionName: z.string().min(1).max(160),
196
+ workspaceName: z.string().min(1).max(160).nullable(),
197
+ paneCount: z.number().int().nonnegative(),
198
+ windowCount: z.number().int().min(1),
199
+ lastActivity: z.number().int().nonnegative().nullable(),
200
+ favicon: TmuxServerSessionFaviconSchemaZ.nullable(),
201
+ })
202
+ .strict(),
203
+ ),
204
+ })
205
+ .strict();
206
+ export type TmuxServerSessionsResourceV2 = z.infer<typeof TmuxServerSessionsResourceV2SchemaZ>;
207
+
154
208
  /** Selector intent is interpreted only by the daemon on its own host. */
155
209
  export const TmuxServerRegistrationRequestSchemaZ = z
156
210
  .object({
@@ -581,15 +581,19 @@ export async function applyTerminalReplicaUpdateCooperatively(
581
581
  }
582
582
  const authenticated = options.authenticatedFrameHash;
583
583
  const frameStart = readProfileClock(options.instrumentation);
584
- const frameHash =
585
- authenticated && /^[0-9a-f]{16}$/u.test(authenticated)
586
- ? authenticated
587
- : await hashCanonicalTerminalValueCooperatively(
588
- { ...admitted, snapshot },
589
- yieldControl,
590
- // Bound frame hashing without scheduling a task per few cells.
591
- 32 * 1024,
592
- );
584
+ const reuseAuthenticated = Boolean(authenticated && /^[0-9a-f]{16}$/u.test(authenticated));
585
+ if (!reuseAuthenticated) {
586
+ await primeTerminalReplicaRowsHashCooperatively(snapshot.grid, yieldControl);
587
+ await primeTerminalReplicaRowsHashCooperatively(snapshot.history, yieldControl);
588
+ }
589
+ const frameHash = reuseAuthenticated
590
+ ? authenticated!
591
+ : await hashCanonicalTerminalValueCooperatively(
592
+ terminalReplicaFrameIdentity({ ...admitted, snapshot }),
593
+ yieldControl,
594
+ // Bound frame hashing without scheduling a task per few cells.
595
+ 32 * 1024,
596
+ );
593
597
  if (profile)
594
598
  profile.reusedAuthenticatedFrameHash = Boolean(
595
599
  authenticated && /^[0-9a-f]{16}$/u.test(authenticated),
@@ -984,11 +988,29 @@ function resolveFrameHash(
984
988
  return authenticated;
985
989
  }
986
990
  const started = readProfileClock(options.instrumentation);
987
- const hash = hashStable(update);
991
+ const hash = hashStable(terminalReplicaFrameIdentity(update));
988
992
  addProfileDuration(profile, "updateHash", started, options.instrumentation);
989
993
  return hash;
990
994
  }
991
995
 
996
+ /**
997
+ * What a frame hash covers. A seed's rows enter as their row-sequence hashes,
998
+ * which the replica caches per frozen row, so a seed carrying a pane's whole
999
+ * history is not walked cell by cell a second time (#706). Every other field
1000
+ * enters as is. The cooperative and ordinary paths must agree: a redelivered
1001
+ * seed whose frame hash differs is a protocol conflict, not idempotent.
1002
+ */
1003
+ function terminalReplicaFrameIdentity(update: CanonicalTerminalReplicaUpdate): unknown {
1004
+ if (update.type !== "terminal.seed") return update;
1005
+ const { grid, history, ...rest } = update.snapshot;
1006
+ return [
1007
+ "terminal-replica-frame-v2",
1008
+ { ...update, snapshot: rest },
1009
+ hashTerminalReplicaRows(grid),
1010
+ hashTerminalReplicaRows(history),
1011
+ ];
1012
+ }
1013
+
992
1014
  function readProfileClock(
993
1015
  instrumentation: TerminalReplicaApplyOptions["instrumentation"] | undefined,
994
1016
  ): number | undefined {
@@ -30,7 +30,7 @@ export class DaemonFleetFactsObserver {
30
30
  #diagnosticActiveOperations = 0;
31
31
  constructor(options) {
32
32
  this.#options = options;
33
- this.#intervalMs = options.intervalMs ?? 2_000;
33
+ this.#intervalMs = options.intervalMs ?? FLEET_FACTS_POLL_MS;
34
34
  this.#setTimer =
35
35
  options.setTimer ??
36
36
  ((callback, delayMs) => {
@@ -461,6 +461,13 @@ export function createDefaultFleetFactsReaders() {
461
461
  };
462
462
  return createSharedFleetFactsReaders(execute);
463
463
  }
464
+ /**
465
+ * How often subscribed clients' tmux facts (sessions, panes, agent stamps) are
466
+ * read. Hook-stamped agent changes reach clients within one tick, so 500 ms
467
+ * keeps a state flip visible within 1 s (#716). One `list-panes -a` per tick,
468
+ * only while a client is subscribed.
469
+ */
470
+ export const FLEET_FACTS_POLL_MS = 500;
464
471
  export const SESSION_COMPOSITION_TMUX_ARGS = [
465
472
  "list-panes",
466
473
  "-a",
@@ -10,13 +10,16 @@ import { streamSSE } from "hono/streaming";
10
10
  import { projectApplicationShellResource } from "./resources/application-shell.js";
11
11
  import { fleetSessionIdForName } from "./resources/fleet-catalog.js";
12
12
  import { z } from "zod";
13
- import { TmuxAgentOpenRequestSchemaZ, TmuxAgentOpenResponseSchemaZ, WorkspaceCatalogLiveSessionIdSchemaZ, WindowLinkTargetSchemaZ, WindowSplitLayoutResourceSchemaZ, TMUX_SERVERS_API_PATH, PANE_STREAM_ISSUE_PATH, TmuxServerIdSchemaZ, TmuxServerScopeSchemaZ, TmuxServersResourceSchemaZ, TmuxServerSessionsResourceSchemaZ, WorkspaceMultiplexerMutationRequestSchemaZ, WorkspacePaneCreateMutationRequestSchemaZ, WorkspaceSessionCreateArgumentsSchemaZ, } from "@tmux-ide/contracts";
13
+ import { TmuxAgentOpenRequestSchemaZ, TmuxAgentOpenResponseSchemaZ, WorkspaceCatalogLiveSessionIdSchemaZ, WindowLinkTargetSchemaZ, WindowSplitLayoutResourceSchemaZ, TMUX_SERVERS_API_PATH, PANE_STREAM_ISSUE_PATH, TmuxServerIdSchemaZ, TmuxServerScopeSchemaZ, TmuxServersResourceSchemaZ, TmuxServerSessionsResourceSchemaZ, TmuxServerSessionsResourceV2SchemaZ, WorkspaceMultiplexerMutationRequestSchemaZ, WorkspacePaneCreateMutationRequestSchemaZ, WorkspaceSessionCreateArgumentsSchemaZ, } from "@tmux-ide/contracts";
14
14
  import { TmuxServerRegistrationSchemaZ, TmuxServerScopeError, } from "../lib/tmux-server-owners.js";
15
+ import { createSessionFaviconResolver } from "../lib/session-favicon.js";
15
16
  import { WorkspaceMultiplexerError } from "../lib/workspace-multiplexer-verbs.js";
16
17
  import { WorkspacePaneCreationError } from "../lib/workspace-pane-creation.js";
17
18
  import { ownerBearerMatches } from "./owner-authority.js";
18
19
  import { mountPaneStreamIssueRoute } from "./pane-stream-issue.js";
19
20
  import { projectTerminalRuntimeInventory } from "./resources/terminal-runtime-inventory.js";
21
+ /** Total raw favicon bytes one version 2 sessions response may carry. */
22
+ export const SESSIONS_RESOURCE_FAVICON_BUDGET_BYTES = 512 * 1024;
20
23
  const registrationSchema = TmuxServerRegistrationSchemaZ.omit({ serverId: true }).strict();
21
24
  const workspaceSchema = z
22
25
  .string()
@@ -69,6 +72,7 @@ function failure(c, error) {
69
72
  /** Owner capability is mandatory even when the ordinary local HTTP surface is open. */
70
73
  export function mountTmuxServerRoutes(app, options) {
71
74
  const base = TMUX_SERVERS_API_PATH;
75
+ const resolveSessionFavicon = options.resolveSessionFavicon ?? createSessionFaviconResolver();
72
76
  const authorize = async (c, next) => {
73
77
  c.header("Cache-Control", "no-store");
74
78
  if (!ownerBearerMatches(c.req.header("Authorization"), options.ownerToken))
@@ -185,16 +189,48 @@ export function mountTmuxServerRoutes(app, options) {
185
189
  }));
186
190
  app.get(`${scoped}/sessions`, route(async (c) => {
187
191
  const server = scope(c);
188
- return c.json(TmuxServerSessionsResourceSchemaZ.parse({
189
- version: 1,
192
+ const v2 = c.req.query("version") === "2";
193
+ const { rows, favicons } = await options.owners.withOwner(server, async (owner) => {
194
+ const sessions = await owner.catalog();
195
+ const workspaces = owner.workspaceRegistry.list();
196
+ const rows = sessions.map((session) => {
197
+ const aliases = workspaces.filter((workspace) => workspace.sessionName === session.sessionName);
198
+ return { session, workspaceName: aliases.length === 1 ? aliases[0].name : null };
199
+ });
200
+ const favicons = v2
201
+ ? await Promise.all(sessions.map((session) => resolveSessionFavicon(session.sessionPath).catch(() => null)))
202
+ : [];
203
+ return { rows, favicons };
204
+ });
205
+ if (!v2)
206
+ return c.json(TmuxServerSessionsResourceSchemaZ.parse({
207
+ version: 1,
208
+ server,
209
+ sessions: rows.map(({ session, workspaceName }) => ({
210
+ liveSessionId: session.liveSessionId,
211
+ sessionName: session.sessionName,
212
+ paneCount: session.paneCount,
213
+ workspaceName,
214
+ })),
215
+ }));
216
+ let budget = SESSIONS_RESOURCE_FAVICON_BUDGET_BYTES;
217
+ return c.json(TmuxServerSessionsResourceV2SchemaZ.parse({
218
+ version: 2,
190
219
  server,
191
- sessions: await options.owners.withOwner(server, async (owner) => {
192
- const sessions = await owner.catalog();
193
- const workspaces = owner.workspaceRegistry.list();
194
- return sessions.map((session) => {
195
- const aliases = workspaces.filter((workspace) => workspace.sessionName === session.sessionName);
196
- return { ...session, workspaceName: aliases.length === 1 ? aliases[0].name : null };
197
- });
220
+ sessions: rows.map(({ session, workspaceName }, index) => {
221
+ const found = favicons[index] ?? null;
222
+ const favicon = found && found.size <= budget ? found : null;
223
+ if (favicon)
224
+ budget -= favicon.size;
225
+ return {
226
+ liveSessionId: session.liveSessionId,
227
+ sessionName: session.sessionName,
228
+ workspaceName,
229
+ paneCount: session.paneCount,
230
+ windowCount: session.windowCount,
231
+ lastActivity: session.lastActivity,
232
+ favicon: favicon ? { mime: favicon.mime, data: favicon.data } : null,
233
+ };
198
234
  }),
199
235
  }));
200
236
  }));
@@ -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. */
@@ -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.16</string>
25
25
  <key>CFBundleVersion</key>
26
- <string>2.9.15</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
- g6ppKlYeW830G3DwzRm9o0xINW8=
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
- lo9t3TSww0mSpC4nN+YoeeUgaXGcFCrv53qBXtKpzEY=
29
+ PKQV15gCKuH8bIwdxGZgHEhWb0Rwq62t/R9Ozfnv/BA=
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();