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
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "tmux-ide",
3
- "version": "2.9.15",
3
+ "version": "2.9.17",
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({
@@ -10,7 +10,7 @@ export interface BufferedFnv64 {
10
10
  digest(): string;
11
11
  }
12
12
 
13
- interface NativeHasher {
13
+ export interface NativeHasher {
14
14
  readonly scratch: Uint8Array;
15
15
  readonly update: (state: bigint, length: number) => bigint;
16
16
  }
@@ -49,6 +49,13 @@ function initialize(): NativeHasher | null {
49
49
  return native;
50
50
  }
51
51
 
52
+ /** Direct access to the WASM scratch buffer and its update, for one synchronous
53
+ * hash at a time. Callers must not yield between filling the scratch and update. */
54
+ export function nativeFnv64(): NativeHasher | null {
55
+ return initialize();
56
+ }
57
+ export const FNV64_OFFSET_BASIS = OFFSET_BASIS;
58
+
52
59
  /** Internal optional accelerator. Canonical serialization remains the caller's responsibility. */
53
60
  export function createBufferedFnv64(initialState = OFFSET_BASIS): BufferedFnv64 | null {
54
61
  const hasher = initialize();
@@ -1,9 +1,15 @@
1
1
  import {
2
+ isOwnedTerminalReplicaRow,
2
3
  isSchemaValidProjectedTerminalReplicaRow,
3
4
  TERMINAL_REPLICA_DEFAULT_COLOR,
4
5
  } from "./terminal-replica-owned-row.ts";
5
6
  import type { TerminalReplicaColor, TerminalReplicaRow } from "@tmux-ide/contracts";
6
- import { createBufferedFnv64, type BufferedFnv64 } from "./terminal-fnv64-wasm.ts";
7
+ import {
8
+ createBufferedFnv64,
9
+ FNV64_OFFSET_BASIS,
10
+ nativeFnv64,
11
+ type BufferedFnv64,
12
+ } from "./terminal-fnv64-wasm.ts";
7
13
 
8
14
  const ROW_HASH_CACHE = new WeakMap<object, string>();
9
15
  const DEEPLY_FROZEN_ROWS = new WeakSet<object>();
@@ -425,6 +431,12 @@ export function hashTerminalReplicaRowCached(
425
431
  const cached = ROW_HASH_CACHE.get(row);
426
432
  if (cached) return cached;
427
433
  onMiss?.();
434
+ const native = nativeFnv64();
435
+ if (native) {
436
+ const digest = hashRowBytes(row, native);
437
+ if (isTerminalReplicaRowDeeplyFrozen(row)) ROW_HASH_CACHE.set(row, digest);
438
+ return digest;
439
+ }
428
440
  const hash = encodingCache?.createHash() ?? new CanonicalFnv64();
429
441
  let canonicalBytes = 10 + String(row.cells.length).length;
430
442
  hash.ascii("a2:");
@@ -631,6 +643,11 @@ export function primeTerminalReplicaRowHash(row: TerminalReplicaRow, digest: str
631
643
 
632
644
  export function isTerminalReplicaRowDeeplyFrozen(row: TerminalReplicaRow): boolean {
633
645
  if (DEEPLY_FROZEN_ROWS.has(row)) return true;
646
+ // Every owned-row constructor freezes the row, its cells and their colors.
647
+ if (isOwnedTerminalReplicaRow(row as unknown)) {
648
+ DEEPLY_FROZEN_ROWS.add(row);
649
+ return true;
650
+ }
634
651
  if (!Object.isFrozen(row) || !Object.isFrozen(row.cells)) return false;
635
652
  for (const cell of row.cells) {
636
653
  if (
@@ -643,3 +660,93 @@ export function isTerminalReplicaRowDeeplyFrozen(row: TerminalReplicaRow): boole
643
660
  DEEPLY_FROZEN_ROWS.add(row);
644
661
  return true;
645
662
  }
663
+
664
+ // #708: the same canonical row bytes as hashTerminalReplicaRowCached's
665
+ // CanonicalFnv64 path, assembled from per-cell byte strings and hashed in
666
+ // WASM in 4 KiB chunks instead of several small JS writes per cell.
667
+ const ASCII_ENCODER = new TextEncoder();
668
+ const CELL_BYTES_LIMIT = 4_096;
669
+ const PLAIN_CELL_BYTES = new Map<string, Uint8Array>();
670
+ const STYLED_CELL_BYTES = new Map<string, Uint8Array>();
671
+ const ROW_TRAILER = ASCII_ENCODER.encode(";;");
672
+
673
+ function canonicalNumber(value: number): string {
674
+ const text = String(value);
675
+ return `d${text.length}:${text};`;
676
+ }
677
+
678
+ function canonicalColor(color: TerminalReplicaColor): string {
679
+ if (color.kind === "default") return "o1:s4:kind;s7:default;;";
680
+ if (color.kind === "indexed")
681
+ return `o2:s5:index;${canonicalNumber(color.index)}s4:kind;s7:indexed;;`;
682
+ return `o2:s4:kind;s3:rgb;s5:value;${canonicalNumber(color.value)};`;
683
+ }
684
+
685
+ function encodeCell(cell: TerminalReplicaRow["cells"][number]): Uint8Array {
686
+ const grapheme = cell.grapheme;
687
+ let length = grapheme.length;
688
+ for (let index = 0; index < grapheme.length; index += 1)
689
+ if (grapheme.charCodeAt(index) > 0x7f) {
690
+ length = UTF8_ENCODER.encode(grapheme).byteLength;
691
+ break;
692
+ }
693
+ return UTF8_ENCODER.encode(
694
+ `a5:s${length}:${grapheme};${canonicalNumber(cell.width)}${canonicalColor(
695
+ cell.foreground,
696
+ )}${canonicalColor(cell.background)}${canonicalNumber(cell.attributes)};`,
697
+ );
698
+ }
699
+
700
+ function remember(cache: Map<string, Uint8Array>, key: string, bytes: Uint8Array): Uint8Array {
701
+ if (cache.size >= CELL_BYTES_LIMIT) cache.clear();
702
+ cache.set(key, bytes);
703
+ return bytes;
704
+ }
705
+
706
+ function cellBytes(cell: TerminalReplicaRow["cells"][number]): Uint8Array {
707
+ const { grapheme, width, attributes, foreground, background } = cell;
708
+ if (
709
+ width === 1 &&
710
+ attributes === 0 &&
711
+ foreground.kind === "default" &&
712
+ background.kind === "default"
713
+ )
714
+ return PLAIN_CELL_BYTES.get(grapheme) ?? remember(PLAIN_CELL_BYTES, grapheme, encodeCell(cell));
715
+ const key = `${width}\u0000${attributes}\u0000${canonicalColor(foreground)}\u0000${canonicalColor(
716
+ background,
717
+ )}\u0000${grapheme}`;
718
+ return STYLED_CELL_BYTES.get(key) ?? remember(STYLED_CELL_BYTES, key, encodeCell(cell));
719
+ }
720
+
721
+ function hashRowBytes(
722
+ row: TerminalReplicaRow,
723
+ native: NonNullable<ReturnType<typeof nativeFnv64>>,
724
+ ): string {
725
+ const scratch = native.scratch;
726
+ const capacity = scratch.byteLength;
727
+ let state = FNV64_OFFSET_BASIS;
728
+ let length = 0;
729
+ const write = (bytes: Uint8Array) => {
730
+ if (bytes.byteLength <= capacity - length) {
731
+ scratch.set(bytes, length);
732
+ length += bytes.byteLength;
733
+ return;
734
+ }
735
+ let offset = 0;
736
+ while (offset < bytes.byteLength) {
737
+ const count = Math.min(capacity - length, bytes.byteLength - offset);
738
+ scratch.set(bytes.subarray(offset, offset + count), length);
739
+ length += count;
740
+ offset += count;
741
+ if (length === capacity) {
742
+ state = BigInt.asUintN(64, native.update(state, length));
743
+ length = 0;
744
+ }
745
+ }
746
+ };
747
+ write(ASCII_ENCODER.encode(`a2:${row.wrapped ? "b1;" : "b0;"}a${row.cells.length}:`));
748
+ for (const cell of row.cells) write(cellBytes(cell));
749
+ write(ROW_TRAILER);
750
+ state = BigInt.asUintN(64, native.update(state, length));
751
+ return state.toString(16).padStart(16, "0");
752
+ }
@@ -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,43 @@ 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.patch") {
1005
+ // #708: a flood patch appends many history rows the interpreter has just
1006
+ // hashed; enter them, and the changed screen rows, by their row hashes.
1007
+ const { rows, history, historyDelta, ...rest } = update.patch;
1008
+ return [
1009
+ "terminal-replica-patch-frame-v2",
1010
+ { ...update, patch: rest },
1011
+ rows.map(({ index, row }) => [index, hashTerminalReplicaRow(row)]),
1012
+ history === undefined ? null : hashTerminalReplicaRows(history),
1013
+ historyDelta === undefined
1014
+ ? null
1015
+ : [historyDelta.trim, hashTerminalReplicaRows(historyDelta.append)],
1016
+ ];
1017
+ }
1018
+ if (update.type !== "terminal.seed") return update;
1019
+ const { grid, history, ...rest } = update.snapshot;
1020
+ return [
1021
+ "terminal-replica-frame-v2",
1022
+ { ...update, snapshot: rest },
1023
+ hashTerminalReplicaRows(grid),
1024
+ hashTerminalReplicaRows(history),
1025
+ ];
1026
+ }
1027
+
992
1028
  function readProfileClock(
993
1029
  instrumentation: TerminalReplicaApplyOptions["instrumentation"] | undefined,
994
1030
  ): 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",
@@ -160,11 +160,15 @@ export function isVisibleFleetSession(name) {
160
160
  export function readAdoptedSessionNames(runTmux = _tmuxRunner) {
161
161
  let raw;
162
162
  try {
163
- raw = runTmux(["list-sessions", "-F", `#{session_name}\t#{${ADOPTED_OPTION}}`]);
163
+ raw = runTmux(ADOPTED_SESSIONS_ARGS);
164
164
  }
165
165
  catch {
166
166
  return null;
167
167
  }
168
+ return parseAdoptedSessionNames(raw);
169
+ }
170
+ const ADOPTED_SESSIONS_ARGS = ["list-sessions", "-F", `#{session_name}\t#{${ADOPTED_OPTION}}`];
171
+ function parseAdoptedSessionNames(raw) {
168
172
  const names = [];
169
173
  for (const line of raw.split("\n")) {
170
174
  if (!line)
@@ -223,18 +227,47 @@ export function readAdoptedFleet(registry = getDefaultWorkspaceRegistry(), runTm
223
227
  const adopted = readAdoptedSessionNames(runTmux);
224
228
  if (adopted === null)
225
229
  return null;
226
- const adoptedSet = new Set(adopted);
227
- if (adoptedSet.size === 0)
230
+ if (adopted.length === 0)
231
+ return [];
232
+ let panesRaw;
233
+ try {
234
+ panesRaw = runTmux(FLEET_PANES_ARGS);
235
+ }
236
+ catch {
237
+ panesRaw = "";
238
+ }
239
+ return assembleAdoptedFleet(adopted, panesRaw, registry, nativeEpoch, resolveInteractionEndpoint, nativeServerEpoch);
240
+ }
241
+ /**
242
+ * {@link readAdoptedFleet} over an asynchronous tmux runner: the same two reads
243
+ * in the same order, without blocking the event loop while tmux answers (#709).
244
+ */
245
+ export async function readAdoptedFleetAsync(registry, runTmux, resolveInteractionEndpoint, nativeServerEpoch) {
246
+ const nativeEpoch = nativeServerEpoch?.() ?? null;
247
+ let sessionsRaw;
248
+ try {
249
+ sessionsRaw = await runTmux(ADOPTED_SESSIONS_ARGS);
250
+ }
251
+ catch {
252
+ return null;
253
+ }
254
+ const adopted = parseAdoptedSessionNames(sessionsRaw);
255
+ if (adopted.length === 0)
228
256
  return [];
229
- const appCreatedSessions = new Set(registry.list().map((workspace) => workspace.sessionName));
230
- const panesBySession = new Map();
231
257
  let panesRaw;
232
258
  try {
233
- panesRaw = runTmux(["list-panes", "-a", "-F", FLEET_PANE_FORMAT]);
259
+ panesRaw = await runTmux(FLEET_PANES_ARGS);
234
260
  }
235
261
  catch {
236
262
  panesRaw = "";
237
263
  }
264
+ return assembleAdoptedFleet(adopted, panesRaw, registry, nativeEpoch, resolveInteractionEndpoint, nativeServerEpoch);
265
+ }
266
+ const FLEET_PANES_ARGS = ["list-panes", "-a", "-F", FLEET_PANE_FORMAT];
267
+ function assembleAdoptedFleet(adopted, panesRaw, registry, nativeEpoch, resolveInteractionEndpoint, nativeServerEpoch) {
268
+ const adoptedSet = new Set(adopted);
269
+ const appCreatedSessions = new Set(registry.list().map((workspace) => workspace.sessionName));
270
+ const panesBySession = new Map();
238
271
  for (const line of panesRaw.split("\n")) {
239
272
  if (!line)
240
273
  continue;
@@ -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,10 @@
1
+ import { readAdoptedFleet, readAdoptedFleetAsync, } from "../command-center/discovery.js";
2
+ /**
3
+ * Read the catalog fleet without blocking the event loop (#709), except inside
4
+ * a session mutation, where the read keeps the fence's synchronous runner.
5
+ */
6
+ export async function readCatalogFleet(registry, runners, resolveInteractionEndpoint, nativeServerEpoch) {
7
+ return runners.fence.active
8
+ ? readAdoptedFleet(registry, runners.fencedSync, resolveInteractionEndpoint, nativeServerEpoch)
9
+ : readAdoptedFleetAsync(registry, runners.generationFencedAsync, resolveInteractionEndpoint, nativeServerEpoch);
10
+ }
@@ -68,7 +68,8 @@ import { setDaemonShutdownBackend, setDaemonRestartBackend, } from "../command-c
68
68
  import { readAppSettings } from "./app-settings.js";
69
69
  import { getDefaultWorkspaceRegistry, readWorkspaceRegistrySessionInventory, WORKSPACE_REGISTRY_TMUX_TIMEOUT_MS, } from "./workspace-registry.js";
70
70
  import { createPinnedWorkspaceTmuxRunner, createPinnedWorkspaceTmuxAsyncRunner, resolveWorkspacePaneTmuxAuthority, WorkspacePaneCreationAuthority, } from "./workspace-pane-creation.js";
71
- import { discoverLiveSessionSummaries, readAdoptedFleet } from "../command-center/discovery.js";
71
+ import { discoverLiveSessionSummaries, readAdoptedFleet, } from "../command-center/discovery.js";
72
+ import { readCatalogFleet } from "./catalog-fleet-read.js";
72
73
  import { WorkspaceOpenAuthority } from "./workspace-open.js";
73
74
  import { WorkspacePromotionAuthority } from "./workspace-promotion.js";
74
75
  import { WorkspaceOpenHandoffCoordinator } from "./workspace-open-handoff.js";
@@ -1542,9 +1543,19 @@ async function startEmbeddedDaemonGeneration(opts) {
1542
1543
  paneStreamRuntime,
1543
1544
  catalogLiveSessions: () => discoverLiveSessionSummaries(catalogTmuxRunner),
1544
1545
  catalogFleet: async () => {
1545
- const fleet = readAdoptedFleet(workspaceRegistry, nativeGenerationTmuxRunner, (sessionName, pane) => pane.semanticPaneId
1546
+ const endpoint = (sessionName, pane) => pane.semanticPaneId
1546
1547
  ? (interactionEvidence?.captureInventoryEndpoint(sessionName, pane.runtimePaneId, pane.semanticPaneId) ?? null)
1547
- : null, () => observationSelector?.nativeServerEpoch ?? null);
1548
+ : null;
1549
+ const epoch = () => observationSelector?.nativeServerEpoch ?? null;
1550
+ // Same pinned server generation either way. Outside a session
1551
+ // mutation the read must not block the event loop: every concurrent
1552
+ // catalog request queued behind its spawnSync (#709). Inside one,
1553
+ // keep the fenced synchronous runner the mutation relies on.
1554
+ const fleet = await readCatalogFleet(workspaceRegistry, {
1555
+ fence: sessionMutationFence,
1556
+ fencedSync: nativeGenerationTmuxRunner,
1557
+ generationFencedAsync: (args) => fleetFactsTmuxRunner(args),
1558
+ }, endpoint, epoch);
1548
1559
  if (!fleet)
1549
1560
  return null;
1550
1561
  const names = await readTeamMemberships(fleet.flatMap((session) => session.panes.map((pane) => ({
@@ -106,13 +106,18 @@ export async function manageDaemonService(action, executable, deps = defaults())
106
106
  refused("The service manager loaded a different definition; refusing to control it");
107
107
  return state;
108
108
  };
109
- const ready = async (previousInstance) => {
109
+ // `retiredPid` is the process this restart terminated. It can publish a record
110
+ // while it shuts down (a KeepAlive relaunch just before the restart), so it is
111
+ // never the replacement, whatever its instance id.
112
+ const ready = async (previousInstance, retiredPid) => {
110
113
  const deadline = Date.now() + deps.waitMs;
111
114
  do {
112
115
  const state = deps.inspect();
113
116
  if (!boundTo(state, plan.supervisionId))
114
117
  refused("Service reservation changed during startup");
115
- if (state.status === "valid" && state.info.instanceId !== previousInstance) {
118
+ if (state.status === "valid" &&
119
+ state.info.instanceId !== previousInstance &&
120
+ state.info.pid !== retiredPid) {
116
121
  const identity = await deps.probe(state.info);
117
122
  const service = await inspectManager();
118
123
  if (identity &&
@@ -236,11 +241,14 @@ export async function manageDaemonService(action, executable, deps = defaults())
236
241
  const before = deps.inspect();
237
242
  if (action === "install" && service.active)
238
243
  return await ready();
239
- if (service.loaded)
240
- await manager.restart(service.pid !== null);
244
+ // Read the manager again: a relaunch may have replaced the process while this
245
+ // waited for the lock, and the restart terminates whichever one runs now.
246
+ const current = service.loaded ? await inspectManager() : service;
247
+ if (current.loaded)
248
+ await manager.restart(current.pid !== null);
241
249
  else
242
250
  await manager.install();
243
- return await ready(before.status === "valid" ? before.info.instanceId : undefined);
251
+ return await ready(before.status === "valid" ? before.info.instanceId : undefined, current.loaded ? current.pid : null);
244
252
  }
245
253
  catch (error) {
246
254
  // Never discard ownership after an uncertain OS activation. The retained