tmux-ide 2.9.13 → 2.9.15

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.
Files changed (30) hide show
  1. package/bin/cli.js +2054 -1555
  2. package/package.json +1 -1
  3. package/packages/contracts/src/index.ts +1 -0
  4. package/packages/contracts/src/native-browser.ts +43 -0
  5. package/packages/contracts/src/pane-stream.ts +7 -0
  6. package/packages/daemon/dist/command-center/tmux-servers.js +6 -0
  7. package/packages/daemon/dist/lib/daemon-embed.js +15 -0
  8. package/packages/daemon/dist/lib/native-browser-rpc.js +110 -0
  9. package/packages/daemon/dist/lib/tmux-native-browser.js +80 -0
  10. package/packages/daemon/dist/lib/tmux-server-owner.js +9 -0
  11. package/packages/daemon/dist/native/TmuxIdeNotifier.app/Contents/Info.plist +2 -2
  12. package/packages/daemon/dist/native/TmuxIdeNotifier.app/Contents/MacOS/tmux-ide-notifier +0 -0
  13. package/packages/daemon/dist/native/TmuxIdeNotifier.app/Contents/Resources/Assets.car +0 -0
  14. package/packages/daemon/dist/native/TmuxIdeNotifier.app/Contents/_CodeSignature/CodeResources +2 -2
  15. package/packages/daemon/dist/open-browser.js +186 -0
  16. package/packages/daemon/dist/terminal/mirror/session-channel.js +6 -0
  17. package/packages/daemon/dist/terminal/pane-stream/pane-stream-websocket.js +9 -3
  18. package/packages/daemon/dist/terminal/protocol/browser-icon.js +16 -0
  19. package/packages/daemon/dist/terminal/protocol/session-descriptor-discovery.js +5 -2
  20. package/packages/daemon/src/command-center/tmux-servers.ts +11 -0
  21. package/packages/daemon/src/lib/daemon-embed.ts +17 -0
  22. package/packages/daemon/src/lib/native-browser-rpc.ts +118 -0
  23. package/packages/daemon/src/lib/tmux-native-browser.ts +100 -0
  24. package/packages/daemon/src/lib/tmux-server-owner.ts +9 -0
  25. package/packages/daemon/src/open-browser.ts +244 -0
  26. package/packages/daemon/src/terminal/mirror/events.ts +1 -0
  27. package/packages/daemon/src/terminal/mirror/session-channel.ts +6 -0
  28. package/packages/daemon/src/terminal/pane-stream/pane-stream-websocket.ts +10 -4
  29. package/packages/daemon/src/terminal/protocol/browser-icon.ts +14 -0
  30. package/packages/daemon/src/terminal/protocol/session-descriptor-discovery.ts +7 -1
@@ -1,3 +1,4 @@
1
+ import { browserIcon } from "./browser-icon.js";
1
2
  /**
2
3
  * `qa` asks tmux to escape each value as a command argument. In particular,
3
4
  * embedded tabs/newlines/backslashes become escapes, leaving our record tabs
@@ -27,6 +28,7 @@ export const SESSION_PANE_DESCRIPTOR_FORMAT = [
27
28
  "#{session_windows}",
28
29
  "#{pane_birth_id}",
29
30
  "#{qa:@tmux_ide_name_source}",
31
+ "#{qa:@tmux_ide_browser_icon}",
30
32
  ].join("\t");
31
33
  /** Decode the escapes emitted by tmux's `qa` format modifier. */
32
34
  export function decodeTmuxArgument(value) {
@@ -100,9 +102,9 @@ export function parseSessionPaneDescriptorReply(lines) {
100
102
  continue;
101
103
  }
102
104
  const encoded = utf8Line.split("\t");
103
- if (encoded.length !== 21 && encoded.length !== 22)
105
+ if (encoded.length !== 21 && encoded.length !== 22 && encoded.length !== 23)
104
106
  continue;
105
- const [runtimePaneId = "", semanticPaneId = "", role = "", type = "", currentCommand = "", cwd = "", windowIndexRaw = "", windowName = "", windowId = "", title = "", runtimeSessionId = "", paneIndexRaw = "", name = "", missionStamp = "", paneActiveRaw = "", windowActiveRaw = "", semanticWindowId = "", sessionName = "", windowPaneCountRaw = "", sessionWindowCountRaw = "", nativePaneBirthId = "", nameSource = "",] = encoded.map(decodeTmuxArgument);
107
+ const [runtimePaneId = "", semanticPaneId = "", role = "", type = "", currentCommand = "", cwd = "", windowIndexRaw = "", windowName = "", windowId = "", title = "", runtimeSessionId = "", paneIndexRaw = "", name = "", missionStamp = "", paneActiveRaw = "", windowActiveRaw = "", semanticWindowId = "", sessionName = "", windowPaneCountRaw = "", sessionWindowCountRaw = "", nativePaneBirthId = "", nameSource = "", browserIconRaw = "",] = encoded.map(decodeTmuxArgument);
106
108
  if (!/^%(?:0|[1-9][0-9]*)$/u.test(runtimePaneId) || runtimePaneId.length > 32)
107
109
  continue;
108
110
  if (!/^\$(?:0|[1-9][0-9]*)$/u.test(runtimeSessionId) || runtimeSessionId.length > 32)
@@ -139,6 +141,7 @@ export function parseSessionPaneDescriptorReply(lines) {
139
141
  continue;
140
142
  }
141
143
  descriptors.push({
144
+ browserIcon: browserIcon(browserIconRaw),
142
145
  sessionName,
143
146
  runtimePaneId,
144
147
  runtimeSessionId,
@@ -150,6 +150,17 @@ export function mountTmuxServerRoutes(app: Hono, options: TmuxServerRoutesOption
150
150
  owners: options.owners,
151
151
  readBody: (request) => boundedJson(request, 8 * 1024),
152
152
  });
153
+ app.post(
154
+ `${scoped}/native-browser`,
155
+ route(async (c) => {
156
+ const server = scope(c);
157
+ const input = await boundedJson(c.req.raw, 24 * 1024);
158
+ const state = await options.owners.withOwner(server, (owner) =>
159
+ owner.nativeBrowser(input, c.req.raw.signal),
160
+ );
161
+ return c.json({ version: 1, state });
162
+ }),
163
+ );
153
164
  // Structured read only: this never acquires a channel or changes tmux geometry.
154
165
  app.post(
155
166
  `${scoped}/split-layout/:workspaceName`,
@@ -1,3 +1,4 @@
1
+ import { createNativeBrowserResolver } from "./tmux-native-browser.ts";
1
2
  import { createNativeSurfaceContextResolver } from "./native-surface-context.ts";
2
3
  import { NativeSurfaceProviderRegistry } from "../terminal/graphics/native-surface-provider.ts";
3
4
  import { TmuxServerVersionSchemaZ } from "@tmux-ide/contracts";
@@ -1937,6 +1938,22 @@ async function startEmbeddedDaemonGeneration(
1937
1938
  throw new Error("Default tmux editor context owner is unavailable");
1938
1939
  },
1939
1940
  }),
1941
+ nativeBrowser: createNativeBrowserResolver({
1942
+ generation: instanceId,
1943
+ run: fleetFactsTmuxRunner,
1944
+ registry: workspaceRegistry,
1945
+ catalog: createNativeTmuxServerCatalog(workspaceRegistry, fleetFactsTmuxRunner),
1946
+ inventory: terminalInventoryRuntime,
1947
+ assertOpen: () => {
1948
+ if (
1949
+ defaultEditorContextRetired ||
1950
+ serverOwners?.defaultRetired ||
1951
+ !initialNativeServerIdentity ||
1952
+ terminalInventoryRuntime?.lifecycleState() !== "ready"
1953
+ )
1954
+ throw new Error("Default native browser owner is unavailable");
1955
+ },
1956
+ }),
1940
1957
  catalog: createNativeTmuxServerCatalog(workspaceRegistry, fleetFactsTmuxRunner),
1941
1958
  readWindowSplitLayout: async (workspaceName, input) => {
1942
1959
  const target = WindowLinkTargetSchemaZ.parse(input);
@@ -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,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";
@@ -619,6 +620,14 @@ export async function createNativeTmuxServerOwner(options: NativeTmuxServerOwner
619
620
  run: (args, signal) => generationRunAsync(args, signal),
620
621
  }),
621
622
  resolvePaneEditorContext,
623
+ nativeBrowser: createNativeBrowserResolver({
624
+ generation,
625
+ assertOpen,
626
+ registry: workspaceRegistry,
627
+ catalog,
628
+ inventory: terminalInventoryRuntime,
629
+ run: generationRunAsync,
630
+ }),
622
631
  catalog,
623
632
  readWindowSplitLayout: async (workspaceName: string, input: WindowLinkTarget) => {
624
633
  assertOpen();
@@ -0,0 +1,244 @@
1
+ import { accessSync, constants, realpathSync, statSync } from "node:fs";
2
+ import { delimiter, isAbsolute, join, resolve } from "node:path";
3
+ import { homedir } from "node:os";
4
+ import { IdeError } from "./lib/errors.ts";
5
+ import { resolveRuntimeNamespace, runtimeNamespaceEnvironment } from "./lib/runtime-namespace.ts";
6
+ import {
7
+ createPinnedWorkspaceTmuxRunner,
8
+ resolveWorkspacePaneTmuxAuthority,
9
+ } from "./lib/workspace-pane-creation.ts";
10
+
11
+ export interface OpenBrowserOptions {
12
+ session?: string;
13
+ pane?: string;
14
+ split?: string;
15
+ size?: string;
16
+ window?: boolean;
17
+ }
18
+
19
+ /** Resolve once before creating a pane; ignore relative PATH entries. */
20
+ export function resolveTerminalBrowser(
21
+ environment: NodeJS.ProcessEnv = process.env,
22
+ platform: NodeJS.Platform = process.platform,
23
+ ): string {
24
+ const configured = environment.TMUX_IDE_TERMINAL_BROWSER_BIN;
25
+ const home = environment.HOME ?? homedir();
26
+ // Resolve the managed alias on every invocation so an app update selects its
27
+ // matching browser. Native assets remain outside the universal npm package.
28
+ const bundled =
29
+ platform === "darwin" && isAbsolute(home)
30
+ ? [
31
+ join(
32
+ home,
33
+ "Library",
34
+ "Application Support",
35
+ "tmux-ide",
36
+ "native-alpha",
37
+ "TmuxIDE.app",
38
+ "Contents",
39
+ "Resources",
40
+ "browser",
41
+ "terminal-browser",
42
+ ),
43
+ ]
44
+ : [];
45
+ const candidates = configured
46
+ ? [configured]
47
+ : [
48
+ ...bundled,
49
+ ...(environment.PATH ?? "")
50
+ .split(delimiter)
51
+ .filter(isAbsolute)
52
+ .map((dir) => join(dir, "terminal-browser")),
53
+ ];
54
+ for (const candidate of candidates) {
55
+ try {
56
+ if (!isAbsolute(candidate)) continue;
57
+ accessSync(candidate, constants.X_OK);
58
+ const path = realpathSync(candidate);
59
+ if (statSync(path).isFile()) return path;
60
+ } catch {
61
+ /* Try the next executable. */
62
+ }
63
+ }
64
+ throw new IdeError(
65
+ configured
66
+ ? "TMUX_IDE_TERMINAL_BROWSER_BIN must name an absolute executable browser launcher."
67
+ : "terminal-browser is not installed or is not on PATH. Install terminal-browser, or set TMUX_IDE_TERMINAL_BROWSER_BIN to its absolute launcher path.",
68
+ { code: "BROWSER_UNAVAILABLE" },
69
+ );
70
+ }
71
+
72
+ function hasControlCharacter(value: string): boolean {
73
+ return Array.from(value).some(
74
+ (character) => character.charCodeAt(0) < 32 || character.charCodeAt(0) === 127,
75
+ );
76
+ }
77
+
78
+ export function validateBrowserOpen(url: string | undefined, options: OpenBrowserOptions): void {
79
+ if (
80
+ url !== undefined &&
81
+ (!url || url.length > 16_384 || hasControlCharacter(url) || url.startsWith("-"))
82
+ )
83
+ throw new IdeError("Provide one URL, localhost address, or HTML file path.", {
84
+ code: "USAGE",
85
+ exitCode: 2,
86
+ });
87
+ if (options.split !== undefined && !["right", "down"].includes(options.split))
88
+ throw new IdeError("--split must be right or down.", { code: "USAGE", exitCode: 2 });
89
+ if (
90
+ options.size !== undefined &&
91
+ (!/^\d+$/u.test(options.size) || Number(options.size) < 10 || Number(options.size) > 90)
92
+ )
93
+ throw new IdeError("--size must be a percentage from 10 to 90.", {
94
+ code: "USAGE",
95
+ exitCode: 2,
96
+ });
97
+ if (options.window && (options.split !== undefined || options.size !== undefined))
98
+ throw new IdeError("--window cannot be combined with --split or --size.", {
99
+ code: "USAGE",
100
+ exitCode: 2,
101
+ });
102
+ if (options.pane !== undefined && !/^%\d+$/u.test(options.pane))
103
+ throw new IdeError("--pane must be an exact tmux pane ID, such as %3.", {
104
+ code: "USAGE",
105
+ exitCode: 2,
106
+ });
107
+ if (
108
+ options.session !== undefined &&
109
+ (!options.session || hasControlCharacter(options.session) || options.session.includes(":"))
110
+ )
111
+ throw new IdeError("--session must be an exact tmux session name.", {
112
+ code: "USAGE",
113
+ exitCode: 2,
114
+ });
115
+ }
116
+
117
+ type Run = (args: readonly string[]) => string;
118
+ export function createBrowserPane(
119
+ run: Run,
120
+ executable: string,
121
+ url: string | undefined,
122
+ options: OpenBrowserOptions,
123
+ environment: NodeJS.ProcessEnv,
124
+ cwd: string,
125
+ namespace: Readonly<Record<string, string>>,
126
+ ): { pane: string; session: string; window: string; status: "created" } {
127
+ validateBrowserOpen(url, options);
128
+ const inheritedPane = /^%\d+$/u.test(environment.TMUX_PANE ?? "")
129
+ ? environment.TMUX_PANE
130
+ : undefined;
131
+ const inheritedServer = /^(.*),(\d+),(\d+)$/u.exec(environment.TMUX ?? "");
132
+ const implicit = !options.pane && !options.session;
133
+ const target =
134
+ options.pane ??
135
+ (options.session
136
+ ? `=${options.session}:`
137
+ : inheritedPane && inheritedServer
138
+ ? `$${inheritedServer[3]}:.${inheritedPane}`
139
+ : undefined);
140
+ if (!target)
141
+ throw new IdeError(
142
+ "Run tmux-ide open inside a tmux pane, or select a session with --session NAME.",
143
+ { code: "BROWSER_TARGET_REQUIRED", exitCode: 2 },
144
+ );
145
+ // Resolve IDs before mutation. No implicit last-used session or window target.
146
+ const identity = run([
147
+ "display-message",
148
+ "-p",
149
+ "-t",
150
+ target,
151
+ "#{session_id}\t#{window_id}\t#{pane_id}\t#{pid}",
152
+ ])
153
+ .trim()
154
+ .split("\t");
155
+ const [session, window, sourcePane, serverPid] = identity;
156
+ if (
157
+ identity.length !== 4 ||
158
+ !/^\$\d+$/u.test(session ?? "") ||
159
+ !/^@\d+$/u.test(window ?? "") ||
160
+ !/^%\d+$/u.test(sourcePane ?? "")
161
+ )
162
+ throw new IdeError("The browser target is no longer available.", {
163
+ code: "BROWSER_TARGET_UNAVAILABLE",
164
+ });
165
+ if (implicit && serverPid !== inheritedServer?.[2])
166
+ throw new IdeError(
167
+ "The current pane belongs to a different tmux server. Select a session explicitly.",
168
+ { code: "BROWSER_TARGET_MISMATCH", exitCode: 2 },
169
+ );
170
+ if (options.session && options.pane) {
171
+ const selected = run([
172
+ "display-message",
173
+ "-p",
174
+ "-t",
175
+ `=${options.session}:`,
176
+ "#{session_id}",
177
+ ]).trim();
178
+ if (selected !== session)
179
+ throw new IdeError("The pane does not belong to the requested session.", {
180
+ code: "BROWSER_TARGET_MISMATCH",
181
+ exitCode: 2,
182
+ });
183
+ }
184
+ const args = options.window
185
+ ? ["new-window", "-t", `${session}:`, "-n", "Browser"]
186
+ : [
187
+ "split-window",
188
+ options.split === "down" ? "-v" : "-h",
189
+ "-t",
190
+ `${session}:${window}.${sourcePane}`,
191
+ "-l",
192
+ `${options.size ?? "50"}%`,
193
+ ];
194
+ args.push("-c", resolve(cwd), "-P", "-F", "#{session_id}\t#{window_id}\t#{pane_id}");
195
+ // tmux supplies the new pane's TMUX and TMUX_PANE. Never copy the caller's
196
+ // native-surface route, which belongs to a different pane.
197
+ const childEnvironment: Record<string, string> = { ...namespace };
198
+ for (const key of [
199
+ "PATH",
200
+ "HOME",
201
+ "XDG_CONFIG_HOME",
202
+ "XDG_DATA_HOME",
203
+ "XDG_STATE_HOME",
204
+ "XDG_CACHE_HOME",
205
+ "XDG_RUNTIME_DIR",
206
+ ])
207
+ if (environment[key] !== undefined) childEnvironment[key] = environment[key];
208
+ for (const [key, value] of Object.entries(childEnvironment)) args.push("-e", `${key}=${value}`);
209
+ // Multiple command arguments use tmux's direct exec path, not shell parsing.
210
+ args.push(executable, "open", ...(url === undefined ? [] : [url]), "--no-merge");
211
+ const [createdSession, createdWindow, pane] = run(args).trim().split("\t");
212
+ if (
213
+ !/^%\d+$/u.test(pane ?? "") ||
214
+ createdSession !== session ||
215
+ !/^@\d+$/u.test(createdWindow ?? "")
216
+ )
217
+ throw new IdeError("tmux did not return the created browser pane ID.", {
218
+ code: "BROWSER_CREATE_FAILED",
219
+ });
220
+ return { pane: pane!, session: createdSession!, window: createdWindow!, status: "created" };
221
+ }
222
+
223
+ export function openBrowser(url: string | undefined, options: OpenBrowserOptions = {}) {
224
+ validateBrowserOpen(url, options);
225
+ const executable = resolveTerminalBrowser();
226
+ try {
227
+ const namespace = { ...runtimeNamespaceEnvironment(resolveRuntimeNamespace()) };
228
+ const authority = resolveWorkspacePaneTmuxAuthority();
229
+ namespace.TMUX_IDE_BROWSER_TMUX = authority.executablePath;
230
+ const run = createPinnedWorkspaceTmuxRunner(authority, {
231
+ timeoutMs: 10_000,
232
+ });
233
+ return createBrowserPane(run, executable, url, options, process.env, process.cwd(), namespace);
234
+ } catch (error) {
235
+ if (error instanceof IdeError) throw error;
236
+ throw new IdeError(
237
+ "Could not create the browser pane. Check that the target tmux session is running.",
238
+ {
239
+ code: "BROWSER_CREATE_FAILED",
240
+ cause: error instanceof Error ? error : undefined,
241
+ },
242
+ );
243
+ }
244
+ }
@@ -91,6 +91,7 @@ export type MirrorPaneEvent =
91
91
 
92
92
  /** One visible pane rectangle inside a layout event, in window cells. */
93
93
  export interface MirrorLayoutPane {
94
+ browserIcon?: string | null;
94
95
  /** Null while the pane's semantic identity join is still unverified. */
95
96
  semanticPaneId: string | null;
96
97
  /** Daemon-authored presentation; clients never infer this from terminal bytes. */
@@ -749,6 +749,7 @@ export class SessionChannel {
749
749
  async start(): Promise<void> {
750
750
  await this.io.start();
751
751
  await this.captureAttachedSessionIdentity();
752
+ this.io.send("refresh-client -B 'tmux-ide-browser-icon:%*:#{@tmux_ide_browser_icon}'");
752
753
  // Changing border placement can resize PTYs without changing the layout
753
754
  // string, so tmux emits no layout-change. Subscribe to this window option.
754
755
  this.io.send("refresh-client -B 'tmux-ide-pane-borders:@*:#{pane-border-status}'");
@@ -2883,6 +2884,10 @@ export class SessionChannel {
2883
2884
  name === "sessions-changed"
2884
2885
  )
2885
2886
  this.opts.onWindowTopologyChanged?.();
2887
+ if (name === "subscription-changed" && rest.startsWith("tmux-ide-browser-icon ")) {
2888
+ this.scheduleSync();
2889
+ return;
2890
+ }
2886
2891
  if (name === "subscription-changed") {
2887
2892
  const policy =
2888
2893
  /^tmux-ide-scroll-on-clear\s+\$[0-9]+\s+@[0-9]+\s+[0-9]+\s+(%[0-9]+)\s+:\s+([01])\s*$/u.exec(
@@ -3299,6 +3304,7 @@ export class SessionChannel {
3299
3304
  semanticPaneId: pane?.semanticId ?? null,
3300
3305
  displayName: display?.name ?? null,
3301
3306
  displayNameSource: display?.source ?? null,
3307
+ browserIcon: pane?.descriptor?.browserIcon ?? null,
3302
3308
  left: leaf.left,
3303
3309
  top: leaf.top,
3304
3310
  width: leaf.width,
@@ -1515,8 +1515,11 @@ export class PaneStreamLiveConnection {
1515
1515
  this.close(1011, "stream-unavailable");
1516
1516
  return;
1517
1517
  }
1518
- const expectedPaneIds = [...this.#descriptor.panes].sort();
1519
- if (expectedPaneIds.some((pane) => !known.has(pane))) {
1518
+ // Layout authority covers the whole session, while content leases may select
1519
+ // only one pane (for example a native browser observing its own canvas).
1520
+ // Keep the full inventory fence without subscribing to unleased content.
1521
+ const expectedPaneIds = [...known].sort();
1522
+ if (this.#descriptor.panes.some((pane) => !known.has(pane))) {
1520
1523
  this.#failTopologyChanged();
1521
1524
  return;
1522
1525
  }
@@ -1917,8 +1920,8 @@ export class PaneStreamLiveConnection {
1917
1920
  // Renderers that cannot inflate see raw formats; their bytes are inflated
1918
1921
  // when served.
1919
1922
  const zlib =
1920
- this.#descriptor.terminalDelivery?.protocolVersions.includes(
1921
- TERMINAL_GRAPHICS_ZLIB_CAPABILITY_VERSION,
1923
+ this.#descriptor.terminalDelivery?.protocolVersions.some(
1924
+ (version) => version === TERMINAL_GRAPHICS_ZLIB_CAPABILITY_VERSION || version === 117,
1922
1925
  ) === true;
1923
1926
  channel.graphicsZlib = zlib;
1924
1927
  channel.graphicsLocal =
@@ -2414,6 +2417,9 @@ export class PaneStreamLiveConnection {
2414
2417
  pane: pane.semanticPaneId,
2415
2418
  displayName: pane.displayName,
2416
2419
  displayNameSource: pane.displayNameSource,
2420
+ ...(this.#descriptor.terminalDelivery?.protocolVersions.includes(117)
2421
+ ? { browserIcon: pane.browserIcon ?? null }
2422
+ : {}),
2417
2423
  left: pane.left,
2418
2424
  top: pane.top,
2419
2425
  width: pane.width,
@@ -0,0 +1,14 @@
1
+ /** Browser-provided 16×16 RGBA pixels, never a URL or filesystem path. */
2
+ export function browserIcon(value: string, alive = processAlive): string | null {
3
+ const match = /^([1-9][0-9]{0,9}):([0-9a-f]{2048}|)$/u.exec(value);
4
+ if (!match || !alive(Number(match[1]))) return null;
5
+ return match[2]!;
6
+ }
7
+ function processAlive(pid: number): boolean {
8
+ try {
9
+ process.kill(pid, 0);
10
+ return true;
11
+ } catch {
12
+ return false;
13
+ }
14
+ }