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.
- package/README.md +1 -1
- package/bin/cli.js +740 -185
- package/package.json +2 -1
- package/packages/contracts/src/tmux-server-scope.ts +54 -0
- package/packages/core/src/terminal-fnv64-wasm.ts +8 -1
- package/packages/core/src/terminal-replica-hash-cache.ts +108 -1
- package/packages/core/src/terminal-replica.ts +46 -10
- package/packages/daemon/dist/command-center/daemon-fleet-facts-observer.js +8 -1
- package/packages/daemon/dist/command-center/discovery.js +39 -6
- package/packages/daemon/dist/command-center/tmux-servers.js +46 -10
- package/packages/daemon/dist/lib/catalog-fleet-read.js +10 -0
- package/packages/daemon/dist/lib/daemon-embed.js +14 -3
- package/packages/daemon/dist/lib/daemon-service.js +13 -5
- package/packages/daemon/dist/lib/session-favicon.js +152 -0
- package/packages/daemon/dist/lib/skill-sync.js +3 -3
- package/packages/daemon/dist/lib/tmux-server-owner.js +16 -3
- package/packages/daemon/dist/lib/tmux-server-owners.js +20 -1
- package/packages/daemon/dist/lib/tui-binary.js +83 -17
- package/packages/daemon/dist/native/TmuxIdeNotifier.app/Contents/Info.plist +2 -2
- package/packages/daemon/dist/native/TmuxIdeNotifier.app/Contents/MacOS/tmux-ide-notifier +0 -0
- package/packages/daemon/dist/native/TmuxIdeNotifier.app/Contents/Resources/AppIcon.icns +0 -0
- package/packages/daemon/dist/native/TmuxIdeNotifier.app/Contents/Resources/Assets.car +0 -0
- package/packages/daemon/dist/native/TmuxIdeNotifier.app/Contents/_CodeSignature/CodeResources +4 -4
- package/packages/daemon/dist/open-browser.js +47 -3
- package/packages/daemon/dist/terminal/pane-stream/pane-stream-websocket.js +12 -0
- package/packages/daemon/dist/terminal/session-runtime/terminal-delivery-hub.js +16 -0
- package/packages/daemon/src/command-center/daemon-fleet-facts-observer.ts +9 -1
- package/packages/daemon/src/command-center/discovery.ts +72 -7
- package/packages/daemon/src/command-center/tmux-servers.ts +55 -11
- package/packages/daemon/src/lib/catalog-fleet-read.ts +40 -0
- package/packages/daemon/src/lib/daemon-embed.ts +27 -12
- package/packages/daemon/src/lib/daemon-service.ts +17 -4
- package/packages/daemon/src/lib/session-favicon.ts +185 -0
- package/packages/daemon/src/lib/skill-sync.ts +3 -3
- package/packages/daemon/src/lib/tmux-server-owner.ts +35 -6
- package/packages/daemon/src/lib/tmux-server-owners.ts +17 -1
- package/packages/daemon/src/lib/tui-binary.ts +114 -22
- package/packages/daemon/src/open-browser.ts +88 -3
- package/packages/daemon/src/terminal/pane-stream/pane-stream-websocket.ts +12 -0
- package/packages/daemon/src/terminal/session-runtime/terminal-delivery-hub.ts +25 -0
- 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.
|
|
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 {
|
|
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
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
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 ??
|
|
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(
|
|
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
|
-
|
|
227
|
-
|
|
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(
|
|
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
|
-
|
|
189
|
-
|
|
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:
|
|
192
|
-
const
|
|
193
|
-
const
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
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
|
|
1546
|
+
const endpoint = (sessionName, pane) => pane.semanticPaneId
|
|
1546
1547
|
? (interactionEvidence?.captureInventoryEndpoint(sessionName, pane.runtimePaneId, pane.semanticPaneId) ?? null)
|
|
1547
|
-
: 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
|
-
|
|
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" &&
|
|
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
|
-
|
|
240
|
-
|
|
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
|