tmux-ide 2.9.15 → 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 +460 -142
- package/package.json +2 -1
- 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 +46 -10
- 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/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 +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/tmux-servers.ts +55 -11
- 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/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.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",
|
|
@@ -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))
|
|
@@ -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,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
|
*/
|
|
@@ -69,7 +69,8 @@ export function createNativeTmuxServerCatalog(workspaceRegistry, run, assertOpen
|
|
|
69
69
|
"list-panes",
|
|
70
70
|
"-a",
|
|
71
71
|
"-F",
|
|
72
|
-
|
|
72
|
+
// session_path stays last: tmux rejects tabs in session names but not in paths.
|
|
73
|
+
"#{pid}\t#{session_id}\t#{session_created}\t#{session_name}\t#{pane_id}\t#{window_id}\t#{session_activity}\t#{session_path}",
|
|
73
74
|
]
|
|
74
75
|
.map(shellEscape)
|
|
75
76
|
.join(" "),
|
|
@@ -80,7 +81,8 @@ export function createNativeTmuxServerCatalog(workspaceRegistry, run, assertOpen
|
|
|
80
81
|
// Command errors, unsupported formats and unwitnessed blank output stay failures.
|
|
81
82
|
const lines = raw.trim() === "tmux-ide-empty-catalog-v1" ? [] : raw.trim().split("\n");
|
|
82
83
|
for (const line of lines) {
|
|
83
|
-
const [pid, id, created, name, pane,
|
|
84
|
+
const [pid, id, created, name, pane, window, activity, ...pathParts] = line.split("\t");
|
|
85
|
+
const dir = pathParts.join("\t");
|
|
84
86
|
if (!pid ||
|
|
85
87
|
!/^\d+$/.test(pid) ||
|
|
86
88
|
!id ||
|
|
@@ -90,6 +92,8 @@ export function createNativeTmuxServerCatalog(workspaceRegistry, run, assertOpen
|
|
|
90
92
|
!name ||
|
|
91
93
|
!pane ||
|
|
92
94
|
!/^%\d+$/.test(pane) ||
|
|
95
|
+
!window ||
|
|
96
|
+
!/^@\d+$/.test(window) ||
|
|
93
97
|
!dir)
|
|
94
98
|
throw new Error("Invalid native server catalog");
|
|
95
99
|
if (!isVisibleFleetSession(name))
|
|
@@ -98,9 +102,12 @@ export function createNativeTmuxServerCatalog(workspaceRegistry, run, assertOpen
|
|
|
98
102
|
const row = rows.get(liveSessionId) ?? {
|
|
99
103
|
summary: { liveSessionId, sessionName: name, paneCount: 0 },
|
|
100
104
|
panes: new Set(),
|
|
105
|
+
windows: new Set(),
|
|
101
106
|
dir,
|
|
107
|
+
activity: activity && /^\d+$/.test(activity) ? Number(activity) : null,
|
|
102
108
|
};
|
|
103
109
|
row.panes.add(pane);
|
|
110
|
+
row.windows.add(window);
|
|
104
111
|
rows.set(liveSessionId, row);
|
|
105
112
|
}
|
|
106
113
|
const names = new Set([...rows.values()].map(({ summary }) => summary.sessionName));
|
|
@@ -115,7 +122,13 @@ export function createNativeTmuxServerCatalog(workspaceRegistry, run, assertOpen
|
|
|
115
122
|
projectDir: dir,
|
|
116
123
|
persistence: "volatile",
|
|
117
124
|
});
|
|
118
|
-
return [...rows.values()].map(({ summary, panes }) => ({
|
|
125
|
+
return [...rows.values()].map(({ summary, panes, windows, dir, activity }) => ({
|
|
126
|
+
...summary,
|
|
127
|
+
paneCount: panes.size,
|
|
128
|
+
windowCount: windows.size,
|
|
129
|
+
lastActivity: activity,
|
|
130
|
+
sessionPath: dir,
|
|
131
|
+
}));
|
|
119
132
|
};
|
|
120
133
|
}
|
|
121
134
|
/** One proven server incarnation. Never elects an endpoint or changes ambient tmux authority. */
|
|
@@ -21,9 +21,9 @@
|
|
|
21
21
|
<key>CFBundlePackageType</key>
|
|
22
22
|
<string>APPL</string>
|
|
23
23
|
<key>CFBundleShortVersionString</key>
|
|
24
|
-
<string>2.9.
|
|
24
|
+
<string>2.9.16</string>
|
|
25
25
|
<key>CFBundleVersion</key>
|
|
26
|
-
<string>2.9.
|
|
26
|
+
<string>2.9.16</string>
|
|
27
27
|
<key>LSMinimumSystemVersion</key>
|
|
28
28
|
<string>11.0</string>
|
|
29
29
|
<key>LSUIElement</key>
|
|
Binary file
|
|
Binary file
|
package/packages/daemon/dist/native/TmuxIdeNotifier.app/Contents/_CodeSignature/CodeResources
CHANGED
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
</data>
|
|
11
11
|
<key>Resources/Assets.car</key>
|
|
12
12
|
<data>
|
|
13
|
-
|
|
13
|
+
80sBn8yzZzR4ZHE3L4qFvRPqA+A=
|
|
14
14
|
</data>
|
|
15
15
|
</dict>
|
|
16
16
|
<key>files2</key>
|
|
@@ -26,7 +26,7 @@
|
|
|
26
26
|
<dict>
|
|
27
27
|
<key>hash2</key>
|
|
28
28
|
<data>
|
|
29
|
-
|
|
29
|
+
PKQV15gCKuH8bIwdxGZgHEhWb0Rwq62t/R9Ozfnv/BA=
|
|
30
30
|
</data>
|
|
31
31
|
</dict>
|
|
32
32
|
</dict>
|
|
@@ -76,6 +76,42 @@ export function validateBrowserOpen(url, options) {
|
|
|
76
76
|
exitCode: 2,
|
|
77
77
|
});
|
|
78
78
|
}
|
|
79
|
+
/** Printed when the opened browser never publishes tmux-ide's native controls. */
|
|
80
|
+
export const NO_NATIVE_CONTROLS_NOTICE = "This browser has no tmux-ide controls; install the native app for back/forward/reload.";
|
|
81
|
+
/** A browser bundled in a native app publishes its controls; a standalone one may not. */
|
|
82
|
+
function isBundledBrowser(executable) {
|
|
83
|
+
return /\/[^/]+\.app\/Contents\/Resources\/browser\//u.test(executable);
|
|
84
|
+
}
|
|
85
|
+
/** How long a standalone browser has to publish its controls. */
|
|
86
|
+
const STANDALONE_WAIT_MS = 2000;
|
|
87
|
+
/**
|
|
88
|
+
* Watches the new pane for @tmux_ide_browser_control, which only a browser with
|
|
89
|
+
* native controls sets. A browser bundled in an app is never reported: if it is
|
|
90
|
+
* slow, its controls appear when it is ready. A standalone browser gets 2 s of
|
|
91
|
+
* wall-clock time, however slow tmux answers, and then the notice. Returns
|
|
92
|
+
* null once the option appears or when the pane is already gone.
|
|
93
|
+
*/
|
|
94
|
+
export async function nativeControlsNotice(run, pane, executable, wait = {}) {
|
|
95
|
+
if (isBundledBrowser(executable))
|
|
96
|
+
return null;
|
|
97
|
+
const sleep = wait.sleep ?? ((ms) => new Promise((r) => setTimeout(r, ms)));
|
|
98
|
+
const now = wait.now ?? Date.now;
|
|
99
|
+
const deadline = now() + STANDALONE_WAIT_MS;
|
|
100
|
+
for (;;) {
|
|
101
|
+
let control;
|
|
102
|
+
try {
|
|
103
|
+
control = run(["display-message", "-p", "-t", pane, "#{@tmux_ide_browser_control}"]).trim();
|
|
104
|
+
}
|
|
105
|
+
catch {
|
|
106
|
+
return null;
|
|
107
|
+
}
|
|
108
|
+
if (control)
|
|
109
|
+
return null;
|
|
110
|
+
if (now() >= deadline)
|
|
111
|
+
return NO_NATIVE_CONTROLS_NOTICE;
|
|
112
|
+
await sleep(250);
|
|
113
|
+
}
|
|
114
|
+
}
|
|
79
115
|
export function createBrowserPane(run, executable, url, options, environment, cwd, namespace) {
|
|
80
116
|
validateBrowserOpen(url, options);
|
|
81
117
|
const inheritedPane = /^%\d+$/u.test(environment.TMUX_PANE ?? "")
|
|
@@ -163,9 +199,16 @@ export function createBrowserPane(run, executable, url, options, environment, cw
|
|
|
163
199
|
});
|
|
164
200
|
return { pane: pane, session: createdSession, window: createdWindow, status: "created" };
|
|
165
201
|
}
|
|
166
|
-
export function openBrowser(url, options = {}) {
|
|
202
|
+
export async function openBrowser(url, options = {}, dependencies = {}) {
|
|
167
203
|
validateBrowserOpen(url, options);
|
|
168
|
-
const executable = resolveTerminalBrowser();
|
|
204
|
+
const executable = (dependencies.executable ?? resolveTerminalBrowser)();
|
|
205
|
+
const { result, run } = (dependencies.create ?? createPinnedBrowserPane)(executable, url, options);
|
|
206
|
+
const message = await nativeControlsNotice(run, result.pane, executable, dependencies.wait);
|
|
207
|
+
if (message)
|
|
208
|
+
(dependencies.notice ?? ((line) => process.stderr.write(`${line}\n`)))(message);
|
|
209
|
+
return result;
|
|
210
|
+
}
|
|
211
|
+
function createPinnedBrowserPane(executable, url, options) {
|
|
169
212
|
try {
|
|
170
213
|
const namespace = { ...runtimeNamespaceEnvironment(resolveRuntimeNamespace()) };
|
|
171
214
|
const authority = resolveWorkspacePaneTmuxAuthority();
|
|
@@ -173,7 +216,8 @@ export function openBrowser(url, options = {}) {
|
|
|
173
216
|
const run = createPinnedWorkspaceTmuxRunner(authority, {
|
|
174
217
|
timeoutMs: 10_000,
|
|
175
218
|
});
|
|
176
|
-
|
|
219
|
+
const result = createBrowserPane(run, executable, url, options, process.env, process.cwd(), namespace);
|
|
220
|
+
return { result, run };
|
|
177
221
|
}
|
|
178
222
|
catch (error) {
|
|
179
223
|
if (error instanceof IdeError)
|
|
@@ -6,6 +6,7 @@ import { PANE_STREAM_INPUT_LIFETIME_CAPABILITY_VERSION, PANE_STREAM_INPUT_BYTES_
|
|
|
6
6
|
import { inflateSync } from "node:zlib";
|
|
7
7
|
import { PaneStreamLayoutSnapshotFrameSchemaZ } from "@tmux-ide/contracts";
|
|
8
8
|
import { z } from "zod";
|
|
9
|
+
import { logger } from "../../lib/log.js";
|
|
9
10
|
import { PANE_STREAM_MAX_HELD_DELTAS, PANE_STREAM_MAX_OUTPUT_BYTES, PANE_STREAM_MAX_PANES, PANE_STREAM_MAX_SEED_BYTES, PANE_STREAM_PROTOCOL_VERSION, PANE_STREAM_REDEEM_PATH, PANE_STREAM_WEBSOCKET_SUBPROTOCOL, TERMINAL_GRAPHICS_BLOB_CHUNK_BYTES, TERMINAL_DELIVERY_BINARY_CAPABILITY_VERSION, TERMINAL_DELIVERY_BINARY_LOCAL_CAPABILITY_VERSION, TERMINAL_GRAPHICS_BINARY_CAPABILITY_VERSION, TERMINAL_GRAPHICS_LOCAL_CAPABILITY_VERSION, encodeTerminalGraphicsBinary, encodeTerminalDeliveryBinaryChunk, TERMINAL_GRAPHICS_CELL_PIXELS_CAPABILITY_VERSION, TERMINAL_GRAPHICS_PRECISE_PIXELS_CAPABILITY_VERSION, TERMINAL_GRAPHICS_LEGACY_MAX_IMAGE_BYTES, TERMINAL_GRAPHICS_ZLIB_CAPABILITY_VERSION, PaneStreamClientFrameSchemaZ, PaneStreamLoopbackWebSocketUrlSchemaZ, PaneStreamRedeemFrameSchemaZ, PaneStreamServerFrameSchemaZ, sharedMonotonicMicros, } from "@tmux-ide/contracts";
|
|
10
11
|
import { canonicalOriginOrNull, digestSecret, digestsEqual, rawDataByteLength, rawDataToBuffer, safeCloseSocket, strictJsonParse, } from "../attachments/admission-util.js";
|
|
11
12
|
import { SessionRuntimeControllerLeaseError } from "../session-runtime/registry.js";
|
|
@@ -547,6 +548,11 @@ class PreAuthAdmission {
|
|
|
547
548
|
this.#open = false;
|
|
548
549
|
this.#cancelDeadline();
|
|
549
550
|
const socket = this.#socket;
|
|
551
|
+
logger.info("pane-stream", "admission closed", {
|
|
552
|
+
code,
|
|
553
|
+
reason,
|
|
554
|
+
hostClientId: this.hostClientId,
|
|
555
|
+
});
|
|
550
556
|
this.#detach();
|
|
551
557
|
this.#onRelease(this);
|
|
552
558
|
if (socket)
|
|
@@ -825,6 +831,12 @@ export class PaneStreamLiveConnection {
|
|
|
825
831
|
if (this.#closed)
|
|
826
832
|
return;
|
|
827
833
|
this.#recordDiagnosticLifecycle("pane-stream-terminal", code, reason);
|
|
834
|
+
logger.info("pane-stream", "stream closed", {
|
|
835
|
+
code,
|
|
836
|
+
reason,
|
|
837
|
+
session: this.#descriptor.sessionName,
|
|
838
|
+
clientId: this.#clientId,
|
|
839
|
+
});
|
|
828
840
|
this.#closed = true;
|
|
829
841
|
for (const demand of this.#surfaceDemands.values())
|
|
830
842
|
demand.close();
|