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
|
@@ -2,6 +2,7 @@ import { prepareProjection } from "./terminal-projection-delivery.js";
|
|
|
2
2
|
import { TerminalProjectionEnvelopeSchemaZ, TerminalProjectionReceiptSchemaZ, TerminalProjectionMaterializeSchemaZ, } from "@tmux-ide/contracts";
|
|
3
3
|
import { encodeTerminalProjectionCooperatively } from "@tmux-ide/core";
|
|
4
4
|
import { StockCaptureTabUnavailableError } from "./stock-capture-fidelity.js";
|
|
5
|
+
import { logger } from "../../lib/log.js";
|
|
5
6
|
import { MAX_RETAINED_NATIVE_BACKING_BYTES, takeNativeSeedBacking, } from "./native-seed-backing.js";
|
|
6
7
|
import { WorkspaceIdSchemaZ, TERMINAL_GRAPHICS_CAPABILITY_VERSION, TERMINAL_DELIVERY_PATCH_TO_SEED_BYTES, TERMINAL_DELIVERY_MAX_REPRESENTATION_BYTES, TerminalDeliveryEnvelopeSchemaZ, TerminalDeliveryAckSchemaZ, TerminalDeliveryNackSchemaZ, TerminalDeliveryOfferSchemaZ, TerminalDeliveryVisibilitySchemaZ, } from "@tmux-ide/contracts";
|
|
7
8
|
import { TerminalDeliveryStateTooLargeError, terminalReplicaRowsEqual, TERMINAL_COMPACT_COOPERATIVE_SEED_CELLS, applyTerminalReplicaUpdate, applyTerminalReplicaUpdateCooperatively, terminalReplicaUpdateNeedsCooperativeReduction, encodeBinarySemanticTerminalUpdate, encodeBinarySemanticTerminalUpdateCooperatively, encodeCompactSemanticTerminalUpdate, encodeCompactSemanticTerminalUpdateCooperatively, terminalSemanticUpdateNeedsCooperativeEncoding, encodeAnsiTerminalRepresentation, encodeSemanticTerminalUpdate, hashTerminalDeliveryRepresentation, negotiateTerminalDelivery, preaccountSemanticTerminalUpdateBytes, } from "@tmux-ide/core";
|
|
@@ -196,6 +197,7 @@ export class SessionRuntimeTerminalDeliveryHub {
|
|
|
196
197
|
if (!(error instanceof StockCaptureTabUnavailableError))
|
|
197
198
|
throw error;
|
|
198
199
|
// Negotiation still settles this pane; no invented canonical seed is published.
|
|
200
|
+
logDeliveryFault({ paneId: semanticPaneId, deliveryWorkspaceName: address }, "source-closed", "capture-unavailable");
|
|
199
201
|
await accept({
|
|
200
202
|
type: "terminal.delivery.fault",
|
|
201
203
|
reason: "source-closed",
|
|
@@ -350,6 +352,7 @@ export class SessionRuntimeTerminalDeliveryHub {
|
|
|
350
352
|
this.#cancelEncoding(client);
|
|
351
353
|
client.outgoing.length = 0;
|
|
352
354
|
client.inFlight = null;
|
|
355
|
+
logDeliveryFault(client, "source-closed", "source-restarted");
|
|
353
356
|
this.#enqueue(client, {
|
|
354
357
|
type: "terminal.delivery.fault",
|
|
355
358
|
reason: "source-closed",
|
|
@@ -1442,6 +1445,7 @@ export class SessionRuntimeTerminalDeliveryHub {
|
|
|
1442
1445
|
this.#recordDeliveryStatus(client, this.#panes.get(client.paneId));
|
|
1443
1446
|
}
|
|
1444
1447
|
#fault(client, reason, message, selectionObservation = null) {
|
|
1448
|
+
logDeliveryFault(client, reason);
|
|
1445
1449
|
if (this.#observability.enabled)
|
|
1446
1450
|
try {
|
|
1447
1451
|
const atMicros = this.#observability.nowMicros();
|
|
@@ -2038,3 +2042,15 @@ function joinBytes(chunks) {
|
|
|
2038
2042
|
}
|
|
2039
2043
|
return joined;
|
|
2040
2044
|
}
|
|
2045
|
+
/**
|
|
2046
|
+
* One info line per fault a client is sent, so a client's drop has a daemon-side
|
|
2047
|
+
* record. Ids and fixed reasons only: the fault message can carry source text.
|
|
2048
|
+
*/
|
|
2049
|
+
function logDeliveryFault(client, reason, cause) {
|
|
2050
|
+
logger.info("terminal-delivery", "fault sent to client", {
|
|
2051
|
+
reason,
|
|
2052
|
+
...(cause ? { cause } : {}),
|
|
2053
|
+
pane: client.paneId,
|
|
2054
|
+
workspace: client.deliveryWorkspaceName,
|
|
2055
|
+
});
|
|
2056
|
+
}
|
|
@@ -90,7 +90,7 @@ export class DaemonFleetFactsObserver {
|
|
|
90
90
|
|
|
91
91
|
constructor(options: DaemonFleetFactsObserverOptions) {
|
|
92
92
|
this.#options = options;
|
|
93
|
-
this.#intervalMs = options.intervalMs ??
|
|
93
|
+
this.#intervalMs = options.intervalMs ?? FLEET_FACTS_POLL_MS;
|
|
94
94
|
this.#setTimer =
|
|
95
95
|
options.setTimer ??
|
|
96
96
|
((callback, delayMs) => {
|
|
@@ -549,6 +549,14 @@ export function createDefaultFleetFactsReaders(): Pick<
|
|
|
549
549
|
return createSharedFleetFactsReaders(execute);
|
|
550
550
|
}
|
|
551
551
|
|
|
552
|
+
/**
|
|
553
|
+
* How often subscribed clients' tmux facts (sessions, panes, agent stamps) are
|
|
554
|
+
* read. Hook-stamped agent changes reach clients within one tick, so 500 ms
|
|
555
|
+
* keeps a state flip visible within 1 s (#716). One `list-panes -a` per tick,
|
|
556
|
+
* only while a client is subscribed.
|
|
557
|
+
*/
|
|
558
|
+
export const FLEET_FACTS_POLL_MS = 500;
|
|
559
|
+
|
|
552
560
|
export const SESSION_COMPOSITION_TMUX_ARGS = [
|
|
553
561
|
"list-panes",
|
|
554
562
|
"-a",
|
|
@@ -22,6 +22,7 @@ import {
|
|
|
22
22
|
TmuxServerScopeSchemaZ,
|
|
23
23
|
TmuxServersResourceSchemaZ,
|
|
24
24
|
TmuxServerSessionsResourceSchemaZ,
|
|
25
|
+
TmuxServerSessionsResourceV2SchemaZ,
|
|
25
26
|
WorkspaceMultiplexerMutationRequestSchemaZ,
|
|
26
27
|
WorkspacePaneCreateMutationRequestSchemaZ,
|
|
27
28
|
WorkspaceSessionCreateArgumentsSchemaZ,
|
|
@@ -32,6 +33,7 @@ import {
|
|
|
32
33
|
TmuxServerScopeError,
|
|
33
34
|
} from "../lib/tmux-server-owners.ts";
|
|
34
35
|
import type { NativeTmuxServerOwner } from "../lib/tmux-server-owner.ts";
|
|
36
|
+
import { createSessionFaviconResolver, type SessionFavicon } from "../lib/session-favicon.ts";
|
|
35
37
|
import { WorkspaceMultiplexerError } from "../lib/workspace-multiplexer-verbs.ts";
|
|
36
38
|
import { WorkspacePaneCreationError } from "../lib/workspace-pane-creation.ts";
|
|
37
39
|
import { ownerBearerMatches } from "./owner-authority.ts";
|
|
@@ -41,7 +43,11 @@ import { projectTerminalRuntimeInventory } from "./resources/terminal-runtime-in
|
|
|
41
43
|
export interface TmuxServerRoutesOptions {
|
|
42
44
|
readonly ownerToken: string | null;
|
|
43
45
|
readonly owners: TmuxServerOwners<NativeTmuxServerOwner>;
|
|
46
|
+
/** Project favicon lookup for the version 2 sessions resource. */
|
|
47
|
+
readonly resolveSessionFavicon?: (sessionPath: string) => Promise<SessionFavicon | null>;
|
|
44
48
|
}
|
|
49
|
+
/** Total raw favicon bytes one version 2 sessions response may carry. */
|
|
50
|
+
export const SESSIONS_RESOURCE_FAVICON_BUDGET_BYTES = 512 * 1024;
|
|
45
51
|
const registrationSchema = TmuxServerRegistrationSchemaZ.omit({ serverId: true }).strict();
|
|
46
52
|
const workspaceSchema = z
|
|
47
53
|
.string()
|
|
@@ -94,6 +100,7 @@ function failure(c: Context, error: unknown): Response {
|
|
|
94
100
|
/** Owner capability is mandatory even when the ordinary local HTTP surface is open. */
|
|
95
101
|
export function mountTmuxServerRoutes(app: Hono, options: TmuxServerRoutesOptions): void {
|
|
96
102
|
const base = TMUX_SERVERS_API_PATH;
|
|
103
|
+
const resolveSessionFavicon = options.resolveSessionFavicon ?? createSessionFaviconResolver();
|
|
97
104
|
const authorize = async (c: Context, next: () => Promise<void>) => {
|
|
98
105
|
c.header("Cache-Control", "no-store");
|
|
99
106
|
if (!ownerBearerMatches(c.req.header("Authorization"), options.ownerToken))
|
|
@@ -271,19 +278,56 @@ export function mountTmuxServerRoutes(app: Hono, options: TmuxServerRoutesOption
|
|
|
271
278
|
`${scoped}/sessions`,
|
|
272
279
|
route(async (c) => {
|
|
273
280
|
const server = scope(c);
|
|
281
|
+
const v2 = c.req.query("version") === "2";
|
|
282
|
+
const { rows, favicons } = await options.owners.withOwner(server, async (owner) => {
|
|
283
|
+
const sessions = await owner.catalog();
|
|
284
|
+
const workspaces = owner.workspaceRegistry.list();
|
|
285
|
+
const rows = sessions.map((session) => {
|
|
286
|
+
const aliases = workspaces.filter(
|
|
287
|
+
(workspace) => workspace.sessionName === session.sessionName,
|
|
288
|
+
);
|
|
289
|
+
return { session, workspaceName: aliases.length === 1 ? aliases[0]!.name : null };
|
|
290
|
+
});
|
|
291
|
+
const favicons = v2
|
|
292
|
+
? await Promise.all(
|
|
293
|
+
sessions.map((session) =>
|
|
294
|
+
resolveSessionFavicon(session.sessionPath).catch(() => null),
|
|
295
|
+
),
|
|
296
|
+
)
|
|
297
|
+
: [];
|
|
298
|
+
return { rows, favicons };
|
|
299
|
+
});
|
|
300
|
+
if (!v2)
|
|
301
|
+
return c.json(
|
|
302
|
+
TmuxServerSessionsResourceSchemaZ.parse({
|
|
303
|
+
version: 1,
|
|
304
|
+
server,
|
|
305
|
+
sessions: rows.map(({ session, workspaceName }) => ({
|
|
306
|
+
liveSessionId: session.liveSessionId,
|
|
307
|
+
sessionName: session.sessionName,
|
|
308
|
+
paneCount: session.paneCount,
|
|
309
|
+
workspaceName,
|
|
310
|
+
})),
|
|
311
|
+
}),
|
|
312
|
+
);
|
|
313
|
+
let budget = SESSIONS_RESOURCE_FAVICON_BUDGET_BYTES;
|
|
274
314
|
return c.json(
|
|
275
|
-
|
|
276
|
-
version:
|
|
315
|
+
TmuxServerSessionsResourceV2SchemaZ.parse({
|
|
316
|
+
version: 2,
|
|
277
317
|
server,
|
|
278
|
-
sessions:
|
|
279
|
-
const
|
|
280
|
-
const
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
318
|
+
sessions: rows.map(({ session, workspaceName }, index) => {
|
|
319
|
+
const found = favicons[index] ?? null;
|
|
320
|
+
const favicon = found && found.size <= budget ? found : null;
|
|
321
|
+
if (favicon) budget -= favicon.size;
|
|
322
|
+
return {
|
|
323
|
+
liveSessionId: session.liveSessionId,
|
|
324
|
+
sessionName: session.sessionName,
|
|
325
|
+
workspaceName,
|
|
326
|
+
paneCount: session.paneCount,
|
|
327
|
+
windowCount: session.windowCount,
|
|
328
|
+
lastActivity: session.lastActivity,
|
|
329
|
+
favicon: favicon ? { mime: favicon.mime, data: favicon.data } : null,
|
|
330
|
+
};
|
|
287
331
|
}),
|
|
288
332
|
}),
|
|
289
333
|
);
|
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
import { constants, type Stats } from "node:fs";
|
|
2
|
+
import { lstat, open } from "node:fs/promises";
|
|
3
|
+
import { extname, isAbsolute, join } from "node:path";
|
|
4
|
+
import {
|
|
5
|
+
TMUX_SERVER_SESSION_FAVICON_MAX_BYTES,
|
|
6
|
+
type TmuxServerSessionFaviconMime,
|
|
7
|
+
} from "@tmux-ide/contracts";
|
|
8
|
+
|
|
9
|
+
/** Project-relative favicon locations, first match wins. */
|
|
10
|
+
export const SESSION_FAVICON_CANDIDATES = [
|
|
11
|
+
"favicon.svg",
|
|
12
|
+
"favicon.png",
|
|
13
|
+
"favicon.ico",
|
|
14
|
+
"public/favicon.svg",
|
|
15
|
+
"public/favicon.png",
|
|
16
|
+
"public/favicon.ico",
|
|
17
|
+
"app/icon.svg",
|
|
18
|
+
"app/icon.png",
|
|
19
|
+
"app/favicon.ico",
|
|
20
|
+
"src/app/icon.svg",
|
|
21
|
+
"src/app/icon.png",
|
|
22
|
+
"src/app/favicon.ico",
|
|
23
|
+
"static/favicon.svg",
|
|
24
|
+
"static/favicon.png",
|
|
25
|
+
"static/favicon.ico",
|
|
26
|
+
"assets/favicon.svg",
|
|
27
|
+
"assets/favicon.png",
|
|
28
|
+
"assets/favicon.ico",
|
|
29
|
+
"apple-touch-icon.png",
|
|
30
|
+
"public/apple-touch-icon.png",
|
|
31
|
+
"icon.svg",
|
|
32
|
+
"icon.png",
|
|
33
|
+
] as const;
|
|
34
|
+
|
|
35
|
+
const MIME_BY_EXTENSION: Readonly<Record<string, TmuxServerSessionFaviconMime>> = {
|
|
36
|
+
".png": "image/png",
|
|
37
|
+
".svg": "image/svg+xml",
|
|
38
|
+
".ico": "image/x-icon",
|
|
39
|
+
".jpg": "image/jpeg",
|
|
40
|
+
".jpeg": "image/jpeg",
|
|
41
|
+
".webp": "image/webp",
|
|
42
|
+
".gif": "image/gif",
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
export interface SessionFavicon {
|
|
46
|
+
readonly mime: TmuxServerSessionFaviconMime;
|
|
47
|
+
/** Base64 of the raw file bytes. */
|
|
48
|
+
readonly data: string;
|
|
49
|
+
/** Raw byte count. */
|
|
50
|
+
readonly size: number;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export interface SessionFaviconIo {
|
|
54
|
+
lstat(path: string): Promise<Stats>;
|
|
55
|
+
/** Reads a regular file without following a final symlink; null when refused or too large. */
|
|
56
|
+
readNoFollow(path: string, maxBytes: number): Promise<Buffer | null>;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export const defaultSessionFaviconIo: SessionFaviconIo = {
|
|
60
|
+
lstat: (path) => lstat(path),
|
|
61
|
+
readNoFollow: async (path, maxBytes) => {
|
|
62
|
+
let handle;
|
|
63
|
+
try {
|
|
64
|
+
handle = await open(path, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK);
|
|
65
|
+
} catch {
|
|
66
|
+
return null;
|
|
67
|
+
}
|
|
68
|
+
try {
|
|
69
|
+
const stats = await handle.stat();
|
|
70
|
+
if (!stats.isFile() || stats.size > maxBytes) return null;
|
|
71
|
+
const bytes = await handle.readFile();
|
|
72
|
+
return bytes.byteLength > maxBytes ? null : bytes;
|
|
73
|
+
} catch {
|
|
74
|
+
return null;
|
|
75
|
+
} finally {
|
|
76
|
+
await handle.close().catch(() => undefined);
|
|
77
|
+
}
|
|
78
|
+
},
|
|
79
|
+
};
|
|
80
|
+
|
|
81
|
+
type CacheEntry =
|
|
82
|
+
| {
|
|
83
|
+
readonly kind: "hit";
|
|
84
|
+
readonly file: string;
|
|
85
|
+
readonly mtimeMs: number;
|
|
86
|
+
readonly size: number;
|
|
87
|
+
readonly favicon: SessionFavicon;
|
|
88
|
+
}
|
|
89
|
+
| { readonly kind: "miss"; readonly at: number };
|
|
90
|
+
|
|
91
|
+
export interface SessionFaviconResolverOptions {
|
|
92
|
+
readonly io?: SessionFaviconIo;
|
|
93
|
+
readonly now?: () => number;
|
|
94
|
+
readonly missTtlMs?: number;
|
|
95
|
+
readonly maxEntries?: number;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
async function lstatOrNull(io: SessionFaviconIo, path: string): Promise<Stats | null> {
|
|
99
|
+
try {
|
|
100
|
+
return await io.lstat(path);
|
|
101
|
+
} catch {
|
|
102
|
+
return null;
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Finds a session's project favicon from its working directory. Only regular
|
|
108
|
+
* files and real directories inside the session path are considered; symlinks
|
|
109
|
+
* are never followed. Hits are revalidated by mtime and size, misses are
|
|
110
|
+
* remembered briefly, and the cache is bounded (least recently used first).
|
|
111
|
+
*/
|
|
112
|
+
export function createSessionFaviconResolver(options: SessionFaviconResolverOptions = {}) {
|
|
113
|
+
const io = options.io ?? defaultSessionFaviconIo;
|
|
114
|
+
const now = options.now ?? Date.now;
|
|
115
|
+
const missTtlMs = options.missTtlMs ?? 30_000;
|
|
116
|
+
const maxEntries = options.maxEntries ?? 256;
|
|
117
|
+
const cache = new Map<string, CacheEntry>();
|
|
118
|
+
|
|
119
|
+
const remember = (sessionPath: string, entry: CacheEntry) => {
|
|
120
|
+
cache.delete(sessionPath);
|
|
121
|
+
cache.set(sessionPath, entry);
|
|
122
|
+
while (cache.size > maxEntries) cache.delete(cache.keys().next().value!);
|
|
123
|
+
};
|
|
124
|
+
|
|
125
|
+
const scan = async (sessionPath: string): Promise<CacheEntry> => {
|
|
126
|
+
const directories = new Map<string, boolean>();
|
|
127
|
+
for (const candidate of SESSION_FAVICON_CANDIDATES) {
|
|
128
|
+
const mime = MIME_BY_EXTENSION[extname(candidate)];
|
|
129
|
+
if (!mime) continue;
|
|
130
|
+
const slash = candidate.lastIndexOf("/");
|
|
131
|
+
if (slash >= 0) {
|
|
132
|
+
const parts = candidate.slice(0, slash).split("/");
|
|
133
|
+
let real = true;
|
|
134
|
+
for (let index = 1; index <= parts.length && real; index += 1) {
|
|
135
|
+
const directory = parts.slice(0, index).join("/");
|
|
136
|
+
let known = directories.get(directory);
|
|
137
|
+
if (known === undefined) {
|
|
138
|
+
const stats = await lstatOrNull(io, join(sessionPath, directory));
|
|
139
|
+
known = stats !== null && stats.isDirectory() && !stats.isSymbolicLink();
|
|
140
|
+
directories.set(directory, known);
|
|
141
|
+
}
|
|
142
|
+
real = known;
|
|
143
|
+
}
|
|
144
|
+
if (!real) continue;
|
|
145
|
+
}
|
|
146
|
+
const file = join(sessionPath, candidate);
|
|
147
|
+
const stats = await lstatOrNull(io, file);
|
|
148
|
+
if (!stats || !stats.isFile() || stats.size > TMUX_SERVER_SESSION_FAVICON_MAX_BYTES) continue;
|
|
149
|
+
const bytes = await io.readNoFollow(file, TMUX_SERVER_SESSION_FAVICON_MAX_BYTES);
|
|
150
|
+
if (!bytes) continue;
|
|
151
|
+
return {
|
|
152
|
+
kind: "hit",
|
|
153
|
+
file,
|
|
154
|
+
mtimeMs: stats.mtimeMs,
|
|
155
|
+
size: stats.size,
|
|
156
|
+
favicon: { mime, data: bytes.toString("base64"), size: bytes.byteLength },
|
|
157
|
+
};
|
|
158
|
+
}
|
|
159
|
+
return { kind: "miss", at: now() };
|
|
160
|
+
};
|
|
161
|
+
|
|
162
|
+
return async (sessionPath: string): Promise<SessionFavicon | null> => {
|
|
163
|
+
if (!sessionPath || !isAbsolute(sessionPath) || sessionPath.includes("\0")) return null;
|
|
164
|
+
const cached = cache.get(sessionPath);
|
|
165
|
+
if (cached?.kind === "miss" && now() - cached.at < missTtlMs) {
|
|
166
|
+
remember(sessionPath, cached);
|
|
167
|
+
return null;
|
|
168
|
+
}
|
|
169
|
+
if (cached?.kind === "hit") {
|
|
170
|
+
const stats = await lstatOrNull(io, cached.file);
|
|
171
|
+
if (
|
|
172
|
+
stats &&
|
|
173
|
+
stats.isFile() &&
|
|
174
|
+
stats.mtimeMs === cached.mtimeMs &&
|
|
175
|
+
stats.size === cached.size
|
|
176
|
+
) {
|
|
177
|
+
remember(sessionPath, cached);
|
|
178
|
+
return cached.favicon;
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
const entry = await scan(sessionPath);
|
|
182
|
+
remember(sessionPath, entry);
|
|
183
|
+
return entry.kind === "hit" ? entry.favicon : null;
|
|
184
|
+
};
|
|
185
|
+
}
|
|
@@ -13,8 +13,8 @@ import { resolveRuntimeNamespace } from "./runtime-namespace.ts";
|
|
|
13
13
|
*
|
|
14
14
|
* The dir is FULLY MANAGED: overwriting our own `~/.claude/skills/tmux-ide` is
|
|
15
15
|
* correct, and this NEVER touches anything outside it (user-authored skills in
|
|
16
|
-
* sibling dirs are safe). The version marker is
|
|
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}
|
|
@@ -73,7 +73,7 @@ export function defaultSkillSource(): string {
|
|
|
73
73
|
// ---------------------------------------------------------------------------
|
|
74
74
|
|
|
75
75
|
/**
|
|
76
|
-
* The version marker line (an HTML comment
|
|
76
|
+
* The version marker line (an HTML comment under SKILL.md's frontmatter). The sync
|
|
77
77
|
* machinery reads it to know which version an installed copy is, and rewrites it
|
|
78
78
|
* to the installed package version at copy time.
|
|
79
79
|
*/
|
|
@@ -97,13 +97,23 @@ function discoveredWorkspaceName(registry: WorkspaceRegistry, sessionName: strin
|
|
|
97
97
|
throw new Error("Unable to allocate discovered workspace identity");
|
|
98
98
|
}
|
|
99
99
|
|
|
100
|
+
/** A live session row with the facts the native app's Home shows beside it. */
|
|
101
|
+
export interface NativeLiveSessionSummary extends LiveSessionSummary {
|
|
102
|
+
/** Distinct tmux windows of the session (at least one). */
|
|
103
|
+
readonly windowCount: number;
|
|
104
|
+
/** tmux `#{session_activity}` in epoch seconds, or null when tmux reports none. */
|
|
105
|
+
readonly lastActivity: number | null;
|
|
106
|
+
/** tmux `#{session_path}`, the session's working directory. */
|
|
107
|
+
readonly sessionPath: string;
|
|
108
|
+
}
|
|
109
|
+
|
|
100
110
|
/** Refresh discovered live membership without changing durable workspace intent or aliases. */
|
|
101
111
|
export function createNativeTmuxServerCatalog(
|
|
102
112
|
workspaceRegistry: WorkspaceRegistry,
|
|
103
113
|
run: (args: readonly string[], signal?: AbortSignal) => Promise<string>,
|
|
104
114
|
assertOpen: () => void = () => undefined,
|
|
105
|
-
): () => Promise<
|
|
106
|
-
return async (): Promise<
|
|
115
|
+
): () => Promise<NativeLiveSessionSummary[]> {
|
|
116
|
+
return async (): Promise<NativeLiveSessionSummary[]> => {
|
|
107
117
|
assertOpen();
|
|
108
118
|
const raw = await run(
|
|
109
119
|
[
|
|
@@ -116,7 +126,8 @@ export function createNativeTmuxServerCatalog(
|
|
|
116
126
|
"list-panes",
|
|
117
127
|
"-a",
|
|
118
128
|
"-F",
|
|
119
|
-
|
|
129
|
+
// session_path stays last: tmux rejects tabs in session names but not in paths.
|
|
130
|
+
"#{pid}\t#{session_id}\t#{session_created}\t#{session_name}\t#{pane_id}\t#{window_id}\t#{session_activity}\t#{session_path}",
|
|
120
131
|
]
|
|
121
132
|
.map(shellEscape)
|
|
122
133
|
.join(" "),
|
|
@@ -126,13 +137,20 @@ export function createNativeTmuxServerCatalog(
|
|
|
126
137
|
assertOpen();
|
|
127
138
|
const rows = new Map<
|
|
128
139
|
string,
|
|
129
|
-
{
|
|
140
|
+
{
|
|
141
|
+
summary: LiveSessionSummary;
|
|
142
|
+
panes: Set<string>;
|
|
143
|
+
windows: Set<string>;
|
|
144
|
+
dir: string;
|
|
145
|
+
activity: number | null;
|
|
146
|
+
}
|
|
130
147
|
>();
|
|
131
148
|
// Only the successful server-side zero-session branch proves emptiness.
|
|
132
149
|
// Command errors, unsupported formats and unwitnessed blank output stay failures.
|
|
133
150
|
const lines = raw.trim() === "tmux-ide-empty-catalog-v1" ? [] : raw.trim().split("\n");
|
|
134
151
|
for (const line of lines) {
|
|
135
|
-
const [pid, id, created, name, pane,
|
|
152
|
+
const [pid, id, created, name, pane, window, activity, ...pathParts] = line.split("\t");
|
|
153
|
+
const dir = pathParts.join("\t");
|
|
136
154
|
if (
|
|
137
155
|
!pid ||
|
|
138
156
|
!/^\d+$/.test(pid) ||
|
|
@@ -143,6 +161,8 @@ export function createNativeTmuxServerCatalog(
|
|
|
143
161
|
!name ||
|
|
144
162
|
!pane ||
|
|
145
163
|
!/^%\d+$/.test(pane) ||
|
|
164
|
+
!window ||
|
|
165
|
+
!/^@\d+$/.test(window) ||
|
|
146
166
|
!dir
|
|
147
167
|
)
|
|
148
168
|
throw new Error("Invalid native server catalog");
|
|
@@ -151,9 +171,12 @@ export function createNativeTmuxServerCatalog(
|
|
|
151
171
|
const row = rows.get(liveSessionId) ?? {
|
|
152
172
|
summary: { liveSessionId, sessionName: name, paneCount: 0 },
|
|
153
173
|
panes: new Set<string>(),
|
|
174
|
+
windows: new Set<string>(),
|
|
154
175
|
dir,
|
|
176
|
+
activity: activity && /^\d+$/.test(activity) ? Number(activity) : null,
|
|
155
177
|
};
|
|
156
178
|
row.panes.add(pane);
|
|
179
|
+
row.windows.add(window);
|
|
157
180
|
rows.set(liveSessionId, row);
|
|
158
181
|
}
|
|
159
182
|
const names = new Set([...rows.values()].map(({ summary }) => summary.sessionName));
|
|
@@ -170,7 +193,13 @@ export function createNativeTmuxServerCatalog(
|
|
|
170
193
|
projectDir: dir,
|
|
171
194
|
persistence: "volatile",
|
|
172
195
|
});
|
|
173
|
-
return [...rows.values()].map(({ summary, panes }) => ({
|
|
196
|
+
return [...rows.values()].map(({ summary, panes, windows, dir, activity }) => ({
|
|
197
|
+
...summary,
|
|
198
|
+
paneCount: panes.size,
|
|
199
|
+
windowCount: windows.size,
|
|
200
|
+
lastActivity: activity,
|
|
201
|
+
sessionPath: dir,
|
|
202
|
+
}));
|
|
174
203
|
};
|
|
175
204
|
}
|
|
176
205
|
|
|
@@ -115,6 +115,54 @@ export function validateBrowserOpen(url: string | undefined, options: OpenBrowse
|
|
|
115
115
|
}
|
|
116
116
|
|
|
117
117
|
type Run = (args: readonly string[]) => string;
|
|
118
|
+
|
|
119
|
+
/** Printed when the opened browser never publishes tmux-ide's native controls. */
|
|
120
|
+
export const NO_NATIVE_CONTROLS_NOTICE =
|
|
121
|
+
"This browser has no tmux-ide controls; install the native app for back/forward/reload.";
|
|
122
|
+
|
|
123
|
+
/** A browser bundled in a native app publishes its controls; a standalone one may not. */
|
|
124
|
+
function isBundledBrowser(executable: string): boolean {
|
|
125
|
+
return /\/[^/]+\.app\/Contents\/Resources\/browser\//u.test(executable);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
export interface ControlsWait {
|
|
129
|
+
sleep?: (ms: number) => Promise<void>;
|
|
130
|
+
/** Milliseconds; Date.now by default. */
|
|
131
|
+
now?: () => number;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/** How long a standalone browser has to publish its controls. */
|
|
135
|
+
const STANDALONE_WAIT_MS = 2000;
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* Watches the new pane for @tmux_ide_browser_control, which only a browser with
|
|
139
|
+
* native controls sets. A browser bundled in an app is never reported: if it is
|
|
140
|
+
* slow, its controls appear when it is ready. A standalone browser gets 2 s of
|
|
141
|
+
* wall-clock time, however slow tmux answers, and then the notice. Returns
|
|
142
|
+
* null once the option appears or when the pane is already gone.
|
|
143
|
+
*/
|
|
144
|
+
export async function nativeControlsNotice(
|
|
145
|
+
run: Run,
|
|
146
|
+
pane: string,
|
|
147
|
+
executable: string,
|
|
148
|
+
wait: ControlsWait = {},
|
|
149
|
+
): Promise<string | null> {
|
|
150
|
+
if (isBundledBrowser(executable)) return null;
|
|
151
|
+
const sleep = wait.sleep ?? ((ms: number) => new Promise<void>((r) => setTimeout(r, ms)));
|
|
152
|
+
const now = wait.now ?? Date.now;
|
|
153
|
+
const deadline = now() + STANDALONE_WAIT_MS;
|
|
154
|
+
for (;;) {
|
|
155
|
+
let control: string;
|
|
156
|
+
try {
|
|
157
|
+
control = run(["display-message", "-p", "-t", pane, "#{@tmux_ide_browser_control}"]).trim();
|
|
158
|
+
} catch {
|
|
159
|
+
return null;
|
|
160
|
+
}
|
|
161
|
+
if (control) return null;
|
|
162
|
+
if (now() >= deadline) return NO_NATIVE_CONTROLS_NOTICE;
|
|
163
|
+
await sleep(250);
|
|
164
|
+
}
|
|
165
|
+
}
|
|
118
166
|
export function createBrowserPane(
|
|
119
167
|
run: Run,
|
|
120
168
|
executable: string,
|
|
@@ -220,9 +268,37 @@ export function createBrowserPane(
|
|
|
220
268
|
return { pane: pane!, session: createdSession!, window: createdWindow!, status: "created" };
|
|
221
269
|
}
|
|
222
270
|
|
|
223
|
-
|
|
271
|
+
/** Seams for tests; the CLI uses the defaults. */
|
|
272
|
+
export interface OpenBrowserDependencies {
|
|
273
|
+
notice?: (line: string) => void;
|
|
274
|
+
executable?: () => string;
|
|
275
|
+
create?: typeof createPinnedBrowserPane;
|
|
276
|
+
wait?: ControlsWait;
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
export async function openBrowser(
|
|
280
|
+
url: string | undefined,
|
|
281
|
+
options: OpenBrowserOptions = {},
|
|
282
|
+
dependencies: OpenBrowserDependencies = {},
|
|
283
|
+
) {
|
|
224
284
|
validateBrowserOpen(url, options);
|
|
225
|
-
const executable = resolveTerminalBrowser();
|
|
285
|
+
const executable = (dependencies.executable ?? resolveTerminalBrowser)();
|
|
286
|
+
const { result, run } = (dependencies.create ?? createPinnedBrowserPane)(
|
|
287
|
+
executable,
|
|
288
|
+
url,
|
|
289
|
+
options,
|
|
290
|
+
);
|
|
291
|
+
const message = await nativeControlsNotice(run, result.pane, executable, dependencies.wait);
|
|
292
|
+
if (message)
|
|
293
|
+
(dependencies.notice ?? ((line: string) => process.stderr.write(`${line}\n`)))(message);
|
|
294
|
+
return result;
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
function createPinnedBrowserPane(
|
|
298
|
+
executable: string,
|
|
299
|
+
url: string | undefined,
|
|
300
|
+
options: OpenBrowserOptions,
|
|
301
|
+
): { result: ReturnType<typeof createBrowserPane>; run: Run } {
|
|
226
302
|
try {
|
|
227
303
|
const namespace = { ...runtimeNamespaceEnvironment(resolveRuntimeNamespace()) };
|
|
228
304
|
const authority = resolveWorkspacePaneTmuxAuthority();
|
|
@@ -230,7 +306,16 @@ export function openBrowser(url: string | undefined, options: OpenBrowserOptions
|
|
|
230
306
|
const run = createPinnedWorkspaceTmuxRunner(authority, {
|
|
231
307
|
timeoutMs: 10_000,
|
|
232
308
|
});
|
|
233
|
-
|
|
309
|
+
const result = createBrowserPane(
|
|
310
|
+
run,
|
|
311
|
+
executable,
|
|
312
|
+
url,
|
|
313
|
+
options,
|
|
314
|
+
process.env,
|
|
315
|
+
process.cwd(),
|
|
316
|
+
namespace,
|
|
317
|
+
);
|
|
318
|
+
return { result, run };
|
|
234
319
|
} catch (error) {
|
|
235
320
|
if (error instanceof IdeError) throw error;
|
|
236
321
|
throw new IdeError(
|
|
@@ -28,6 +28,7 @@ import { inflateSync } from "node:zlib";
|
|
|
28
28
|
import { PaneStreamLayoutSnapshotFrameSchemaZ, type WindowLinkTopology } from "@tmux-ide/contracts";
|
|
29
29
|
import type { TerminalDeliveryGraphics } from "../session-runtime/terminal-delivery-hub.ts";
|
|
30
30
|
import { z } from "zod";
|
|
31
|
+
import { logger } from "../../lib/log.ts";
|
|
31
32
|
import {
|
|
32
33
|
PANE_STREAM_MAX_HELD_DELTAS,
|
|
33
34
|
PANE_STREAM_MAX_OUTPUT_BYTES,
|
|
@@ -988,6 +989,11 @@ class PreAuthAdmission implements PaneStreamPreAuthAdmission {
|
|
|
988
989
|
this.#open = false;
|
|
989
990
|
this.#cancelDeadline();
|
|
990
991
|
const socket = this.#socket;
|
|
992
|
+
logger.info("pane-stream", "admission closed", {
|
|
993
|
+
code,
|
|
994
|
+
reason,
|
|
995
|
+
hostClientId: this.hostClientId,
|
|
996
|
+
});
|
|
991
997
|
this.#detach();
|
|
992
998
|
this.#onRelease(this);
|
|
993
999
|
if (socket) safeCloseSocket(socket, code, reason);
|
|
@@ -1386,6 +1392,12 @@ export class PaneStreamLiveConnection {
|
|
|
1386
1392
|
close(code = 1000, reason = "stream-closed"): void {
|
|
1387
1393
|
if (this.#closed) return;
|
|
1388
1394
|
this.#recordDiagnosticLifecycle("pane-stream-terminal", code, reason);
|
|
1395
|
+
logger.info("pane-stream", "stream closed", {
|
|
1396
|
+
code,
|
|
1397
|
+
reason,
|
|
1398
|
+
session: this.#descriptor.sessionName,
|
|
1399
|
+
clientId: this.#clientId,
|
|
1400
|
+
});
|
|
1389
1401
|
this.#closed = true;
|
|
1390
1402
|
for (const demand of this.#surfaceDemands.values()) demand.close();
|
|
1391
1403
|
this.#surfaceDemands.clear();
|
|
@@ -12,6 +12,7 @@ import { encodeTerminalProjectionCooperatively } from "@tmux-ide/core";
|
|
|
12
12
|
type DeliveryEnvelope = TerminalDeliveryEnvelope | TerminalProjectionEnvelope;
|
|
13
13
|
import type { KittyImage } from "../graphics/kitty-graphics.ts";
|
|
14
14
|
import { StockCaptureTabUnavailableError } from "./stock-capture-fidelity.ts";
|
|
15
|
+
import { logger } from "../../lib/log.ts";
|
|
15
16
|
import {
|
|
16
17
|
MAX_RETAINED_NATIVE_BACKING_BYTES,
|
|
17
18
|
takeNativeSeedBacking,
|
|
@@ -512,6 +513,11 @@ export class SessionRuntimeTerminalDeliveryHub {
|
|
|
512
513
|
} catch (error) {
|
|
513
514
|
if (!(error instanceof StockCaptureTabUnavailableError)) throw error;
|
|
514
515
|
// Negotiation still settles this pane; no invented canonical seed is published.
|
|
516
|
+
logDeliveryFault(
|
|
517
|
+
{ paneId: semanticPaneId, deliveryWorkspaceName: address },
|
|
518
|
+
"source-closed",
|
|
519
|
+
"capture-unavailable",
|
|
520
|
+
);
|
|
515
521
|
await accept({
|
|
516
522
|
type: "terminal.delivery.fault",
|
|
517
523
|
reason: "source-closed",
|
|
@@ -697,6 +703,7 @@ export class SessionRuntimeTerminalDeliveryHub {
|
|
|
697
703
|
this.#cancelEncoding(client);
|
|
698
704
|
client.outgoing.length = 0;
|
|
699
705
|
client.inFlight = null;
|
|
706
|
+
logDeliveryFault(client, "source-closed", "source-restarted");
|
|
700
707
|
this.#enqueue(client, {
|
|
701
708
|
type: "terminal.delivery.fault",
|
|
702
709
|
reason: "source-closed",
|
|
@@ -2008,6 +2015,7 @@ export class SessionRuntimeTerminalDeliveryHub {
|
|
|
2008
2015
|
message: string,
|
|
2009
2016
|
selectionObservation: SemanticSelectionObservation | null = null,
|
|
2010
2017
|
): void {
|
|
2018
|
+
logDeliveryFault(client, reason);
|
|
2011
2019
|
if (this.#observability.enabled)
|
|
2012
2020
|
try {
|
|
2013
2021
|
const atMicros = this.#observability.nowMicros();
|
|
@@ -2694,3 +2702,20 @@ function joinBytes(chunks: readonly Uint8Array[]): Uint8Array {
|
|
|
2694
2702
|
}
|
|
2695
2703
|
return joined;
|
|
2696
2704
|
}
|
|
2705
|
+
|
|
2706
|
+
/**
|
|
2707
|
+
* One info line per fault a client is sent, so a client's drop has a daemon-side
|
|
2708
|
+
* record. Ids and fixed reasons only: the fault message can carry source text.
|
|
2709
|
+
*/
|
|
2710
|
+
function logDeliveryFault(
|
|
2711
|
+
client: Pick<ClientState, "paneId" | "deliveryWorkspaceName">,
|
|
2712
|
+
reason: "state-too-large" | "source-closed" | "protocol-violation",
|
|
2713
|
+
cause?: "source-restarted" | "capture-unavailable",
|
|
2714
|
+
): void {
|
|
2715
|
+
logger.info("terminal-delivery", "fault sent to client", {
|
|
2716
|
+
reason,
|
|
2717
|
+
...(cause ? { cause } : {}),
|
|
2718
|
+
pane: client.paneId,
|
|
2719
|
+
workspace: client.deliveryWorkspaceName,
|
|
2720
|
+
});
|
|
2721
|
+
}
|