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
@@ -90,7 +90,7 @@ export class DaemonFleetFactsObserver {
90
90
 
91
91
  constructor(options: DaemonFleetFactsObserverOptions) {
92
92
  this.#options = options;
93
- this.#intervalMs = options.intervalMs ?? 2_000;
93
+ this.#intervalMs = options.intervalMs ?? FLEET_FACTS_POLL_MS;
94
94
  this.#setTimer =
95
95
  options.setTimer ??
96
96
  ((callback, delayMs) => {
@@ -549,6 +549,14 @@ export function createDefaultFleetFactsReaders(): Pick<
549
549
  return createSharedFleetFactsReaders(execute);
550
550
  }
551
551
 
552
+ /**
553
+ * How often subscribed clients' tmux facts (sessions, panes, agent stamps) are
554
+ * read. Hook-stamped agent changes reach clients within one tick, so 500 ms
555
+ * keeps a state flip visible within 1 s (#716). One `list-panes -a` per tick,
556
+ * only while a client is subscribed.
557
+ */
558
+ export const FLEET_FACTS_POLL_MS = 500;
559
+
552
560
  export const SESSION_COMPOSITION_TMUX_ARGS = [
553
561
  "list-panes",
554
562
  "-a",
@@ -208,10 +208,16 @@ export function isVisibleFleetSession(name: string): boolean {
208
208
  export function readAdoptedSessionNames(runTmux: TmuxRunner = _tmuxRunner): string[] | null {
209
209
  let raw: string;
210
210
  try {
211
- raw = runTmux(["list-sessions", "-F", `#{session_name}\t#{${ADOPTED_OPTION}}`]);
211
+ raw = runTmux(ADOPTED_SESSIONS_ARGS);
212
212
  } catch {
213
213
  return null;
214
214
  }
215
+ return parseAdoptedSessionNames(raw);
216
+ }
217
+
218
+ const ADOPTED_SESSIONS_ARGS = ["list-sessions", "-F", `#{session_name}\t#{${ADOPTED_OPTION}}`];
219
+
220
+ function parseAdoptedSessionNames(raw: string): string[] {
215
221
  const names: string[] = [];
216
222
  for (const line of raw.split("\n")) {
217
223
  if (!line) continue;
@@ -312,18 +318,77 @@ export function readAdoptedFleet(
312
318
  const nativeEpoch = nativeServerEpoch?.() ?? null;
313
319
  const adopted = readAdoptedSessionNames(runTmux);
314
320
  if (adopted === null) return null;
315
- const adoptedSet = new Set(adopted);
316
- if (adoptedSet.size === 0) return [];
317
-
318
- const appCreatedSessions = new Set(registry.list().map((workspace) => workspace.sessionName));
321
+ if (adopted.length === 0) return [];
322
+ let panesRaw: string;
323
+ try {
324
+ panesRaw = runTmux(FLEET_PANES_ARGS);
325
+ } catch {
326
+ panesRaw = "";
327
+ }
328
+ return assembleAdoptedFleet(
329
+ adopted,
330
+ panesRaw,
331
+ registry,
332
+ nativeEpoch,
333
+ resolveInteractionEndpoint,
334
+ nativeServerEpoch,
335
+ );
336
+ }
319
337
 
320
- const panesBySession = new Map<string, FleetPaneFacts[]>();
338
+ /**
339
+ * {@link readAdoptedFleet} over an asynchronous tmux runner: the same two reads
340
+ * in the same order, without blocking the event loop while tmux answers (#709).
341
+ */
342
+ export async function readAdoptedFleetAsync(
343
+ registry: { list(): { sessionName: string }[] },
344
+ runTmux: (args: string[]) => Promise<string>,
345
+ resolveInteractionEndpoint?: (
346
+ sessionName: string,
347
+ pane: FleetPaneFacts,
348
+ ) => Extract<InteractionPaneEndpoint, { kind: "pane" }> | null,
349
+ nativeServerEpoch?: () => string | null,
350
+ ): Promise<FleetSessionFacts[] | null> {
351
+ const nativeEpoch = nativeServerEpoch?.() ?? null;
352
+ let sessionsRaw: string;
353
+ try {
354
+ sessionsRaw = await runTmux(ADOPTED_SESSIONS_ARGS);
355
+ } catch {
356
+ return null;
357
+ }
358
+ const adopted = parseAdoptedSessionNames(sessionsRaw);
359
+ if (adopted.length === 0) return [];
321
360
  let panesRaw: string;
322
361
  try {
323
- panesRaw = runTmux(["list-panes", "-a", "-F", FLEET_PANE_FORMAT]);
362
+ panesRaw = await runTmux(FLEET_PANES_ARGS);
324
363
  } catch {
325
364
  panesRaw = "";
326
365
  }
366
+ return assembleAdoptedFleet(
367
+ adopted,
368
+ panesRaw,
369
+ registry,
370
+ nativeEpoch,
371
+ resolveInteractionEndpoint,
372
+ nativeServerEpoch,
373
+ );
374
+ }
375
+
376
+ const FLEET_PANES_ARGS = ["list-panes", "-a", "-F", FLEET_PANE_FORMAT];
377
+
378
+ function assembleAdoptedFleet(
379
+ adopted: string[],
380
+ panesRaw: string,
381
+ registry: { list(): { sessionName: string }[] },
382
+ nativeEpoch: string | null,
383
+ resolveInteractionEndpoint?: (
384
+ sessionName: string,
385
+ pane: FleetPaneFacts,
386
+ ) => Extract<InteractionPaneEndpoint, { kind: "pane" }> | null,
387
+ nativeServerEpoch?: () => string | null,
388
+ ): FleetSessionFacts[] {
389
+ const adoptedSet = new Set(adopted);
390
+ const appCreatedSessions = new Set(registry.list().map((workspace) => workspace.sessionName));
391
+ const panesBySession = new Map<string, FleetPaneFacts[]>();
327
392
  for (const line of panesRaw.split("\n")) {
328
393
  if (!line) continue;
329
394
  const fields = line.split(FLEET_FIELD_SEPARATOR);
@@ -22,6 +22,7 @@ import {
22
22
  TmuxServerScopeSchemaZ,
23
23
  TmuxServersResourceSchemaZ,
24
24
  TmuxServerSessionsResourceSchemaZ,
25
+ TmuxServerSessionsResourceV2SchemaZ,
25
26
  WorkspaceMultiplexerMutationRequestSchemaZ,
26
27
  WorkspacePaneCreateMutationRequestSchemaZ,
27
28
  WorkspaceSessionCreateArgumentsSchemaZ,
@@ -32,6 +33,7 @@ import {
32
33
  TmuxServerScopeError,
33
34
  } from "../lib/tmux-server-owners.ts";
34
35
  import type { NativeTmuxServerOwner } from "../lib/tmux-server-owner.ts";
36
+ import { createSessionFaviconResolver, type SessionFavicon } from "../lib/session-favicon.ts";
35
37
  import { WorkspaceMultiplexerError } from "../lib/workspace-multiplexer-verbs.ts";
36
38
  import { WorkspacePaneCreationError } from "../lib/workspace-pane-creation.ts";
37
39
  import { ownerBearerMatches } from "./owner-authority.ts";
@@ -41,7 +43,11 @@ import { projectTerminalRuntimeInventory } from "./resources/terminal-runtime-in
41
43
  export interface TmuxServerRoutesOptions {
42
44
  readonly ownerToken: string | null;
43
45
  readonly owners: TmuxServerOwners<NativeTmuxServerOwner>;
46
+ /** Project favicon lookup for the version 2 sessions resource. */
47
+ readonly resolveSessionFavicon?: (sessionPath: string) => Promise<SessionFavicon | null>;
44
48
  }
49
+ /** Total raw favicon bytes one version 2 sessions response may carry. */
50
+ export const SESSIONS_RESOURCE_FAVICON_BUDGET_BYTES = 512 * 1024;
45
51
  const registrationSchema = TmuxServerRegistrationSchemaZ.omit({ serverId: true }).strict();
46
52
  const workspaceSchema = z
47
53
  .string()
@@ -94,6 +100,7 @@ function failure(c: Context, error: unknown): Response {
94
100
  /** Owner capability is mandatory even when the ordinary local HTTP surface is open. */
95
101
  export function mountTmuxServerRoutes(app: Hono, options: TmuxServerRoutesOptions): void {
96
102
  const base = TMUX_SERVERS_API_PATH;
103
+ const resolveSessionFavicon = options.resolveSessionFavicon ?? createSessionFaviconResolver();
97
104
  const authorize = async (c: Context, next: () => Promise<void>) => {
98
105
  c.header("Cache-Control", "no-store");
99
106
  if (!ownerBearerMatches(c.req.header("Authorization"), options.ownerToken))
@@ -271,19 +278,56 @@ export function mountTmuxServerRoutes(app: Hono, options: TmuxServerRoutesOption
271
278
  `${scoped}/sessions`,
272
279
  route(async (c) => {
273
280
  const server = scope(c);
281
+ const v2 = c.req.query("version") === "2";
282
+ const { rows, favicons } = await options.owners.withOwner(server, async (owner) => {
283
+ const sessions = await owner.catalog();
284
+ const workspaces = owner.workspaceRegistry.list();
285
+ const rows = sessions.map((session) => {
286
+ const aliases = workspaces.filter(
287
+ (workspace) => workspace.sessionName === session.sessionName,
288
+ );
289
+ return { session, workspaceName: aliases.length === 1 ? aliases[0]!.name : null };
290
+ });
291
+ const favicons = v2
292
+ ? await Promise.all(
293
+ sessions.map((session) =>
294
+ resolveSessionFavicon(session.sessionPath).catch(() => null),
295
+ ),
296
+ )
297
+ : [];
298
+ return { rows, favicons };
299
+ });
300
+ if (!v2)
301
+ return c.json(
302
+ TmuxServerSessionsResourceSchemaZ.parse({
303
+ version: 1,
304
+ server,
305
+ sessions: rows.map(({ session, workspaceName }) => ({
306
+ liveSessionId: session.liveSessionId,
307
+ sessionName: session.sessionName,
308
+ paneCount: session.paneCount,
309
+ workspaceName,
310
+ })),
311
+ }),
312
+ );
313
+ let budget = SESSIONS_RESOURCE_FAVICON_BUDGET_BYTES;
274
314
  return c.json(
275
- TmuxServerSessionsResourceSchemaZ.parse({
276
- version: 1,
315
+ TmuxServerSessionsResourceV2SchemaZ.parse({
316
+ version: 2,
277
317
  server,
278
- sessions: await options.owners.withOwner(server, async (owner) => {
279
- const sessions = await owner.catalog();
280
- const workspaces = owner.workspaceRegistry.list();
281
- return sessions.map((session) => {
282
- const aliases = workspaces.filter(
283
- (workspace) => workspace.sessionName === session.sessionName,
284
- );
285
- return { ...session, workspaceName: aliases.length === 1 ? aliases[0]!.name : null };
286
- });
318
+ sessions: rows.map(({ session, workspaceName }, index) => {
319
+ const found = favicons[index] ?? null;
320
+ const favicon = found && found.size <= budget ? found : null;
321
+ if (favicon) budget -= favicon.size;
322
+ return {
323
+ liveSessionId: session.liveSessionId,
324
+ sessionName: session.sessionName,
325
+ workspaceName,
326
+ paneCount: session.paneCount,
327
+ windowCount: session.windowCount,
328
+ lastActivity: session.lastActivity,
329
+ favicon: favicon ? { mime: favicon.mime, data: favicon.data } : null,
330
+ };
287
331
  }),
288
332
  }),
289
333
  );
@@ -0,0 +1,40 @@
1
+ import type { InteractionPaneEndpoint } from "@tmux-ide/contracts";
2
+ import {
3
+ readAdoptedFleet,
4
+ readAdoptedFleetAsync,
5
+ type FleetPaneFacts,
6
+ type FleetSessionFacts,
7
+ } from "../command-center/discovery.ts";
8
+
9
+ /** The two runners a catalog fleet read may use; both are fenced to one tmux server generation. */
10
+ export interface CatalogFleetRunners {
11
+ /** The session-mutation fence; only its `active` state is read here. */
12
+ readonly fence: { readonly active: boolean };
13
+ /** Synchronous runner wrapped by that fence: inside a mutation it adds the session check. */
14
+ readonly fencedSync: (args: string[]) => string;
15
+ /** Asynchronous runner fenced to the same server generation. */
16
+ readonly generationFencedAsync: (args: string[]) => Promise<string>;
17
+ }
18
+
19
+ /**
20
+ * Read the catalog fleet without blocking the event loop (#709), except inside
21
+ * a session mutation, where the read keeps the fence's synchronous runner.
22
+ */
23
+ export async function readCatalogFleet(
24
+ registry: { list(): { sessionName: string }[] },
25
+ runners: CatalogFleetRunners,
26
+ resolveInteractionEndpoint?: (
27
+ sessionName: string,
28
+ pane: FleetPaneFacts,
29
+ ) => Extract<InteractionPaneEndpoint, { kind: "pane" }> | null,
30
+ nativeServerEpoch?: () => string | null,
31
+ ): Promise<FleetSessionFacts[] | null> {
32
+ return runners.fence.active
33
+ ? readAdoptedFleet(registry, runners.fencedSync, resolveInteractionEndpoint, nativeServerEpoch)
34
+ : readAdoptedFleetAsync(
35
+ registry,
36
+ runners.generationFencedAsync,
37
+ resolveInteractionEndpoint,
38
+ nativeServerEpoch,
39
+ );
40
+ }
@@ -117,7 +117,12 @@ import {
117
117
  resolveWorkspacePaneTmuxAuthority,
118
118
  WorkspacePaneCreationAuthority,
119
119
  } from "./workspace-pane-creation.ts";
120
- import { discoverLiveSessionSummaries, readAdoptedFleet } from "../command-center/discovery.ts";
120
+ import {
121
+ discoverLiveSessionSummaries,
122
+ readAdoptedFleet,
123
+ type FleetSessionFacts,
124
+ } from "../command-center/discovery.ts";
125
+ import { readCatalogFleet } from "./catalog-fleet-read.ts";
121
126
  import { WorkspaceOpenAuthority } from "./workspace-open.ts";
122
127
  import { WorkspacePromotionAuthority } from "./workspace-promotion.ts";
123
128
  import { WorkspaceOpenHandoffCoordinator } from "./workspace-open-handoff.ts";
@@ -2103,18 +2108,28 @@ async function startEmbeddedDaemonGeneration(
2103
2108
  paneStreamRuntime,
2104
2109
  catalogLiveSessions: () => discoverLiveSessionSummaries(catalogTmuxRunner),
2105
2110
  catalogFleet: async () => {
2106
- const fleet = readAdoptedFleet(
2111
+ const endpoint = (sessionName: string, pane: FleetSessionFacts["panes"][number]) =>
2112
+ pane.semanticPaneId
2113
+ ? (interactionEvidence?.captureInventoryEndpoint(
2114
+ sessionName,
2115
+ pane.runtimePaneId,
2116
+ pane.semanticPaneId,
2117
+ ) ?? null)
2118
+ : null;
2119
+ const epoch = () => observationSelector?.nativeServerEpoch ?? null;
2120
+ // Same pinned server generation either way. Outside a session
2121
+ // mutation the read must not block the event loop: every concurrent
2122
+ // catalog request queued behind its spawnSync (#709). Inside one,
2123
+ // keep the fenced synchronous runner the mutation relies on.
2124
+ const fleet = await readCatalogFleet(
2107
2125
  workspaceRegistry,
2108
- nativeGenerationTmuxRunner,
2109
- (sessionName, pane) =>
2110
- pane.semanticPaneId
2111
- ? (interactionEvidence?.captureInventoryEndpoint(
2112
- sessionName,
2113
- pane.runtimePaneId,
2114
- pane.semanticPaneId,
2115
- ) ?? null)
2116
- : null,
2117
- () => observationSelector?.nativeServerEpoch ?? null,
2126
+ {
2127
+ fence: sessionMutationFence,
2128
+ fencedSync: nativeGenerationTmuxRunner,
2129
+ generationFencedAsync: (args) => fleetFactsTmuxRunner(args),
2130
+ },
2131
+ endpoint,
2132
+ epoch,
2118
2133
  );
2119
2134
  if (!fleet) return null;
2120
2135
  const names = await readTeamMemberships(
@@ -149,13 +149,20 @@ export async function manageDaemonService(
149
149
  refused("The service manager loaded a different definition; refusing to control it");
150
150
  return state;
151
151
  };
152
- const ready = async (previousInstance?: string) => {
152
+ // `retiredPid` is the process this restart terminated. It can publish a record
153
+ // while it shuts down (a KeepAlive relaunch just before the restart), so it is
154
+ // never the replacement, whatever its instance id.
155
+ const ready = async (previousInstance?: string, retiredPid?: number | null) => {
153
156
  const deadline = Date.now() + deps.waitMs;
154
157
  do {
155
158
  const state = deps.inspect();
156
159
  if (!boundTo(state, plan.supervisionId))
157
160
  refused("Service reservation changed during startup");
158
- if (state.status === "valid" && state.info.instanceId !== previousInstance) {
161
+ if (
162
+ state.status === "valid" &&
163
+ state.info.instanceId !== previousInstance &&
164
+ state.info.pid !== retiredPid
165
+ ) {
159
166
  const identity = await deps.probe(state.info);
160
167
  const service = await inspectManager();
161
168
  if (
@@ -283,9 +290,15 @@ export async function manageDaemonService(
283
290
  }
284
291
  const before = deps.inspect();
285
292
  if (action === "install" && service.active) return await ready();
286
- if (service.loaded) await manager.restart(service.pid !== null);
293
+ // Read the manager again: a relaunch may have replaced the process while this
294
+ // waited for the lock, and the restart terminates whichever one runs now.
295
+ const current = service.loaded ? await inspectManager() : service;
296
+ if (current.loaded) await manager.restart(current.pid !== null);
287
297
  else await manager.install();
288
- return await ready(before.status === "valid" ? before.info.instanceId : undefined);
298
+ return await ready(
299
+ before.status === "valid" ? before.info.instanceId : undefined,
300
+ current.loaded ? current.pid : null,
301
+ );
289
302
  } catch (error) {
290
303
  // Never discard ownership after an uncertain OS activation. The retained
291
304
  // record/reservation gives the explicit remove/restart commands a recovery path.
@@ -0,0 +1,185 @@
1
+ import { constants, type Stats } from "node:fs";
2
+ import { lstat, open } from "node:fs/promises";
3
+ import { extname, isAbsolute, join } from "node:path";
4
+ import {
5
+ TMUX_SERVER_SESSION_FAVICON_MAX_BYTES,
6
+ type TmuxServerSessionFaviconMime,
7
+ } from "@tmux-ide/contracts";
8
+
9
+ /** Project-relative favicon locations, first match wins. */
10
+ export const SESSION_FAVICON_CANDIDATES = [
11
+ "favicon.svg",
12
+ "favicon.png",
13
+ "favicon.ico",
14
+ "public/favicon.svg",
15
+ "public/favicon.png",
16
+ "public/favicon.ico",
17
+ "app/icon.svg",
18
+ "app/icon.png",
19
+ "app/favicon.ico",
20
+ "src/app/icon.svg",
21
+ "src/app/icon.png",
22
+ "src/app/favicon.ico",
23
+ "static/favicon.svg",
24
+ "static/favicon.png",
25
+ "static/favicon.ico",
26
+ "assets/favicon.svg",
27
+ "assets/favicon.png",
28
+ "assets/favicon.ico",
29
+ "apple-touch-icon.png",
30
+ "public/apple-touch-icon.png",
31
+ "icon.svg",
32
+ "icon.png",
33
+ ] as const;
34
+
35
+ const MIME_BY_EXTENSION: Readonly<Record<string, TmuxServerSessionFaviconMime>> = {
36
+ ".png": "image/png",
37
+ ".svg": "image/svg+xml",
38
+ ".ico": "image/x-icon",
39
+ ".jpg": "image/jpeg",
40
+ ".jpeg": "image/jpeg",
41
+ ".webp": "image/webp",
42
+ ".gif": "image/gif",
43
+ };
44
+
45
+ export interface SessionFavicon {
46
+ readonly mime: TmuxServerSessionFaviconMime;
47
+ /** Base64 of the raw file bytes. */
48
+ readonly data: string;
49
+ /** Raw byte count. */
50
+ readonly size: number;
51
+ }
52
+
53
+ export interface SessionFaviconIo {
54
+ lstat(path: string): Promise<Stats>;
55
+ /** Reads a regular file without following a final symlink; null when refused or too large. */
56
+ readNoFollow(path: string, maxBytes: number): Promise<Buffer | null>;
57
+ }
58
+
59
+ export const defaultSessionFaviconIo: SessionFaviconIo = {
60
+ lstat: (path) => lstat(path),
61
+ readNoFollow: async (path, maxBytes) => {
62
+ let handle;
63
+ try {
64
+ handle = await open(path, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK);
65
+ } catch {
66
+ return null;
67
+ }
68
+ try {
69
+ const stats = await handle.stat();
70
+ if (!stats.isFile() || stats.size > maxBytes) return null;
71
+ const bytes = await handle.readFile();
72
+ return bytes.byteLength > maxBytes ? null : bytes;
73
+ } catch {
74
+ return null;
75
+ } finally {
76
+ await handle.close().catch(() => undefined);
77
+ }
78
+ },
79
+ };
80
+
81
+ type CacheEntry =
82
+ | {
83
+ readonly kind: "hit";
84
+ readonly file: string;
85
+ readonly mtimeMs: number;
86
+ readonly size: number;
87
+ readonly favicon: SessionFavicon;
88
+ }
89
+ | { readonly kind: "miss"; readonly at: number };
90
+
91
+ export interface SessionFaviconResolverOptions {
92
+ readonly io?: SessionFaviconIo;
93
+ readonly now?: () => number;
94
+ readonly missTtlMs?: number;
95
+ readonly maxEntries?: number;
96
+ }
97
+
98
+ async function lstatOrNull(io: SessionFaviconIo, path: string): Promise<Stats | null> {
99
+ try {
100
+ return await io.lstat(path);
101
+ } catch {
102
+ return null;
103
+ }
104
+ }
105
+
106
+ /**
107
+ * Finds a session's project favicon from its working directory. Only regular
108
+ * files and real directories inside the session path are considered; symlinks
109
+ * are never followed. Hits are revalidated by mtime and size, misses are
110
+ * remembered briefly, and the cache is bounded (least recently used first).
111
+ */
112
+ export function createSessionFaviconResolver(options: SessionFaviconResolverOptions = {}) {
113
+ const io = options.io ?? defaultSessionFaviconIo;
114
+ const now = options.now ?? Date.now;
115
+ const missTtlMs = options.missTtlMs ?? 30_000;
116
+ const maxEntries = options.maxEntries ?? 256;
117
+ const cache = new Map<string, CacheEntry>();
118
+
119
+ const remember = (sessionPath: string, entry: CacheEntry) => {
120
+ cache.delete(sessionPath);
121
+ cache.set(sessionPath, entry);
122
+ while (cache.size > maxEntries) cache.delete(cache.keys().next().value!);
123
+ };
124
+
125
+ const scan = async (sessionPath: string): Promise<CacheEntry> => {
126
+ const directories = new Map<string, boolean>();
127
+ for (const candidate of SESSION_FAVICON_CANDIDATES) {
128
+ const mime = MIME_BY_EXTENSION[extname(candidate)];
129
+ if (!mime) continue;
130
+ const slash = candidate.lastIndexOf("/");
131
+ if (slash >= 0) {
132
+ const parts = candidate.slice(0, slash).split("/");
133
+ let real = true;
134
+ for (let index = 1; index <= parts.length && real; index += 1) {
135
+ const directory = parts.slice(0, index).join("/");
136
+ let known = directories.get(directory);
137
+ if (known === undefined) {
138
+ const stats = await lstatOrNull(io, join(sessionPath, directory));
139
+ known = stats !== null && stats.isDirectory() && !stats.isSymbolicLink();
140
+ directories.set(directory, known);
141
+ }
142
+ real = known;
143
+ }
144
+ if (!real) continue;
145
+ }
146
+ const file = join(sessionPath, candidate);
147
+ const stats = await lstatOrNull(io, file);
148
+ if (!stats || !stats.isFile() || stats.size > TMUX_SERVER_SESSION_FAVICON_MAX_BYTES) continue;
149
+ const bytes = await io.readNoFollow(file, TMUX_SERVER_SESSION_FAVICON_MAX_BYTES);
150
+ if (!bytes) continue;
151
+ return {
152
+ kind: "hit",
153
+ file,
154
+ mtimeMs: stats.mtimeMs,
155
+ size: stats.size,
156
+ favicon: { mime, data: bytes.toString("base64"), size: bytes.byteLength },
157
+ };
158
+ }
159
+ return { kind: "miss", at: now() };
160
+ };
161
+
162
+ return async (sessionPath: string): Promise<SessionFavicon | null> => {
163
+ if (!sessionPath || !isAbsolute(sessionPath) || sessionPath.includes("\0")) return null;
164
+ const cached = cache.get(sessionPath);
165
+ if (cached?.kind === "miss" && now() - cached.at < missTtlMs) {
166
+ remember(sessionPath, cached);
167
+ return null;
168
+ }
169
+ if (cached?.kind === "hit") {
170
+ const stats = await lstatOrNull(io, cached.file);
171
+ if (
172
+ stats &&
173
+ stats.isFile() &&
174
+ stats.mtimeMs === cached.mtimeMs &&
175
+ stats.size === cached.size
176
+ ) {
177
+ remember(sessionPath, cached);
178
+ return cached.favicon;
179
+ }
180
+ }
181
+ const entry = await scan(sessionPath);
182
+ remember(sessionPath, entry);
183
+ return entry.kind === "hit" ? entry.favicon : null;
184
+ };
185
+ }
@@ -13,8 +13,8 @@ import { resolveRuntimeNamespace } from "./runtime-namespace.ts";
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}
@@ -73,7 +73,7 @@ export function defaultSkillSource(): string {
73
73
  // ---------------------------------------------------------------------------
74
74
 
75
75
  /**
76
- * The version marker line (an HTML comment on line 2 of SKILL.md). The sync
76
+ * The version marker line (an HTML comment under SKILL.md's frontmatter). The sync
77
77
  * machinery reads it to know which version an installed copy is, and rewrites it
78
78
  * to the installed package version at copy time.
79
79
  */
@@ -97,13 +97,23 @@ function discoveredWorkspaceName(registry: WorkspaceRegistry, sessionName: strin
97
97
  throw new Error("Unable to allocate discovered workspace identity");
98
98
  }
99
99
 
100
+ /** A live session row with the facts the native app's Home shows beside it. */
101
+ export interface NativeLiveSessionSummary extends LiveSessionSummary {
102
+ /** Distinct tmux windows of the session (at least one). */
103
+ readonly windowCount: number;
104
+ /** tmux `#{session_activity}` in epoch seconds, or null when tmux reports none. */
105
+ readonly lastActivity: number | null;
106
+ /** tmux `#{session_path}`, the session's working directory. */
107
+ readonly sessionPath: string;
108
+ }
109
+
100
110
  /** Refresh discovered live membership without changing durable workspace intent or aliases. */
101
111
  export function createNativeTmuxServerCatalog(
102
112
  workspaceRegistry: WorkspaceRegistry,
103
113
  run: (args: readonly string[], signal?: AbortSignal) => Promise<string>,
104
114
  assertOpen: () => void = () => undefined,
105
- ): () => Promise<LiveSessionSummary[]> {
106
- return async (): Promise<LiveSessionSummary[]> => {
115
+ ): () => Promise<NativeLiveSessionSummary[]> {
116
+ return async (): Promise<NativeLiveSessionSummary[]> => {
107
117
  assertOpen();
108
118
  const raw = await run(
109
119
  [
@@ -116,7 +126,8 @@ export function createNativeTmuxServerCatalog(
116
126
  "list-panes",
117
127
  "-a",
118
128
  "-F",
119
- "#{pid}\t#{session_id}\t#{session_created}\t#{session_name}\t#{pane_id}\t#{session_path}",
129
+ // session_path stays last: tmux rejects tabs in session names but not in paths.
130
+ "#{pid}\t#{session_id}\t#{session_created}\t#{session_name}\t#{pane_id}\t#{window_id}\t#{session_activity}\t#{session_path}",
120
131
  ]
121
132
  .map(shellEscape)
122
133
  .join(" "),
@@ -126,13 +137,20 @@ export function createNativeTmuxServerCatalog(
126
137
  assertOpen();
127
138
  const rows = new Map<
128
139
  string,
129
- { summary: LiveSessionSummary; panes: Set<string>; dir: string }
140
+ {
141
+ summary: LiveSessionSummary;
142
+ panes: Set<string>;
143
+ windows: Set<string>;
144
+ dir: string;
145
+ activity: number | null;
146
+ }
130
147
  >();
131
148
  // Only the successful server-side zero-session branch proves emptiness.
132
149
  // Command errors, unsupported formats and unwitnessed blank output stay failures.
133
150
  const lines = raw.trim() === "tmux-ide-empty-catalog-v1" ? [] : raw.trim().split("\n");
134
151
  for (const line of lines) {
135
- const [pid, id, created, name, pane, dir] = line.split("\t");
152
+ const [pid, id, created, name, pane, window, activity, ...pathParts] = line.split("\t");
153
+ const dir = pathParts.join("\t");
136
154
  if (
137
155
  !pid ||
138
156
  !/^\d+$/.test(pid) ||
@@ -143,6 +161,8 @@ export function createNativeTmuxServerCatalog(
143
161
  !name ||
144
162
  !pane ||
145
163
  !/^%\d+$/.test(pane) ||
164
+ !window ||
165
+ !/^@\d+$/.test(window) ||
146
166
  !dir
147
167
  )
148
168
  throw new Error("Invalid native server catalog");
@@ -151,9 +171,12 @@ export function createNativeTmuxServerCatalog(
151
171
  const row = rows.get(liveSessionId) ?? {
152
172
  summary: { liveSessionId, sessionName: name, paneCount: 0 },
153
173
  panes: new Set<string>(),
174
+ windows: new Set<string>(),
154
175
  dir,
176
+ activity: activity && /^\d+$/.test(activity) ? Number(activity) : null,
155
177
  };
156
178
  row.panes.add(pane);
179
+ row.windows.add(window);
157
180
  rows.set(liveSessionId, row);
158
181
  }
159
182
  const names = new Set([...rows.values()].map(({ summary }) => summary.sessionName));
@@ -170,7 +193,13 @@ export function createNativeTmuxServerCatalog(
170
193
  projectDir: dir,
171
194
  persistence: "volatile",
172
195
  });
173
- return [...rows.values()].map(({ summary, panes }) => ({ ...summary, paneCount: panes.size }));
196
+ return [...rows.values()].map(({ summary, panes, windows, dir, activity }) => ({
197
+ ...summary,
198
+ paneCount: panes.size,
199
+ windowCount: windows.size,
200
+ lastActivity: activity,
201
+ sessionPath: dir,
202
+ }));
174
203
  };
175
204
  }
176
205