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
|
@@ -0,0 +1,118 @@
|
|
|
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 {
|
|
6
|
+
NativeBrowserCommandSchemaZ,
|
|
7
|
+
NativeBrowserStateSchemaZ,
|
|
8
|
+
type NativeBrowserCommand,
|
|
9
|
+
type NativeBrowserState,
|
|
10
|
+
} from "@tmux-ide/contracts";
|
|
11
|
+
import { z } from "zod";
|
|
12
|
+
|
|
13
|
+
export const NativeBrowserEndpointSchema = z
|
|
14
|
+
.object({
|
|
15
|
+
version: z.literal(1),
|
|
16
|
+
pid: z.number().int().positive(),
|
|
17
|
+
instance: z.uuid(),
|
|
18
|
+
pane: z
|
|
19
|
+
.string()
|
|
20
|
+
.regex(/^%[0-9]+$/u)
|
|
21
|
+
.max(32),
|
|
22
|
+
socket: z.string().min(1).max(1024).refine(isAbsolute),
|
|
23
|
+
})
|
|
24
|
+
.strict();
|
|
25
|
+
export type NativeBrowserEndpoint = z.infer<typeof NativeBrowserEndpointSchema>;
|
|
26
|
+
export class NativeBrowserUnavailable extends Error {
|
|
27
|
+
constructor() {
|
|
28
|
+
super("Native browser controls are unavailable");
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
const responseSchema = z.discriminatedUnion("ok", [
|
|
32
|
+
z
|
|
33
|
+
.object({ id: z.string(), ok: z.literal(true), data: NativeBrowserStateSchemaZ.nullable() })
|
|
34
|
+
.strict(),
|
|
35
|
+
z.object({ id: z.string(), ok: z.literal(false), error: z.string().max(4096) }).strict(),
|
|
36
|
+
]);
|
|
37
|
+
|
|
38
|
+
/** One request, one bounded response. Mutations are never retried or refreshed. */
|
|
39
|
+
export async function nativeBrowserRpc(
|
|
40
|
+
endpoint: NativeBrowserEndpoint,
|
|
41
|
+
command: NativeBrowserCommand | null,
|
|
42
|
+
signal: AbortSignal,
|
|
43
|
+
): Promise<NativeBrowserState | null> {
|
|
44
|
+
const target = NativeBrowserEndpointSchema.parse(endpoint);
|
|
45
|
+
const payload = command ? NativeBrowserCommandSchemaZ.parse(command) : null;
|
|
46
|
+
if (payload && (payload.instance !== target.instance || payload.pane !== target.pane))
|
|
47
|
+
throw new NativeBrowserUnavailable();
|
|
48
|
+
signal.throwIfAborted();
|
|
49
|
+
const info = await lstat(target.socket);
|
|
50
|
+
if (!info.isSocket() || info.uid !== process.getuid?.() || (info.mode & 0o022) !== 0)
|
|
51
|
+
throw new NativeBrowserUnavailable();
|
|
52
|
+
try {
|
|
53
|
+
process.kill(target.pid, 0);
|
|
54
|
+
} catch {
|
|
55
|
+
throw new NativeBrowserUnavailable();
|
|
56
|
+
}
|
|
57
|
+
signal.throwIfAborted();
|
|
58
|
+
const id = randomUUID();
|
|
59
|
+
const request =
|
|
60
|
+
JSON.stringify({
|
|
61
|
+
id,
|
|
62
|
+
cmd: payload ? "native/1/command" : "native/1/state",
|
|
63
|
+
...(payload ? { payload } : {}),
|
|
64
|
+
}) + "\n";
|
|
65
|
+
return new Promise((resolve, reject) => {
|
|
66
|
+
const socket = connect(target.socket);
|
|
67
|
+
let finished = false;
|
|
68
|
+
let size = 0;
|
|
69
|
+
const chunks: Buffer[] = [];
|
|
70
|
+
const finish = (error?: Error, state: NativeBrowserState | null = null) => {
|
|
71
|
+
if (finished) return;
|
|
72
|
+
finished = true;
|
|
73
|
+
clearTimeout(timer);
|
|
74
|
+
signal.removeEventListener("abort", abort);
|
|
75
|
+
socket.destroy();
|
|
76
|
+
if (error) reject(error);
|
|
77
|
+
else resolve(state);
|
|
78
|
+
};
|
|
79
|
+
const abort = () => finish(new NativeBrowserUnavailable());
|
|
80
|
+
const timer = setTimeout(abort, 1500);
|
|
81
|
+
signal.addEventListener("abort", abort, { once: true });
|
|
82
|
+
if (signal.aborted) {
|
|
83
|
+
abort();
|
|
84
|
+
return;
|
|
85
|
+
}
|
|
86
|
+
socket.once("connect", () => socket.write(request));
|
|
87
|
+
socket.on("error", abort);
|
|
88
|
+
socket.once("end", () => {
|
|
89
|
+
if (!finished) abort();
|
|
90
|
+
});
|
|
91
|
+
socket.on("data", (chunk: Buffer) => {
|
|
92
|
+
size += chunk.length;
|
|
93
|
+
if (size > 128 * 1024) {
|
|
94
|
+
abort();
|
|
95
|
+
return;
|
|
96
|
+
}
|
|
97
|
+
chunks.push(chunk);
|
|
98
|
+
if (!chunk.includes(10)) return;
|
|
99
|
+
try {
|
|
100
|
+
const bytes = Buffer.concat(chunks);
|
|
101
|
+
const newline = bytes.indexOf(10);
|
|
102
|
+
if (newline !== bytes.length - 1) throw new NativeBrowserUnavailable();
|
|
103
|
+
const reply = responseSchema.parse(
|
|
104
|
+
JSON.parse(new TextDecoder("utf-8", { fatal: true }).decode(bytes.subarray(0, newline))),
|
|
105
|
+
);
|
|
106
|
+
if (reply.id !== id || !reply.ok) throw new NativeBrowserUnavailable();
|
|
107
|
+
if (
|
|
108
|
+
reply.data &&
|
|
109
|
+
(reply.data.instance !== target.instance || reply.data.pane !== target.pane)
|
|
110
|
+
)
|
|
111
|
+
throw new NativeBrowserUnavailable();
|
|
112
|
+
finish(undefined, reply.data);
|
|
113
|
+
} catch {
|
|
114
|
+
abort();
|
|
115
|
+
}
|
|
116
|
+
});
|
|
117
|
+
});
|
|
118
|
+
}
|
|
@@ -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
|
*/
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
import { PaneEditorContextTargetSchemaZ, NativeBrowserCommandSchemaZ } from "@tmux-ide/contracts";
|
|
2
|
+
import { z } from "zod";
|
|
3
|
+
import type { LiveSessionSummary } from "../command-center/discovery.ts";
|
|
4
|
+
import type { WorkspaceTerminalInventoryRuntime } from "../terminal/attachments/native-runtime.ts";
|
|
5
|
+
import type { WorkspaceRegistry } from "./workspace-registry.ts";
|
|
6
|
+
import { decodeTmuxArgument } from "../terminal/protocol/session-descriptor-discovery.ts";
|
|
7
|
+
import {
|
|
8
|
+
NativeBrowserEndpointSchema,
|
|
9
|
+
NativeBrowserUnavailable,
|
|
10
|
+
nativeBrowserRpc,
|
|
11
|
+
} from "./native-browser-rpc.ts";
|
|
12
|
+
|
|
13
|
+
export const NativeBrowserRequestSchema = z
|
|
14
|
+
.object({
|
|
15
|
+
target: PaneEditorContextTargetSchemaZ,
|
|
16
|
+
command: NativeBrowserCommandSchemaZ.nullable(),
|
|
17
|
+
})
|
|
18
|
+
.strict();
|
|
19
|
+
export function createNativeBrowserResolver(options: {
|
|
20
|
+
generation: string;
|
|
21
|
+
assertOpen(): void;
|
|
22
|
+
registry: Pick<WorkspaceRegistry, "get">;
|
|
23
|
+
catalog(): Promise<LiveSessionSummary[]>;
|
|
24
|
+
inventory: Pick<WorkspaceTerminalInventoryRuntime, "discoverTerminalInventory">;
|
|
25
|
+
run(args: readonly string[], signal: AbortSignal): Promise<string>;
|
|
26
|
+
}) {
|
|
27
|
+
let readers = 0;
|
|
28
|
+
return async (input: unknown, cancellation: AbortSignal) => {
|
|
29
|
+
const { target, command } = NativeBrowserRequestSchema.parse(input);
|
|
30
|
+
const signal = AbortSignal.any([cancellation, AbortSignal.timeout(2500)]);
|
|
31
|
+
const workspace = options.registry.get(target.workspaceName);
|
|
32
|
+
const assertCurrent = () => {
|
|
33
|
+
options.assertOpen();
|
|
34
|
+
signal.throwIfAborted();
|
|
35
|
+
if (
|
|
36
|
+
target.generation !== options.generation ||
|
|
37
|
+
!workspace ||
|
|
38
|
+
options.registry.get(target.workspaceName) !== workspace
|
|
39
|
+
)
|
|
40
|
+
throw new NativeBrowserUnavailable();
|
|
41
|
+
};
|
|
42
|
+
assertCurrent();
|
|
43
|
+
if (readers >= 8) throw new NativeBrowserUnavailable();
|
|
44
|
+
readers++;
|
|
45
|
+
try {
|
|
46
|
+
const sessions = await options.catalog();
|
|
47
|
+
assertCurrent();
|
|
48
|
+
const matching = sessions.filter((s) => s.liveSessionId === target.liveSessionId);
|
|
49
|
+
if (matching.length !== 1 || matching[0]!.sessionName !== workspace!.sessionName)
|
|
50
|
+
throw new NativeBrowserUnavailable();
|
|
51
|
+
const snapshot = await options.inventory.discoverTerminalInventory(signal);
|
|
52
|
+
assertCurrent();
|
|
53
|
+
if (
|
|
54
|
+
snapshot.catalog.invalidRuntimeProof ||
|
|
55
|
+
snapshot.catalog.duplicateSemanticStamp ||
|
|
56
|
+
snapshot.catalog.duplicateRuntimePaneBinding
|
|
57
|
+
)
|
|
58
|
+
throw new NativeBrowserUnavailable();
|
|
59
|
+
const matches = snapshot.panes.filter(
|
|
60
|
+
(p) =>
|
|
61
|
+
p.workspaceName === target.workspaceName &&
|
|
62
|
+
p.semanticPaneId === target.semanticPaneId &&
|
|
63
|
+
p.sessionName === workspace!.sessionName,
|
|
64
|
+
);
|
|
65
|
+
if (matches.length !== 1) throw new NativeBrowserUnavailable();
|
|
66
|
+
const pane = { ...matches[0]! };
|
|
67
|
+
if (!pane.nativePaneBirthId || !/^%[0-9]+$/u.test(pane.runtimePaneId))
|
|
68
|
+
throw new NativeBrowserUnavailable();
|
|
69
|
+
const observe = async () => {
|
|
70
|
+
const raw = await options.run(
|
|
71
|
+
[
|
|
72
|
+
"display-message",
|
|
73
|
+
"-p",
|
|
74
|
+
"-t",
|
|
75
|
+
pane.runtimePaneId,
|
|
76
|
+
"#{qa:@tmux_ide_pane_id}\t#{qa:pane_birth_id}\t#{qa:@tmux_ide_browser_control}",
|
|
77
|
+
],
|
|
78
|
+
signal,
|
|
79
|
+
);
|
|
80
|
+
assertCurrent();
|
|
81
|
+
if (raw.length > 8192) throw new NativeBrowserUnavailable();
|
|
82
|
+
const [semantic, birth, endpoint] = raw.trimEnd().split("\t").map(decodeTmuxArgument);
|
|
83
|
+
if (semantic !== target.semanticPaneId || birth !== pane.nativePaneBirthId)
|
|
84
|
+
throw new NativeBrowserUnavailable();
|
|
85
|
+
const parsed = NativeBrowserEndpointSchema.parse(JSON.parse(endpoint ?? "null"));
|
|
86
|
+
if (parsed.pane !== pane.runtimePaneId) throw new NativeBrowserUnavailable();
|
|
87
|
+
return parsed;
|
|
88
|
+
};
|
|
89
|
+
const endpoint = await observe();
|
|
90
|
+
const result = await nativeBrowserRpc(endpoint, command, signal);
|
|
91
|
+
assertCurrent();
|
|
92
|
+
const current = await observe();
|
|
93
|
+
if (JSON.stringify(current) !== JSON.stringify(endpoint))
|
|
94
|
+
throw new NativeBrowserUnavailable();
|
|
95
|
+
return result;
|
|
96
|
+
} finally {
|
|
97
|
+
readers--;
|
|
98
|
+
}
|
|
99
|
+
};
|
|
100
|
+
}
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { createNativeBrowserResolver } from "./tmux-native-browser.ts";
|
|
1
2
|
import { createNativeSurfaceContextResolver } from "./native-surface-context.ts";
|
|
2
3
|
import type { LocalImageOwner } from "../terminal/graphics/local-image-owner.ts";
|
|
3
4
|
import { shellEscape } from "./shell.ts";
|
|
@@ -96,13 +97,23 @@ function discoveredWorkspaceName(registry: WorkspaceRegistry, sessionName: strin
|
|
|
96
97
|
throw new Error("Unable to allocate discovered workspace identity");
|
|
97
98
|
}
|
|
98
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
|
+
|
|
99
110
|
/** Refresh discovered live membership without changing durable workspace intent or aliases. */
|
|
100
111
|
export function createNativeTmuxServerCatalog(
|
|
101
112
|
workspaceRegistry: WorkspaceRegistry,
|
|
102
113
|
run: (args: readonly string[], signal?: AbortSignal) => Promise<string>,
|
|
103
114
|
assertOpen: () => void = () => undefined,
|
|
104
|
-
): () => Promise<
|
|
105
|
-
return async (): Promise<
|
|
115
|
+
): () => Promise<NativeLiveSessionSummary[]> {
|
|
116
|
+
return async (): Promise<NativeLiveSessionSummary[]> => {
|
|
106
117
|
assertOpen();
|
|
107
118
|
const raw = await run(
|
|
108
119
|
[
|
|
@@ -115,7 +126,8 @@ export function createNativeTmuxServerCatalog(
|
|
|
115
126
|
"list-panes",
|
|
116
127
|
"-a",
|
|
117
128
|
"-F",
|
|
118
|
-
|
|
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}",
|
|
119
131
|
]
|
|
120
132
|
.map(shellEscape)
|
|
121
133
|
.join(" "),
|
|
@@ -125,13 +137,20 @@ export function createNativeTmuxServerCatalog(
|
|
|
125
137
|
assertOpen();
|
|
126
138
|
const rows = new Map<
|
|
127
139
|
string,
|
|
128
|
-
{
|
|
140
|
+
{
|
|
141
|
+
summary: LiveSessionSummary;
|
|
142
|
+
panes: Set<string>;
|
|
143
|
+
windows: Set<string>;
|
|
144
|
+
dir: string;
|
|
145
|
+
activity: number | null;
|
|
146
|
+
}
|
|
129
147
|
>();
|
|
130
148
|
// Only the successful server-side zero-session branch proves emptiness.
|
|
131
149
|
// Command errors, unsupported formats and unwitnessed blank output stay failures.
|
|
132
150
|
const lines = raw.trim() === "tmux-ide-empty-catalog-v1" ? [] : raw.trim().split("\n");
|
|
133
151
|
for (const line of lines) {
|
|
134
|
-
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");
|
|
135
154
|
if (
|
|
136
155
|
!pid ||
|
|
137
156
|
!/^\d+$/.test(pid) ||
|
|
@@ -142,6 +161,8 @@ export function createNativeTmuxServerCatalog(
|
|
|
142
161
|
!name ||
|
|
143
162
|
!pane ||
|
|
144
163
|
!/^%\d+$/.test(pane) ||
|
|
164
|
+
!window ||
|
|
165
|
+
!/^@\d+$/.test(window) ||
|
|
145
166
|
!dir
|
|
146
167
|
)
|
|
147
168
|
throw new Error("Invalid native server catalog");
|
|
@@ -150,9 +171,12 @@ export function createNativeTmuxServerCatalog(
|
|
|
150
171
|
const row = rows.get(liveSessionId) ?? {
|
|
151
172
|
summary: { liveSessionId, sessionName: name, paneCount: 0 },
|
|
152
173
|
panes: new Set<string>(),
|
|
174
|
+
windows: new Set<string>(),
|
|
153
175
|
dir,
|
|
176
|
+
activity: activity && /^\d+$/.test(activity) ? Number(activity) : null,
|
|
154
177
|
};
|
|
155
178
|
row.panes.add(pane);
|
|
179
|
+
row.windows.add(window);
|
|
156
180
|
rows.set(liveSessionId, row);
|
|
157
181
|
}
|
|
158
182
|
const names = new Set([...rows.values()].map(({ summary }) => summary.sessionName));
|
|
@@ -169,7 +193,13 @@ export function createNativeTmuxServerCatalog(
|
|
|
169
193
|
projectDir: dir,
|
|
170
194
|
persistence: "volatile",
|
|
171
195
|
});
|
|
172
|
-
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
|
+
}));
|
|
173
203
|
};
|
|
174
204
|
}
|
|
175
205
|
|
|
@@ -619,6 +649,14 @@ export async function createNativeTmuxServerOwner(options: NativeTmuxServerOwner
|
|
|
619
649
|
run: (args, signal) => generationRunAsync(args, signal),
|
|
620
650
|
}),
|
|
621
651
|
resolvePaneEditorContext,
|
|
652
|
+
nativeBrowser: createNativeBrowserResolver({
|
|
653
|
+
generation,
|
|
654
|
+
assertOpen,
|
|
655
|
+
registry: workspaceRegistry,
|
|
656
|
+
catalog,
|
|
657
|
+
inventory: terminalInventoryRuntime,
|
|
658
|
+
run: generationRunAsync,
|
|
659
|
+
}),
|
|
622
660
|
catalog,
|
|
623
661
|
readWindowSplitLayout: async (workspaceName: string, input: WindowLinkTarget) => {
|
|
624
662
|
assertOpen();
|