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
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "tmux-ide",
3
- "version": "2.9.14",
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",
@@ -116,6 +116,7 @@ export * from "./visual-terminal-palette.ts";
116
116
 
117
117
  export * from "./window-split-layout.ts";
118
118
  export * from "./pane-editor-context.ts";
119
+ export * from "./native-browser.ts";
119
120
 
120
121
  export * from "./tmux-agent-open.ts";
121
122
 
@@ -0,0 +1,43 @@
1
+ import { z } from "zod";
2
+
3
+ const text = (limit: number) =>
4
+ z
5
+ .string()
6
+ .max(limit)
7
+ // Intentionally reject control characters in display text and navigation input.
8
+ // eslint-disable-next-line no-control-regex
9
+ .refine((value) => !/[\x00-\x1f\x7f]/u.test(value));
10
+ const identity = {
11
+ version: z.literal(1),
12
+ instance: z.uuid(),
13
+ pane: z
14
+ .string()
15
+ .regex(/^%[0-9]+$/u)
16
+ .max(32),
17
+ tab: z.number().int().positive().max(Number.MAX_SAFE_INTEGER),
18
+ revision: z.number().int().positive().max(Number.MAX_SAFE_INTEGER),
19
+ };
20
+ /** Display state is not authority: every command echoes the exact browser revision. */
21
+ export const NativeBrowserStateSchemaZ = z
22
+ .object({
23
+ ...identity,
24
+ enabled: z.boolean(),
25
+ url: text(16_384),
26
+ title: text(512),
27
+ loading: z.boolean(),
28
+ canGoBack: z.boolean(),
29
+ canGoForward: z.boolean(),
30
+ })
31
+ .strict();
32
+ export const NativeBrowserCommandSchemaZ = z.discriminatedUnion("action", [
33
+ z.object({ ...identity, action: z.enum(["back", "forward", "reload", "stop"]) }).strict(),
34
+ z
35
+ .object({
36
+ ...identity,
37
+ action: z.literal("navigate"),
38
+ text: text(16_384).refine((value) => value.trim().length > 0),
39
+ })
40
+ .strict(),
41
+ ]);
42
+ export type NativeBrowserState = z.infer<typeof NativeBrowserStateSchemaZ>;
43
+ export type NativeBrowserCommand = z.infer<typeof NativeBrowserCommandSchemaZ>;
@@ -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))
@@ -107,6 +111,12 @@ export function mountTmuxServerRoutes(app, options) {
107
111
  owners: options.owners,
108
112
  readBody: (request) => boundedJson(request, 8 * 1024),
109
113
  });
114
+ app.post(`${scoped}/native-browser`, route(async (c) => {
115
+ const server = scope(c);
116
+ const input = await boundedJson(c.req.raw, 24 * 1024);
117
+ const state = await options.owners.withOwner(server, (owner) => owner.nativeBrowser(input, c.req.raw.signal));
118
+ return c.json({ version: 1, state });
119
+ }));
110
120
  // Structured read only: this never acquires a channel or changes tmux geometry.
111
121
  app.post(`${scoped}/split-layout/:workspaceName`, route(async (c) => {
112
122
  const server = scope(c);
@@ -179,16 +189,48 @@ export function mountTmuxServerRoutes(app, options) {
179
189
  }));
180
190
  app.get(`${scoped}/sessions`, route(async (c) => {
181
191
  const server = scope(c);
182
- return c.json(TmuxServerSessionsResourceSchemaZ.parse({
183
- 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,
184
219
  server,
185
- sessions: await options.owners.withOwner(server, async (owner) => {
186
- const sessions = await owner.catalog();
187
- const workspaces = owner.workspaceRegistry.list();
188
- return sessions.map((session) => {
189
- const aliases = workspaces.filter((workspace) => workspace.sessionName === session.sessionName);
190
- return { ...session, workspaceName: aliases.length === 1 ? aliases[0].name : null };
191
- });
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
+ };
192
234
  }),
193
235
  }));
194
236
  }));
@@ -6,6 +6,7 @@ var __rewriteRelativeImportExtension = (this && this.__rewriteRelativeImportExte
6
6
  }
7
7
  return path;
8
8
  };
9
+ import { createNativeBrowserResolver } from "./tmux-native-browser.js";
9
10
  import { createNativeSurfaceContextResolver } from "./native-surface-context.js";
10
11
  import { NativeSurfaceProviderRegistry } from "../terminal/graphics/native-surface-provider.js";
11
12
  import { TmuxServerVersionSchemaZ } from "@tmux-ide/contracts";
@@ -1397,6 +1398,20 @@ async function startEmbeddedDaemonGeneration(opts) {
1397
1398
  throw new Error("Default tmux editor context owner is unavailable");
1398
1399
  },
1399
1400
  }),
1401
+ nativeBrowser: createNativeBrowserResolver({
1402
+ generation: instanceId,
1403
+ run: fleetFactsTmuxRunner,
1404
+ registry: workspaceRegistry,
1405
+ catalog: createNativeTmuxServerCatalog(workspaceRegistry, fleetFactsTmuxRunner),
1406
+ inventory: terminalInventoryRuntime,
1407
+ assertOpen: () => {
1408
+ if (defaultEditorContextRetired ||
1409
+ serverOwners?.defaultRetired ||
1410
+ !initialNativeServerIdentity ||
1411
+ terminalInventoryRuntime?.lifecycleState() !== "ready")
1412
+ throw new Error("Default native browser owner is unavailable");
1413
+ },
1414
+ }),
1400
1415
  catalog: createNativeTmuxServerCatalog(workspaceRegistry, fleetFactsTmuxRunner),
1401
1416
  readWindowSplitLayout: async (workspaceName, input) => {
1402
1417
  const target = WindowLinkTargetSchemaZ.parse(input);
@@ -0,0 +1,110 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import { lstat } from "node:fs/promises";
3
+ import { isAbsolute } from "node:path";
4
+ import { connect } from "node:net";
5
+ import { NativeBrowserCommandSchemaZ, NativeBrowserStateSchemaZ, } from "@tmux-ide/contracts";
6
+ import { z } from "zod";
7
+ export const NativeBrowserEndpointSchema = z
8
+ .object({
9
+ version: z.literal(1),
10
+ pid: z.number().int().positive(),
11
+ instance: z.uuid(),
12
+ pane: z
13
+ .string()
14
+ .regex(/^%[0-9]+$/u)
15
+ .max(32),
16
+ socket: z.string().min(1).max(1024).refine(isAbsolute),
17
+ })
18
+ .strict();
19
+ export class NativeBrowserUnavailable extends Error {
20
+ constructor() {
21
+ super("Native browser controls are unavailable");
22
+ }
23
+ }
24
+ const responseSchema = z.discriminatedUnion("ok", [
25
+ z
26
+ .object({ id: z.string(), ok: z.literal(true), data: NativeBrowserStateSchemaZ.nullable() })
27
+ .strict(),
28
+ z.object({ id: z.string(), ok: z.literal(false), error: z.string().max(4096) }).strict(),
29
+ ]);
30
+ /** One request, one bounded response. Mutations are never retried or refreshed. */
31
+ export async function nativeBrowserRpc(endpoint, command, signal) {
32
+ const target = NativeBrowserEndpointSchema.parse(endpoint);
33
+ const payload = command ? NativeBrowserCommandSchemaZ.parse(command) : null;
34
+ if (payload && (payload.instance !== target.instance || payload.pane !== target.pane))
35
+ throw new NativeBrowserUnavailable();
36
+ signal.throwIfAborted();
37
+ const info = await lstat(target.socket);
38
+ if (!info.isSocket() || info.uid !== process.getuid?.() || (info.mode & 0o022) !== 0)
39
+ throw new NativeBrowserUnavailable();
40
+ try {
41
+ process.kill(target.pid, 0);
42
+ }
43
+ catch {
44
+ throw new NativeBrowserUnavailable();
45
+ }
46
+ signal.throwIfAborted();
47
+ const id = randomUUID();
48
+ const request = JSON.stringify({
49
+ id,
50
+ cmd: payload ? "native/1/command" : "native/1/state",
51
+ ...(payload ? { payload } : {}),
52
+ }) + "\n";
53
+ return new Promise((resolve, reject) => {
54
+ const socket = connect(target.socket);
55
+ let finished = false;
56
+ let size = 0;
57
+ const chunks = [];
58
+ const finish = (error, state = null) => {
59
+ if (finished)
60
+ return;
61
+ finished = true;
62
+ clearTimeout(timer);
63
+ signal.removeEventListener("abort", abort);
64
+ socket.destroy();
65
+ if (error)
66
+ reject(error);
67
+ else
68
+ resolve(state);
69
+ };
70
+ const abort = () => finish(new NativeBrowserUnavailable());
71
+ const timer = setTimeout(abort, 1500);
72
+ signal.addEventListener("abort", abort, { once: true });
73
+ if (signal.aborted) {
74
+ abort();
75
+ return;
76
+ }
77
+ socket.once("connect", () => socket.write(request));
78
+ socket.on("error", abort);
79
+ socket.once("end", () => {
80
+ if (!finished)
81
+ abort();
82
+ });
83
+ socket.on("data", (chunk) => {
84
+ size += chunk.length;
85
+ if (size > 128 * 1024) {
86
+ abort();
87
+ return;
88
+ }
89
+ chunks.push(chunk);
90
+ if (!chunk.includes(10))
91
+ return;
92
+ try {
93
+ const bytes = Buffer.concat(chunks);
94
+ const newline = bytes.indexOf(10);
95
+ if (newline !== bytes.length - 1)
96
+ throw new NativeBrowserUnavailable();
97
+ const reply = responseSchema.parse(JSON.parse(new TextDecoder("utf-8", { fatal: true }).decode(bytes.subarray(0, newline))));
98
+ if (reply.id !== id || !reply.ok)
99
+ throw new NativeBrowserUnavailable();
100
+ if (reply.data &&
101
+ (reply.data.instance !== target.instance || reply.data.pane !== target.pane))
102
+ throw new NativeBrowserUnavailable();
103
+ finish(undefined, reply.data);
104
+ }
105
+ catch {
106
+ abort();
107
+ }
108
+ });
109
+ });
110
+ }
@@ -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
  */