tmux-ide 2.9.14 → 2.9.16
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -1
- package/bin/cli.js +2015 -1428
- package/package.json +2 -1
- package/packages/contracts/src/index.ts +1 -0
- package/packages/contracts/src/native-browser.ts +43 -0
- package/packages/contracts/src/tmux-server-scope.ts +54 -0
- package/packages/core/src/terminal-replica.ts +32 -10
- package/packages/daemon/dist/command-center/daemon-fleet-facts-observer.js +8 -1
- package/packages/daemon/dist/command-center/tmux-servers.js +52 -10
- package/packages/daemon/dist/lib/daemon-embed.js +15 -0
- package/packages/daemon/dist/lib/native-browser-rpc.js +110 -0
- 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-native-browser.js +80 -0
- package/packages/daemon/dist/lib/tmux-server-owner.js +25 -3
- 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/Assets.car +0 -0
- package/packages/daemon/dist/native/TmuxIdeNotifier.app/Contents/_CodeSignature/CodeResources +2 -2
- package/packages/daemon/dist/open-browser.js +64 -8
- 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/tmux-servers.ts +66 -11
- package/packages/daemon/src/lib/daemon-embed.ts +17 -0
- package/packages/daemon/src/lib/native-browser-rpc.ts +118 -0
- 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-native-browser.ts +100 -0
- package/packages/daemon/src/lib/tmux-server-owner.ts +44 -6
- package/packages/daemon/src/open-browser.ts +120 -8
- 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.16",
|
|
4
4
|
"description": "The open-source workspace for coding agents: run Claude Code, Codex and other agents in tmux with live status",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -70,6 +70,7 @@
|
|
|
70
70
|
"postinstall": "node scripts/postinstall.js",
|
|
71
71
|
"docs": "turbo run dev --filter=@tmux-ide/docs",
|
|
72
72
|
"demo:tui": "bun --preload @opentui/solid/preload docs/scripts/render-tui-demo.tsx",
|
|
73
|
+
"demo:themes": "bun --preload @opentui/solid/preload docs/scripts/render-theme-gallery.tsx && node docs/scripts/rasterize-theme-gallery.mjs",
|
|
73
74
|
"demo:font": "node docs/scripts/tui-demo-font/build.mjs",
|
|
74
75
|
"readme:media": "node docs/scripts/readme-media.mjs",
|
|
75
76
|
"test:tui-renderer": "bun test --preload @opentui/solid/preload --preload ./packages/daemon/test-support/opentui-renderer-preload.ts ./packages/daemon/src/tui/mirror/automatic-contrast-renderer.test.tsx ./packages/daemon/src/tui/mirror/pane-surface-renderer.test.tsx ./packages/daemon/src/tui/mirror/native-grid-projection-renderer.test.tsx ./packages/daemon/src/tui/mirror/widget-surface-renderer.test.tsx ./packages/daemon/src/tui/mirror/missions-surface-renderer.test.tsx ./packages/daemon/src/tui/mirror/recipes-gallery-renderer.test.tsx ./packages/daemon/src/tui/mirror/shell-chrome-renderer.test.tsx ./packages/daemon/src/tui/mirror/sidebar-renderer.test.tsx ./packages/daemon/src/tui/mirror/home-files-surface-renderer.test.tsx ./packages/daemon/src/tui/mirror/changes-terminal-surface-renderer.test.tsx ./packages/daemon/src/tui/mirror/activity-surface-renderer.test.tsx ./packages/daemon/src/tui/mirror/features/files/session-renderer.test.tsx ./packages/daemon/src/tui/mirror/runtime/application-terminal-workspace-renderer.test.tsx ./packages/daemon/src/tui/mirror/testing/styled-frame-oracle.test.tsx ./packages/daemon/src/tui/mirror/runtime/terminal-transient-frame-renderer.test.tsx ./packages/daemon/src/tui/mirror/runtime/terminal-draw-failure-renderer.test.tsx ./packages/daemon/src/tui/mirror/runtime/application-shell-view-renderer.test.tsx ./packages/daemon/src/tui/mirror/runtime/application-root-error-renderer.test.tsx ./packages/daemon/src/tui/mirror/runtime/application-backpressure-renderer.test.tsx ./packages/daemon/src/tui/mirror/runtime/application-demand-cadence-renderer.test.tsx ./packages/daemon/src/tui/mirror/runtime/files-optional-feature-renderer.test.tsx ./packages/daemon/src/tui/mirror/runtime/changes-optional-feature-renderer.test.tsx ./packages/daemon/src/tui/mirror/runtime/missions-activity-optional-feature-renderer.test.tsx ./packages/daemon/src/tui/mirror/runtime/dialogs-optional-feature-renderer.test.tsx ./packages/daemon/src/tui/mirror/runtime/optional-feature-registry-renderer.test.tsx ./packages/daemon/src/tui/mirror/runtime/palette-optional-feature-renderer.test.tsx ./packages/daemon/src/tui/mirror/runtime/pane-scoped-terminal-surface-renderer.test.tsx ./packages/daemon/src/tui/mirror/features/rich-preview/feature.test.ts ./packages/daemon/src/tui/mirror/runtime/rich-preview-optional-feature-renderer.test.tsx ./packages/daemon/src/tui/mirror/workspace/application-shell-renderer.test.tsx ./packages/daemon/src/tui/mirror/workspace/pane-frame-renderer.test.tsx ./packages/daemon/src/tui/mirror/workspace/terminal-pane-chrome-view.test.tsx ./packages/daemon/src/tui/mirror/workspace/terminal-window-strip-renderer.test.tsx ./packages/daemon/src/tui/mirror/workspace/workbench-shell-renderer.test.tsx ./packages/daemon/src/tui/mirror/workspace/workbench-dock-dual-host-renderer.test.tsx ./packages/daemon/src/tui/mirror/workspace/agent-terminal-canvas-renderer.test.tsx ./packages/daemon/src/tui/mirror/workspace/command-palette-surface-renderer.test.tsx ./packages/daemon/src/tui/mirror/workspace/opentui-insertion-stability-renderer.test.tsx ./packages/daemon/src/tui/mirror/runtime/application-shell-home-renderer.test.tsx ./packages/daemon/src/tui/mirror/workspace/terminal-pane-header-polish-renderer.test.tsx ./packages/daemon/src/tui/mirror/runtime/application-home-agent-roster-renderer.test.tsx ./packages/daemon/src/tui/mirror/runtime/application-home-agent-flow-renderer.test.tsx ./packages/daemon/src/tui/mirror/runtime/guided-tour-renderer.test.tsx ./packages/daemon/src/tui/mirror/runtime/application-shell-sidebar-catalog-renderer.test.tsx ./packages/daemon/src/tui/mirror/ui/ui-renderer.test.tsx ./packages/daemon/src/tui/mirror/ui/pane-interaction-renderer.test.tsx ./packages/daemon/src/tui/mirror/runtime/application-machine-sidebar-renderer.test.tsx ./packages/daemon/src/tui/mirror/runtime/application-add-machine-dialog-renderer.test.tsx ./packages/daemon/src/tui/mirror/runtime/application-new-agent-dialog-renderer.test.tsx ./packages/daemon/src/tui/mirror/runtime/application-fleet-switcher-renderer.test.tsx ./packages/daemon/src/tui/mirror/runtime/application-palette-preview-renderer.test.tsx ./packages/daemon/src/tui/mirror/runtime/application-fleet-palette-renderer.test.tsx ./packages/daemon/src/tui/mirror/runtime/application-fleet-session-actions-renderer.test.tsx ./packages/daemon/src/tui/mirror/ui/agent-team-renderer.test.tsx ./scripts/solid-conditional-props.test.tsx",
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
|
|
3
|
+
const text = (limit: number) =>
|
|
4
|
+
z
|
|
5
|
+
.string()
|
|
6
|
+
.max(limit)
|
|
7
|
+
// Intentionally reject control characters in display text and navigation input.
|
|
8
|
+
// eslint-disable-next-line no-control-regex
|
|
9
|
+
.refine((value) => !/[\x00-\x1f\x7f]/u.test(value));
|
|
10
|
+
const identity = {
|
|
11
|
+
version: z.literal(1),
|
|
12
|
+
instance: z.uuid(),
|
|
13
|
+
pane: z
|
|
14
|
+
.string()
|
|
15
|
+
.regex(/^%[0-9]+$/u)
|
|
16
|
+
.max(32),
|
|
17
|
+
tab: z.number().int().positive().max(Number.MAX_SAFE_INTEGER),
|
|
18
|
+
revision: z.number().int().positive().max(Number.MAX_SAFE_INTEGER),
|
|
19
|
+
};
|
|
20
|
+
/** Display state is not authority: every command echoes the exact browser revision. */
|
|
21
|
+
export const NativeBrowserStateSchemaZ = z
|
|
22
|
+
.object({
|
|
23
|
+
...identity,
|
|
24
|
+
enabled: z.boolean(),
|
|
25
|
+
url: text(16_384),
|
|
26
|
+
title: text(512),
|
|
27
|
+
loading: z.boolean(),
|
|
28
|
+
canGoBack: z.boolean(),
|
|
29
|
+
canGoForward: z.boolean(),
|
|
30
|
+
})
|
|
31
|
+
.strict();
|
|
32
|
+
export const NativeBrowserCommandSchemaZ = z.discriminatedUnion("action", [
|
|
33
|
+
z.object({ ...identity, action: z.enum(["back", "forward", "reload", "stop"]) }).strict(),
|
|
34
|
+
z
|
|
35
|
+
.object({
|
|
36
|
+
...identity,
|
|
37
|
+
action: z.literal("navigate"),
|
|
38
|
+
text: text(16_384).refine((value) => value.trim().length > 0),
|
|
39
|
+
})
|
|
40
|
+
.strict(),
|
|
41
|
+
]);
|
|
42
|
+
export type NativeBrowserState = z.infer<typeof NativeBrowserStateSchemaZ>;
|
|
43
|
+
export type NativeBrowserCommand = z.infer<typeof NativeBrowserCommandSchemaZ>;
|
|
@@ -151,6 +151,60 @@ export const TmuxServerSessionsResourceSchemaZ = z
|
|
|
151
151
|
})
|
|
152
152
|
.strict();
|
|
153
153
|
|
|
154
|
+
/** Raw (decoded) byte limit for one session favicon on the sessions resource. */
|
|
155
|
+
export const TMUX_SERVER_SESSION_FAVICON_MAX_BYTES = 32_768;
|
|
156
|
+
export const TmuxServerSessionFaviconMimeSchemaZ = z.enum([
|
|
157
|
+
"image/png",
|
|
158
|
+
"image/svg+xml",
|
|
159
|
+
"image/x-icon",
|
|
160
|
+
"image/jpeg",
|
|
161
|
+
"image/webp",
|
|
162
|
+
"image/gif",
|
|
163
|
+
]);
|
|
164
|
+
export type TmuxServerSessionFaviconMime = z.infer<typeof TmuxServerSessionFaviconMimeSchemaZ>;
|
|
165
|
+
function base64DecodedLength(data: string): number {
|
|
166
|
+
const padding = data.endsWith("==") ? 2 : data.endsWith("=") ? 1 : 0;
|
|
167
|
+
return (data.length / 4) * 3 - padding;
|
|
168
|
+
}
|
|
169
|
+
export const TmuxServerSessionFaviconSchemaZ = z
|
|
170
|
+
.object({
|
|
171
|
+
mime: TmuxServerSessionFaviconMimeSchemaZ,
|
|
172
|
+
data: z
|
|
173
|
+
.string()
|
|
174
|
+
.regex(/^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/u)
|
|
175
|
+
.refine(
|
|
176
|
+
(data) => base64DecodedLength(data) <= TMUX_SERVER_SESSION_FAVICON_MAX_BYTES,
|
|
177
|
+
"favicon exceeds 32 KiB",
|
|
178
|
+
),
|
|
179
|
+
})
|
|
180
|
+
.strict();
|
|
181
|
+
export type TmuxServerSessionFavicon = z.infer<typeof TmuxServerSessionFaviconSchemaZ>;
|
|
182
|
+
|
|
183
|
+
/**
|
|
184
|
+
* Version 2 of the sessions resource, served only for `?version=2`. Version 1
|
|
185
|
+
* stays byte-for-byte for clients that parse it with unknown fields denied.
|
|
186
|
+
*/
|
|
187
|
+
export const TmuxServerSessionsResourceV2SchemaZ = z
|
|
188
|
+
.object({
|
|
189
|
+
version: z.literal(2),
|
|
190
|
+
server: TmuxServerScopeSchemaZ,
|
|
191
|
+
sessions: z.array(
|
|
192
|
+
z
|
|
193
|
+
.object({
|
|
194
|
+
liveSessionId: WorkspaceCatalogLiveSessionIdSchemaZ,
|
|
195
|
+
sessionName: z.string().min(1).max(160),
|
|
196
|
+
workspaceName: z.string().min(1).max(160).nullable(),
|
|
197
|
+
paneCount: z.number().int().nonnegative(),
|
|
198
|
+
windowCount: z.number().int().min(1),
|
|
199
|
+
lastActivity: z.number().int().nonnegative().nullable(),
|
|
200
|
+
favicon: TmuxServerSessionFaviconSchemaZ.nullable(),
|
|
201
|
+
})
|
|
202
|
+
.strict(),
|
|
203
|
+
),
|
|
204
|
+
})
|
|
205
|
+
.strict();
|
|
206
|
+
export type TmuxServerSessionsResourceV2 = z.infer<typeof TmuxServerSessionsResourceV2SchemaZ>;
|
|
207
|
+
|
|
154
208
|
/** Selector intent is interpreted only by the daemon on its own host. */
|
|
155
209
|
export const TmuxServerRegistrationRequestSchemaZ = z
|
|
156
210
|
.object({
|
|
@@ -581,15 +581,19 @@ export async function applyTerminalReplicaUpdateCooperatively(
|
|
|
581
581
|
}
|
|
582
582
|
const authenticated = options.authenticatedFrameHash;
|
|
583
583
|
const frameStart = readProfileClock(options.instrumentation);
|
|
584
|
-
const
|
|
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,29 @@ function resolveFrameHash(
|
|
|
984
988
|
return authenticated;
|
|
985
989
|
}
|
|
986
990
|
const started = readProfileClock(options.instrumentation);
|
|
987
|
-
const hash = hashStable(update);
|
|
991
|
+
const hash = hashStable(terminalReplicaFrameIdentity(update));
|
|
988
992
|
addProfileDuration(profile, "updateHash", started, options.instrumentation);
|
|
989
993
|
return hash;
|
|
990
994
|
}
|
|
991
995
|
|
|
996
|
+
/**
|
|
997
|
+
* What a frame hash covers. A seed's rows enter as their row-sequence hashes,
|
|
998
|
+
* which the replica caches per frozen row, so a seed carrying a pane's whole
|
|
999
|
+
* history is not walked cell by cell a second time (#706). Every other field
|
|
1000
|
+
* enters as is. The cooperative and ordinary paths must agree: a redelivered
|
|
1001
|
+
* seed whose frame hash differs is a protocol conflict, not idempotent.
|
|
1002
|
+
*/
|
|
1003
|
+
function terminalReplicaFrameIdentity(update: CanonicalTerminalReplicaUpdate): unknown {
|
|
1004
|
+
if (update.type !== "terminal.seed") return update;
|
|
1005
|
+
const { grid, history, ...rest } = update.snapshot;
|
|
1006
|
+
return [
|
|
1007
|
+
"terminal-replica-frame-v2",
|
|
1008
|
+
{ ...update, snapshot: rest },
|
|
1009
|
+
hashTerminalReplicaRows(grid),
|
|
1010
|
+
hashTerminalReplicaRows(history),
|
|
1011
|
+
];
|
|
1012
|
+
}
|
|
1013
|
+
|
|
992
1014
|
function readProfileClock(
|
|
993
1015
|
instrumentation: TerminalReplicaApplyOptions["instrumentation"] | undefined,
|
|
994
1016
|
): number | undefined {
|
|
@@ -30,7 +30,7 @@ export class DaemonFleetFactsObserver {
|
|
|
30
30
|
#diagnosticActiveOperations = 0;
|
|
31
31
|
constructor(options) {
|
|
32
32
|
this.#options = options;
|
|
33
|
-
this.#intervalMs = options.intervalMs ??
|
|
33
|
+
this.#intervalMs = options.intervalMs ?? FLEET_FACTS_POLL_MS;
|
|
34
34
|
this.#setTimer =
|
|
35
35
|
options.setTimer ??
|
|
36
36
|
((callback, delayMs) => {
|
|
@@ -461,6 +461,13 @@ export function createDefaultFleetFactsReaders() {
|
|
|
461
461
|
};
|
|
462
462
|
return createSharedFleetFactsReaders(execute);
|
|
463
463
|
}
|
|
464
|
+
/**
|
|
465
|
+
* How often subscribed clients' tmux facts (sessions, panes, agent stamps) are
|
|
466
|
+
* read. Hook-stamped agent changes reach clients within one tick, so 500 ms
|
|
467
|
+
* keeps a state flip visible within 1 s (#716). One `list-panes -a` per tick,
|
|
468
|
+
* only while a client is subscribed.
|
|
469
|
+
*/
|
|
470
|
+
export const FLEET_FACTS_POLL_MS = 500;
|
|
464
471
|
export const SESSION_COMPOSITION_TMUX_ARGS = [
|
|
465
472
|
"list-panes",
|
|
466
473
|
"-a",
|
|
@@ -10,13 +10,16 @@ import { streamSSE } from "hono/streaming";
|
|
|
10
10
|
import { projectApplicationShellResource } from "./resources/application-shell.js";
|
|
11
11
|
import { fleetSessionIdForName } from "./resources/fleet-catalog.js";
|
|
12
12
|
import { z } from "zod";
|
|
13
|
-
import { TmuxAgentOpenRequestSchemaZ, TmuxAgentOpenResponseSchemaZ, WorkspaceCatalogLiveSessionIdSchemaZ, WindowLinkTargetSchemaZ, WindowSplitLayoutResourceSchemaZ, TMUX_SERVERS_API_PATH, PANE_STREAM_ISSUE_PATH, TmuxServerIdSchemaZ, TmuxServerScopeSchemaZ, TmuxServersResourceSchemaZ, TmuxServerSessionsResourceSchemaZ, WorkspaceMultiplexerMutationRequestSchemaZ, WorkspacePaneCreateMutationRequestSchemaZ, WorkspaceSessionCreateArgumentsSchemaZ, } from "@tmux-ide/contracts";
|
|
13
|
+
import { TmuxAgentOpenRequestSchemaZ, TmuxAgentOpenResponseSchemaZ, WorkspaceCatalogLiveSessionIdSchemaZ, WindowLinkTargetSchemaZ, WindowSplitLayoutResourceSchemaZ, TMUX_SERVERS_API_PATH, PANE_STREAM_ISSUE_PATH, TmuxServerIdSchemaZ, TmuxServerScopeSchemaZ, TmuxServersResourceSchemaZ, TmuxServerSessionsResourceSchemaZ, TmuxServerSessionsResourceV2SchemaZ, WorkspaceMultiplexerMutationRequestSchemaZ, WorkspacePaneCreateMutationRequestSchemaZ, WorkspaceSessionCreateArgumentsSchemaZ, } from "@tmux-ide/contracts";
|
|
14
14
|
import { TmuxServerRegistrationSchemaZ, TmuxServerScopeError, } from "../lib/tmux-server-owners.js";
|
|
15
|
+
import { createSessionFaviconResolver } from "../lib/session-favicon.js";
|
|
15
16
|
import { WorkspaceMultiplexerError } from "../lib/workspace-multiplexer-verbs.js";
|
|
16
17
|
import { WorkspacePaneCreationError } from "../lib/workspace-pane-creation.js";
|
|
17
18
|
import { ownerBearerMatches } from "./owner-authority.js";
|
|
18
19
|
import { mountPaneStreamIssueRoute } from "./pane-stream-issue.js";
|
|
19
20
|
import { projectTerminalRuntimeInventory } from "./resources/terminal-runtime-inventory.js";
|
|
21
|
+
/** Total raw favicon bytes one version 2 sessions response may carry. */
|
|
22
|
+
export const SESSIONS_RESOURCE_FAVICON_BUDGET_BYTES = 512 * 1024;
|
|
20
23
|
const registrationSchema = TmuxServerRegistrationSchemaZ.omit({ serverId: true }).strict();
|
|
21
24
|
const workspaceSchema = z
|
|
22
25
|
.string()
|
|
@@ -69,6 +72,7 @@ function failure(c, error) {
|
|
|
69
72
|
/** Owner capability is mandatory even when the ordinary local HTTP surface is open. */
|
|
70
73
|
export function mountTmuxServerRoutes(app, options) {
|
|
71
74
|
const base = TMUX_SERVERS_API_PATH;
|
|
75
|
+
const resolveSessionFavicon = options.resolveSessionFavicon ?? createSessionFaviconResolver();
|
|
72
76
|
const authorize = async (c, next) => {
|
|
73
77
|
c.header("Cache-Control", "no-store");
|
|
74
78
|
if (!ownerBearerMatches(c.req.header("Authorization"), options.ownerToken))
|
|
@@ -107,6 +111,12 @@ export function mountTmuxServerRoutes(app, options) {
|
|
|
107
111
|
owners: options.owners,
|
|
108
112
|
readBody: (request) => boundedJson(request, 8 * 1024),
|
|
109
113
|
});
|
|
114
|
+
app.post(`${scoped}/native-browser`, route(async (c) => {
|
|
115
|
+
const server = scope(c);
|
|
116
|
+
const input = await boundedJson(c.req.raw, 24 * 1024);
|
|
117
|
+
const state = await options.owners.withOwner(server, (owner) => owner.nativeBrowser(input, c.req.raw.signal));
|
|
118
|
+
return c.json({ version: 1, state });
|
|
119
|
+
}));
|
|
110
120
|
// Structured read only: this never acquires a channel or changes tmux geometry.
|
|
111
121
|
app.post(`${scoped}/split-layout/:workspaceName`, route(async (c) => {
|
|
112
122
|
const server = scope(c);
|
|
@@ -179,16 +189,48 @@ export function mountTmuxServerRoutes(app, options) {
|
|
|
179
189
|
}));
|
|
180
190
|
app.get(`${scoped}/sessions`, route(async (c) => {
|
|
181
191
|
const server = scope(c);
|
|
182
|
-
|
|
183
|
-
|
|
192
|
+
const v2 = c.req.query("version") === "2";
|
|
193
|
+
const { rows, favicons } = await options.owners.withOwner(server, async (owner) => {
|
|
194
|
+
const sessions = await owner.catalog();
|
|
195
|
+
const workspaces = owner.workspaceRegistry.list();
|
|
196
|
+
const rows = sessions.map((session) => {
|
|
197
|
+
const aliases = workspaces.filter((workspace) => workspace.sessionName === session.sessionName);
|
|
198
|
+
return { session, workspaceName: aliases.length === 1 ? aliases[0].name : null };
|
|
199
|
+
});
|
|
200
|
+
const favicons = v2
|
|
201
|
+
? await Promise.all(sessions.map((session) => resolveSessionFavicon(session.sessionPath).catch(() => null)))
|
|
202
|
+
: [];
|
|
203
|
+
return { rows, favicons };
|
|
204
|
+
});
|
|
205
|
+
if (!v2)
|
|
206
|
+
return c.json(TmuxServerSessionsResourceSchemaZ.parse({
|
|
207
|
+
version: 1,
|
|
208
|
+
server,
|
|
209
|
+
sessions: rows.map(({ session, workspaceName }) => ({
|
|
210
|
+
liveSessionId: session.liveSessionId,
|
|
211
|
+
sessionName: session.sessionName,
|
|
212
|
+
paneCount: session.paneCount,
|
|
213
|
+
workspaceName,
|
|
214
|
+
})),
|
|
215
|
+
}));
|
|
216
|
+
let budget = SESSIONS_RESOURCE_FAVICON_BUDGET_BYTES;
|
|
217
|
+
return c.json(TmuxServerSessionsResourceV2SchemaZ.parse({
|
|
218
|
+
version: 2,
|
|
184
219
|
server,
|
|
185
|
-
sessions:
|
|
186
|
-
const
|
|
187
|
-
const
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
220
|
+
sessions: rows.map(({ session, workspaceName }, index) => {
|
|
221
|
+
const found = favicons[index] ?? null;
|
|
222
|
+
const favicon = found && found.size <= budget ? found : null;
|
|
223
|
+
if (favicon)
|
|
224
|
+
budget -= favicon.size;
|
|
225
|
+
return {
|
|
226
|
+
liveSessionId: session.liveSessionId,
|
|
227
|
+
sessionName: session.sessionName,
|
|
228
|
+
workspaceName,
|
|
229
|
+
paneCount: session.paneCount,
|
|
230
|
+
windowCount: session.windowCount,
|
|
231
|
+
lastActivity: session.lastActivity,
|
|
232
|
+
favicon: favicon ? { mime: favicon.mime, data: favicon.data } : null,
|
|
233
|
+
};
|
|
192
234
|
}),
|
|
193
235
|
}));
|
|
194
236
|
}));
|
|
@@ -6,6 +6,7 @@ var __rewriteRelativeImportExtension = (this && this.__rewriteRelativeImportExte
|
|
|
6
6
|
}
|
|
7
7
|
return path;
|
|
8
8
|
};
|
|
9
|
+
import { createNativeBrowserResolver } from "./tmux-native-browser.js";
|
|
9
10
|
import { createNativeSurfaceContextResolver } from "./native-surface-context.js";
|
|
10
11
|
import { NativeSurfaceProviderRegistry } from "../terminal/graphics/native-surface-provider.js";
|
|
11
12
|
import { TmuxServerVersionSchemaZ } from "@tmux-ide/contracts";
|
|
@@ -1397,6 +1398,20 @@ async function startEmbeddedDaemonGeneration(opts) {
|
|
|
1397
1398
|
throw new Error("Default tmux editor context owner is unavailable");
|
|
1398
1399
|
},
|
|
1399
1400
|
}),
|
|
1401
|
+
nativeBrowser: createNativeBrowserResolver({
|
|
1402
|
+
generation: instanceId,
|
|
1403
|
+
run: fleetFactsTmuxRunner,
|
|
1404
|
+
registry: workspaceRegistry,
|
|
1405
|
+
catalog: createNativeTmuxServerCatalog(workspaceRegistry, fleetFactsTmuxRunner),
|
|
1406
|
+
inventory: terminalInventoryRuntime,
|
|
1407
|
+
assertOpen: () => {
|
|
1408
|
+
if (defaultEditorContextRetired ||
|
|
1409
|
+
serverOwners?.defaultRetired ||
|
|
1410
|
+
!initialNativeServerIdentity ||
|
|
1411
|
+
terminalInventoryRuntime?.lifecycleState() !== "ready")
|
|
1412
|
+
throw new Error("Default native browser owner is unavailable");
|
|
1413
|
+
},
|
|
1414
|
+
}),
|
|
1400
1415
|
catalog: createNativeTmuxServerCatalog(workspaceRegistry, fleetFactsTmuxRunner),
|
|
1401
1416
|
readWindowSplitLayout: async (workspaceName, input) => {
|
|
1402
1417
|
const target = WindowLinkTargetSchemaZ.parse(input);
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import { lstat } from "node:fs/promises";
|
|
3
|
+
import { isAbsolute } from "node:path";
|
|
4
|
+
import { connect } from "node:net";
|
|
5
|
+
import { NativeBrowserCommandSchemaZ, NativeBrowserStateSchemaZ, } from "@tmux-ide/contracts";
|
|
6
|
+
import { z } from "zod";
|
|
7
|
+
export const NativeBrowserEndpointSchema = z
|
|
8
|
+
.object({
|
|
9
|
+
version: z.literal(1),
|
|
10
|
+
pid: z.number().int().positive(),
|
|
11
|
+
instance: z.uuid(),
|
|
12
|
+
pane: z
|
|
13
|
+
.string()
|
|
14
|
+
.regex(/^%[0-9]+$/u)
|
|
15
|
+
.max(32),
|
|
16
|
+
socket: z.string().min(1).max(1024).refine(isAbsolute),
|
|
17
|
+
})
|
|
18
|
+
.strict();
|
|
19
|
+
export class NativeBrowserUnavailable extends Error {
|
|
20
|
+
constructor() {
|
|
21
|
+
super("Native browser controls are unavailable");
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
const responseSchema = z.discriminatedUnion("ok", [
|
|
25
|
+
z
|
|
26
|
+
.object({ id: z.string(), ok: z.literal(true), data: NativeBrowserStateSchemaZ.nullable() })
|
|
27
|
+
.strict(),
|
|
28
|
+
z.object({ id: z.string(), ok: z.literal(false), error: z.string().max(4096) }).strict(),
|
|
29
|
+
]);
|
|
30
|
+
/** One request, one bounded response. Mutations are never retried or refreshed. */
|
|
31
|
+
export async function nativeBrowserRpc(endpoint, command, signal) {
|
|
32
|
+
const target = NativeBrowserEndpointSchema.parse(endpoint);
|
|
33
|
+
const payload = command ? NativeBrowserCommandSchemaZ.parse(command) : null;
|
|
34
|
+
if (payload && (payload.instance !== target.instance || payload.pane !== target.pane))
|
|
35
|
+
throw new NativeBrowserUnavailable();
|
|
36
|
+
signal.throwIfAborted();
|
|
37
|
+
const info = await lstat(target.socket);
|
|
38
|
+
if (!info.isSocket() || info.uid !== process.getuid?.() || (info.mode & 0o022) !== 0)
|
|
39
|
+
throw new NativeBrowserUnavailable();
|
|
40
|
+
try {
|
|
41
|
+
process.kill(target.pid, 0);
|
|
42
|
+
}
|
|
43
|
+
catch {
|
|
44
|
+
throw new NativeBrowserUnavailable();
|
|
45
|
+
}
|
|
46
|
+
signal.throwIfAborted();
|
|
47
|
+
const id = randomUUID();
|
|
48
|
+
const request = JSON.stringify({
|
|
49
|
+
id,
|
|
50
|
+
cmd: payload ? "native/1/command" : "native/1/state",
|
|
51
|
+
...(payload ? { payload } : {}),
|
|
52
|
+
}) + "\n";
|
|
53
|
+
return new Promise((resolve, reject) => {
|
|
54
|
+
const socket = connect(target.socket);
|
|
55
|
+
let finished = false;
|
|
56
|
+
let size = 0;
|
|
57
|
+
const chunks = [];
|
|
58
|
+
const finish = (error, state = null) => {
|
|
59
|
+
if (finished)
|
|
60
|
+
return;
|
|
61
|
+
finished = true;
|
|
62
|
+
clearTimeout(timer);
|
|
63
|
+
signal.removeEventListener("abort", abort);
|
|
64
|
+
socket.destroy();
|
|
65
|
+
if (error)
|
|
66
|
+
reject(error);
|
|
67
|
+
else
|
|
68
|
+
resolve(state);
|
|
69
|
+
};
|
|
70
|
+
const abort = () => finish(new NativeBrowserUnavailable());
|
|
71
|
+
const timer = setTimeout(abort, 1500);
|
|
72
|
+
signal.addEventListener("abort", abort, { once: true });
|
|
73
|
+
if (signal.aborted) {
|
|
74
|
+
abort();
|
|
75
|
+
return;
|
|
76
|
+
}
|
|
77
|
+
socket.once("connect", () => socket.write(request));
|
|
78
|
+
socket.on("error", abort);
|
|
79
|
+
socket.once("end", () => {
|
|
80
|
+
if (!finished)
|
|
81
|
+
abort();
|
|
82
|
+
});
|
|
83
|
+
socket.on("data", (chunk) => {
|
|
84
|
+
size += chunk.length;
|
|
85
|
+
if (size > 128 * 1024) {
|
|
86
|
+
abort();
|
|
87
|
+
return;
|
|
88
|
+
}
|
|
89
|
+
chunks.push(chunk);
|
|
90
|
+
if (!chunk.includes(10))
|
|
91
|
+
return;
|
|
92
|
+
try {
|
|
93
|
+
const bytes = Buffer.concat(chunks);
|
|
94
|
+
const newline = bytes.indexOf(10);
|
|
95
|
+
if (newline !== bytes.length - 1)
|
|
96
|
+
throw new NativeBrowserUnavailable();
|
|
97
|
+
const reply = responseSchema.parse(JSON.parse(new TextDecoder("utf-8", { fatal: true }).decode(bytes.subarray(0, newline))));
|
|
98
|
+
if (reply.id !== id || !reply.ok)
|
|
99
|
+
throw new NativeBrowserUnavailable();
|
|
100
|
+
if (reply.data &&
|
|
101
|
+
(reply.data.instance !== target.instance || reply.data.pane !== target.pane))
|
|
102
|
+
throw new NativeBrowserUnavailable();
|
|
103
|
+
finish(undefined, reply.data);
|
|
104
|
+
}
|
|
105
|
+
catch {
|
|
106
|
+
abort();
|
|
107
|
+
}
|
|
108
|
+
});
|
|
109
|
+
});
|
|
110
|
+
}
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
import { constants } from "node:fs";
|
|
2
|
+
import { lstat, open } from "node:fs/promises";
|
|
3
|
+
import { extname, isAbsolute, join } from "node:path";
|
|
4
|
+
import { TMUX_SERVER_SESSION_FAVICON_MAX_BYTES, } from "@tmux-ide/contracts";
|
|
5
|
+
/** Project-relative favicon locations, first match wins. */
|
|
6
|
+
export const SESSION_FAVICON_CANDIDATES = [
|
|
7
|
+
"favicon.svg",
|
|
8
|
+
"favicon.png",
|
|
9
|
+
"favicon.ico",
|
|
10
|
+
"public/favicon.svg",
|
|
11
|
+
"public/favicon.png",
|
|
12
|
+
"public/favicon.ico",
|
|
13
|
+
"app/icon.svg",
|
|
14
|
+
"app/icon.png",
|
|
15
|
+
"app/favicon.ico",
|
|
16
|
+
"src/app/icon.svg",
|
|
17
|
+
"src/app/icon.png",
|
|
18
|
+
"src/app/favicon.ico",
|
|
19
|
+
"static/favicon.svg",
|
|
20
|
+
"static/favicon.png",
|
|
21
|
+
"static/favicon.ico",
|
|
22
|
+
"assets/favicon.svg",
|
|
23
|
+
"assets/favicon.png",
|
|
24
|
+
"assets/favicon.ico",
|
|
25
|
+
"apple-touch-icon.png",
|
|
26
|
+
"public/apple-touch-icon.png",
|
|
27
|
+
"icon.svg",
|
|
28
|
+
"icon.png",
|
|
29
|
+
];
|
|
30
|
+
const MIME_BY_EXTENSION = {
|
|
31
|
+
".png": "image/png",
|
|
32
|
+
".svg": "image/svg+xml",
|
|
33
|
+
".ico": "image/x-icon",
|
|
34
|
+
".jpg": "image/jpeg",
|
|
35
|
+
".jpeg": "image/jpeg",
|
|
36
|
+
".webp": "image/webp",
|
|
37
|
+
".gif": "image/gif",
|
|
38
|
+
};
|
|
39
|
+
export const defaultSessionFaviconIo = {
|
|
40
|
+
lstat: (path) => lstat(path),
|
|
41
|
+
readNoFollow: async (path, maxBytes) => {
|
|
42
|
+
let handle;
|
|
43
|
+
try {
|
|
44
|
+
handle = await open(path, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK);
|
|
45
|
+
}
|
|
46
|
+
catch {
|
|
47
|
+
return null;
|
|
48
|
+
}
|
|
49
|
+
try {
|
|
50
|
+
const stats = await handle.stat();
|
|
51
|
+
if (!stats.isFile() || stats.size > maxBytes)
|
|
52
|
+
return null;
|
|
53
|
+
const bytes = await handle.readFile();
|
|
54
|
+
return bytes.byteLength > maxBytes ? null : bytes;
|
|
55
|
+
}
|
|
56
|
+
catch {
|
|
57
|
+
return null;
|
|
58
|
+
}
|
|
59
|
+
finally {
|
|
60
|
+
await handle.close().catch(() => undefined);
|
|
61
|
+
}
|
|
62
|
+
},
|
|
63
|
+
};
|
|
64
|
+
async function lstatOrNull(io, path) {
|
|
65
|
+
try {
|
|
66
|
+
return await io.lstat(path);
|
|
67
|
+
}
|
|
68
|
+
catch {
|
|
69
|
+
return null;
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
/**
|
|
73
|
+
* Finds a session's project favicon from its working directory. Only regular
|
|
74
|
+
* files and real directories inside the session path are considered; symlinks
|
|
75
|
+
* are never followed. Hits are revalidated by mtime and size, misses are
|
|
76
|
+
* remembered briefly, and the cache is bounded (least recently used first).
|
|
77
|
+
*/
|
|
78
|
+
export function createSessionFaviconResolver(options = {}) {
|
|
79
|
+
const io = options.io ?? defaultSessionFaviconIo;
|
|
80
|
+
const now = options.now ?? Date.now;
|
|
81
|
+
const missTtlMs = options.missTtlMs ?? 30_000;
|
|
82
|
+
const maxEntries = options.maxEntries ?? 256;
|
|
83
|
+
const cache = new Map();
|
|
84
|
+
const remember = (sessionPath, entry) => {
|
|
85
|
+
cache.delete(sessionPath);
|
|
86
|
+
cache.set(sessionPath, entry);
|
|
87
|
+
while (cache.size > maxEntries)
|
|
88
|
+
cache.delete(cache.keys().next().value);
|
|
89
|
+
};
|
|
90
|
+
const scan = async (sessionPath) => {
|
|
91
|
+
const directories = new Map();
|
|
92
|
+
for (const candidate of SESSION_FAVICON_CANDIDATES) {
|
|
93
|
+
const mime = MIME_BY_EXTENSION[extname(candidate)];
|
|
94
|
+
if (!mime)
|
|
95
|
+
continue;
|
|
96
|
+
const slash = candidate.lastIndexOf("/");
|
|
97
|
+
if (slash >= 0) {
|
|
98
|
+
const parts = candidate.slice(0, slash).split("/");
|
|
99
|
+
let real = true;
|
|
100
|
+
for (let index = 1; index <= parts.length && real; index += 1) {
|
|
101
|
+
const directory = parts.slice(0, index).join("/");
|
|
102
|
+
let known = directories.get(directory);
|
|
103
|
+
if (known === undefined) {
|
|
104
|
+
const stats = await lstatOrNull(io, join(sessionPath, directory));
|
|
105
|
+
known = stats !== null && stats.isDirectory() && !stats.isSymbolicLink();
|
|
106
|
+
directories.set(directory, known);
|
|
107
|
+
}
|
|
108
|
+
real = known;
|
|
109
|
+
}
|
|
110
|
+
if (!real)
|
|
111
|
+
continue;
|
|
112
|
+
}
|
|
113
|
+
const file = join(sessionPath, candidate);
|
|
114
|
+
const stats = await lstatOrNull(io, file);
|
|
115
|
+
if (!stats || !stats.isFile() || stats.size > TMUX_SERVER_SESSION_FAVICON_MAX_BYTES)
|
|
116
|
+
continue;
|
|
117
|
+
const bytes = await io.readNoFollow(file, TMUX_SERVER_SESSION_FAVICON_MAX_BYTES);
|
|
118
|
+
if (!bytes)
|
|
119
|
+
continue;
|
|
120
|
+
return {
|
|
121
|
+
kind: "hit",
|
|
122
|
+
file,
|
|
123
|
+
mtimeMs: stats.mtimeMs,
|
|
124
|
+
size: stats.size,
|
|
125
|
+
favicon: { mime, data: bytes.toString("base64"), size: bytes.byteLength },
|
|
126
|
+
};
|
|
127
|
+
}
|
|
128
|
+
return { kind: "miss", at: now() };
|
|
129
|
+
};
|
|
130
|
+
return async (sessionPath) => {
|
|
131
|
+
if (!sessionPath || !isAbsolute(sessionPath) || sessionPath.includes("\0"))
|
|
132
|
+
return null;
|
|
133
|
+
const cached = cache.get(sessionPath);
|
|
134
|
+
if (cached?.kind === "miss" && now() - cached.at < missTtlMs) {
|
|
135
|
+
remember(sessionPath, cached);
|
|
136
|
+
return null;
|
|
137
|
+
}
|
|
138
|
+
if (cached?.kind === "hit") {
|
|
139
|
+
const stats = await lstatOrNull(io, cached.file);
|
|
140
|
+
if (stats &&
|
|
141
|
+
stats.isFile() &&
|
|
142
|
+
stats.mtimeMs === cached.mtimeMs &&
|
|
143
|
+
stats.size === cached.size) {
|
|
144
|
+
remember(sessionPath, cached);
|
|
145
|
+
return cached.favicon;
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
const entry = await scan(sessionPath);
|
|
149
|
+
remember(sessionPath, entry);
|
|
150
|
+
return entry.kind === "hit" ? entry.favicon : null;
|
|
151
|
+
};
|
|
152
|
+
}
|
|
@@ -13,8 +13,8 @@ import { resolveRuntimeNamespace } from "./runtime-namespace.js";
|
|
|
13
13
|
*
|
|
14
14
|
* The dir is FULLY MANAGED: overwriting our own `~/.claude/skills/tmux-ide` is
|
|
15
15
|
* correct, and this NEVER touches anything outside it (user-authored skills in
|
|
16
|
-
* sibling dirs are safe). The version marker is
|
|
17
|
-
*
|
|
16
|
+
* sibling dirs are safe). The version marker is an HTML comment under
|
|
17
|
+
* SKILL.md's frontmatter ({@link VERSION_MARKER_RE}); {@link parseSkillVersion} reads it and
|
|
18
18
|
* {@link rewriteVersionMarker} substitutes it at copy time.
|
|
19
19
|
*
|
|
20
20
|
* The parse/render helpers are PURE and tested against fixtures; {@link syncSkill}
|
|
@@ -66,7 +66,7 @@ export function defaultSkillSource() {
|
|
|
66
66
|
// Pure — version marker
|
|
67
67
|
// ---------------------------------------------------------------------------
|
|
68
68
|
/**
|
|
69
|
-
* The version marker line (an HTML comment
|
|
69
|
+
* The version marker line (an HTML comment under SKILL.md's frontmatter). The sync
|
|
70
70
|
* machinery reads it to know which version an installed copy is, and rewrites it
|
|
71
71
|
* to the installed package version at copy time.
|
|
72
72
|
*/
|