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
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "tmux-ide",
3
- "version": "2.9.13",
3
+ "version": "2.9.15",
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": {
@@ -116,6 +116,7 @@ export * from "./visual-terminal-palette.ts";
116
116
 
117
117
  export * from "./window-split-layout.ts";
118
118
  export * from "./pane-editor-context.ts";
119
+ export * from "./native-browser.ts";
119
120
 
120
121
  export * from "./tmux-agent-open.ts";
121
122
 
@@ -0,0 +1,43 @@
1
+ import { z } from "zod";
2
+
3
+ const text = (limit: number) =>
4
+ z
5
+ .string()
6
+ .max(limit)
7
+ // Intentionally reject control characters in display text and navigation input.
8
+ // eslint-disable-next-line no-control-regex
9
+ .refine((value) => !/[\x00-\x1f\x7f]/u.test(value));
10
+ const identity = {
11
+ version: z.literal(1),
12
+ instance: z.uuid(),
13
+ pane: z
14
+ .string()
15
+ .regex(/^%[0-9]+$/u)
16
+ .max(32),
17
+ tab: z.number().int().positive().max(Number.MAX_SAFE_INTEGER),
18
+ revision: z.number().int().positive().max(Number.MAX_SAFE_INTEGER),
19
+ };
20
+ /** Display state is not authority: every command echoes the exact browser revision. */
21
+ export const NativeBrowserStateSchemaZ = z
22
+ .object({
23
+ ...identity,
24
+ enabled: z.boolean(),
25
+ url: text(16_384),
26
+ title: text(512),
27
+ loading: z.boolean(),
28
+ canGoBack: z.boolean(),
29
+ canGoForward: z.boolean(),
30
+ })
31
+ .strict();
32
+ export const NativeBrowserCommandSchemaZ = z.discriminatedUnion("action", [
33
+ z.object({ ...identity, action: z.enum(["back", "forward", "reload", "stop"]) }).strict(),
34
+ z
35
+ .object({
36
+ ...identity,
37
+ action: z.literal("navigate"),
38
+ text: text(16_384).refine((value) => value.trim().length > 0),
39
+ })
40
+ .strict(),
41
+ ]);
42
+ export type NativeBrowserState = z.infer<typeof NativeBrowserStateSchemaZ>;
43
+ export type NativeBrowserCommand = z.infer<typeof NativeBrowserCommandSchemaZ>;
@@ -249,6 +249,8 @@ export const PaneStreamKeyNameSchemaZ = SessionRuntimeTerminalKeyNameSchemaZ;
249
249
  export const PANE_STREAM_INPUT_LIFETIME_CAPABILITY_VERSION = 105;
250
250
  /** Exact hex bytes require explicit offer + ready acknowledgement; older daemons may omit it. */
251
251
  export const PANE_STREAM_INPUT_BYTES_CAPABILITY_VERSION = 106;
252
+ /** Bundles zlib capability 102 and optional browser icon metadata; fixed 16×16 RGBA hex, empty means browser fallback. */
253
+ export const PANE_STREAM_BROWSER_ICON_CAPABILITY_VERSION = 117;
252
254
  export const PaneStreamInputLifetimeSchemaZ = z
253
255
  .object({
254
256
  generation: z.string().min(1).max(256),
@@ -558,6 +560,11 @@ export const PaneStreamLayoutFrameSchemaZ = z
558
560
  /** Optional for compatibility with an older daemon during reconnect. */
559
561
  displayName: BoundedDisplayNameSchemaZ.nullable().optional(),
560
562
  displayNameSource: PaneDisplayNameSourceSchemaZ.nullable().optional(),
563
+ browserIcon: z
564
+ .string()
565
+ .regex(/^(?:[0-9a-f]{2048})?$/u)
566
+ .nullable()
567
+ .optional(),
561
568
  left: CellCoordinateSchemaZ,
562
569
  top: CellCoordinateSchemaZ,
563
570
  width: GridCellSchemaZ,
@@ -107,6 +107,12 @@ export function mountTmuxServerRoutes(app, options) {
107
107
  owners: options.owners,
108
108
  readBody: (request) => boundedJson(request, 8 * 1024),
109
109
  });
110
+ app.post(`${scoped}/native-browser`, route(async (c) => {
111
+ const server = scope(c);
112
+ const input = await boundedJson(c.req.raw, 24 * 1024);
113
+ const state = await options.owners.withOwner(server, (owner) => owner.nativeBrowser(input, c.req.raw.signal));
114
+ return c.json({ version: 1, state });
115
+ }));
110
116
  // Structured read only: this never acquires a channel or changes tmux geometry.
111
117
  app.post(`${scoped}/split-layout/:workspaceName`, route(async (c) => {
112
118
  const server = scope(c);
@@ -6,6 +6,7 @@ var __rewriteRelativeImportExtension = (this && this.__rewriteRelativeImportExte
6
6
  }
7
7
  return path;
8
8
  };
9
+ import { createNativeBrowserResolver } from "./tmux-native-browser.js";
9
10
  import { createNativeSurfaceContextResolver } from "./native-surface-context.js";
10
11
  import { NativeSurfaceProviderRegistry } from "../terminal/graphics/native-surface-provider.js";
11
12
  import { TmuxServerVersionSchemaZ } from "@tmux-ide/contracts";
@@ -1397,6 +1398,20 @@ async function startEmbeddedDaemonGeneration(opts) {
1397
1398
  throw new Error("Default tmux editor context owner is unavailable");
1398
1399
  },
1399
1400
  }),
1401
+ nativeBrowser: createNativeBrowserResolver({
1402
+ generation: instanceId,
1403
+ run: fleetFactsTmuxRunner,
1404
+ registry: workspaceRegistry,
1405
+ catalog: createNativeTmuxServerCatalog(workspaceRegistry, fleetFactsTmuxRunner),
1406
+ inventory: terminalInventoryRuntime,
1407
+ assertOpen: () => {
1408
+ if (defaultEditorContextRetired ||
1409
+ serverOwners?.defaultRetired ||
1410
+ !initialNativeServerIdentity ||
1411
+ terminalInventoryRuntime?.lifecycleState() !== "ready")
1412
+ throw new Error("Default native browser owner is unavailable");
1413
+ },
1414
+ }),
1400
1415
  catalog: createNativeTmuxServerCatalog(workspaceRegistry, fleetFactsTmuxRunner),
1401
1416
  readWindowSplitLayout: async (workspaceName, input) => {
1402
1417
  const target = WindowLinkTargetSchemaZ.parse(input);
@@ -0,0 +1,110 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import { lstat } from "node:fs/promises";
3
+ import { isAbsolute } from "node:path";
4
+ import { connect } from "node:net";
5
+ import { NativeBrowserCommandSchemaZ, NativeBrowserStateSchemaZ, } from "@tmux-ide/contracts";
6
+ import { z } from "zod";
7
+ export const NativeBrowserEndpointSchema = z
8
+ .object({
9
+ version: z.literal(1),
10
+ pid: z.number().int().positive(),
11
+ instance: z.uuid(),
12
+ pane: z
13
+ .string()
14
+ .regex(/^%[0-9]+$/u)
15
+ .max(32),
16
+ socket: z.string().min(1).max(1024).refine(isAbsolute),
17
+ })
18
+ .strict();
19
+ export class NativeBrowserUnavailable extends Error {
20
+ constructor() {
21
+ super("Native browser controls are unavailable");
22
+ }
23
+ }
24
+ const responseSchema = z.discriminatedUnion("ok", [
25
+ z
26
+ .object({ id: z.string(), ok: z.literal(true), data: NativeBrowserStateSchemaZ.nullable() })
27
+ .strict(),
28
+ z.object({ id: z.string(), ok: z.literal(false), error: z.string().max(4096) }).strict(),
29
+ ]);
30
+ /** One request, one bounded response. Mutations are never retried or refreshed. */
31
+ export async function nativeBrowserRpc(endpoint, command, signal) {
32
+ const target = NativeBrowserEndpointSchema.parse(endpoint);
33
+ const payload = command ? NativeBrowserCommandSchemaZ.parse(command) : null;
34
+ if (payload && (payload.instance !== target.instance || payload.pane !== target.pane))
35
+ throw new NativeBrowserUnavailable();
36
+ signal.throwIfAborted();
37
+ const info = await lstat(target.socket);
38
+ if (!info.isSocket() || info.uid !== process.getuid?.() || (info.mode & 0o022) !== 0)
39
+ throw new NativeBrowserUnavailable();
40
+ try {
41
+ process.kill(target.pid, 0);
42
+ }
43
+ catch {
44
+ throw new NativeBrowserUnavailable();
45
+ }
46
+ signal.throwIfAborted();
47
+ const id = randomUUID();
48
+ const request = JSON.stringify({
49
+ id,
50
+ cmd: payload ? "native/1/command" : "native/1/state",
51
+ ...(payload ? { payload } : {}),
52
+ }) + "\n";
53
+ return new Promise((resolve, reject) => {
54
+ const socket = connect(target.socket);
55
+ let finished = false;
56
+ let size = 0;
57
+ const chunks = [];
58
+ const finish = (error, state = null) => {
59
+ if (finished)
60
+ return;
61
+ finished = true;
62
+ clearTimeout(timer);
63
+ signal.removeEventListener("abort", abort);
64
+ socket.destroy();
65
+ if (error)
66
+ reject(error);
67
+ else
68
+ resolve(state);
69
+ };
70
+ const abort = () => finish(new NativeBrowserUnavailable());
71
+ const timer = setTimeout(abort, 1500);
72
+ signal.addEventListener("abort", abort, { once: true });
73
+ if (signal.aborted) {
74
+ abort();
75
+ return;
76
+ }
77
+ socket.once("connect", () => socket.write(request));
78
+ socket.on("error", abort);
79
+ socket.once("end", () => {
80
+ if (!finished)
81
+ abort();
82
+ });
83
+ socket.on("data", (chunk) => {
84
+ size += chunk.length;
85
+ if (size > 128 * 1024) {
86
+ abort();
87
+ return;
88
+ }
89
+ chunks.push(chunk);
90
+ if (!chunk.includes(10))
91
+ return;
92
+ try {
93
+ const bytes = Buffer.concat(chunks);
94
+ const newline = bytes.indexOf(10);
95
+ if (newline !== bytes.length - 1)
96
+ throw new NativeBrowserUnavailable();
97
+ const reply = responseSchema.parse(JSON.parse(new TextDecoder("utf-8", { fatal: true }).decode(bytes.subarray(0, newline))));
98
+ if (reply.id !== id || !reply.ok)
99
+ throw new NativeBrowserUnavailable();
100
+ if (reply.data &&
101
+ (reply.data.instance !== target.instance || reply.data.pane !== target.pane))
102
+ throw new NativeBrowserUnavailable();
103
+ finish(undefined, reply.data);
104
+ }
105
+ catch {
106
+ abort();
107
+ }
108
+ });
109
+ });
110
+ }
@@ -0,0 +1,80 @@
1
+ import { PaneEditorContextTargetSchemaZ, NativeBrowserCommandSchemaZ } from "@tmux-ide/contracts";
2
+ import { z } from "zod";
3
+ import { decodeTmuxArgument } from "../terminal/protocol/session-descriptor-discovery.js";
4
+ import { NativeBrowserEndpointSchema, NativeBrowserUnavailable, nativeBrowserRpc, } from "./native-browser-rpc.js";
5
+ export const NativeBrowserRequestSchema = z
6
+ .object({
7
+ target: PaneEditorContextTargetSchemaZ,
8
+ command: NativeBrowserCommandSchemaZ.nullable(),
9
+ })
10
+ .strict();
11
+ export function createNativeBrowserResolver(options) {
12
+ let readers = 0;
13
+ return async (input, cancellation) => {
14
+ const { target, command } = NativeBrowserRequestSchema.parse(input);
15
+ const signal = AbortSignal.any([cancellation, AbortSignal.timeout(2500)]);
16
+ const workspace = options.registry.get(target.workspaceName);
17
+ const assertCurrent = () => {
18
+ options.assertOpen();
19
+ signal.throwIfAborted();
20
+ if (target.generation !== options.generation ||
21
+ !workspace ||
22
+ options.registry.get(target.workspaceName) !== workspace)
23
+ throw new NativeBrowserUnavailable();
24
+ };
25
+ assertCurrent();
26
+ if (readers >= 8)
27
+ throw new NativeBrowserUnavailable();
28
+ readers++;
29
+ try {
30
+ const sessions = await options.catalog();
31
+ assertCurrent();
32
+ const matching = sessions.filter((s) => s.liveSessionId === target.liveSessionId);
33
+ if (matching.length !== 1 || matching[0].sessionName !== workspace.sessionName)
34
+ throw new NativeBrowserUnavailable();
35
+ const snapshot = await options.inventory.discoverTerminalInventory(signal);
36
+ assertCurrent();
37
+ if (snapshot.catalog.invalidRuntimeProof ||
38
+ snapshot.catalog.duplicateSemanticStamp ||
39
+ snapshot.catalog.duplicateRuntimePaneBinding)
40
+ throw new NativeBrowserUnavailable();
41
+ const matches = snapshot.panes.filter((p) => p.workspaceName === target.workspaceName &&
42
+ p.semanticPaneId === target.semanticPaneId &&
43
+ p.sessionName === workspace.sessionName);
44
+ if (matches.length !== 1)
45
+ throw new NativeBrowserUnavailable();
46
+ const pane = { ...matches[0] };
47
+ if (!pane.nativePaneBirthId || !/^%[0-9]+$/u.test(pane.runtimePaneId))
48
+ throw new NativeBrowserUnavailable();
49
+ const observe = async () => {
50
+ const raw = await options.run([
51
+ "display-message",
52
+ "-p",
53
+ "-t",
54
+ pane.runtimePaneId,
55
+ "#{qa:@tmux_ide_pane_id}\t#{qa:pane_birth_id}\t#{qa:@tmux_ide_browser_control}",
56
+ ], signal);
57
+ assertCurrent();
58
+ if (raw.length > 8192)
59
+ throw new NativeBrowserUnavailable();
60
+ const [semantic, birth, endpoint] = raw.trimEnd().split("\t").map(decodeTmuxArgument);
61
+ if (semantic !== target.semanticPaneId || birth !== pane.nativePaneBirthId)
62
+ throw new NativeBrowserUnavailable();
63
+ const parsed = NativeBrowserEndpointSchema.parse(JSON.parse(endpoint ?? "null"));
64
+ if (parsed.pane !== pane.runtimePaneId)
65
+ throw new NativeBrowserUnavailable();
66
+ return parsed;
67
+ };
68
+ const endpoint = await observe();
69
+ const result = await nativeBrowserRpc(endpoint, command, signal);
70
+ assertCurrent();
71
+ const current = await observe();
72
+ if (JSON.stringify(current) !== JSON.stringify(endpoint))
73
+ throw new NativeBrowserUnavailable();
74
+ return result;
75
+ }
76
+ finally {
77
+ readers--;
78
+ }
79
+ };
80
+ }
@@ -1,3 +1,4 @@
1
+ import { createNativeBrowserResolver } from "./tmux-native-browser.js";
1
2
  import { createNativeSurfaceContextResolver } from "./native-surface-context.js";
2
3
  import { shellEscape } from "./shell.js";
3
4
  import { createNativeSplitCapabilityProbe, createNativeSplitReadiness, } from "./native-split-capability.js";
@@ -505,6 +506,14 @@ export async function createNativeTmuxServerOwner(options) {
505
506
  run: (args, signal) => generationRunAsync(args, signal),
506
507
  }),
507
508
  resolvePaneEditorContext,
509
+ nativeBrowser: createNativeBrowserResolver({
510
+ generation,
511
+ assertOpen,
512
+ registry: workspaceRegistry,
513
+ catalog,
514
+ inventory: terminalInventoryRuntime,
515
+ run: generationRunAsync,
516
+ }),
508
517
  catalog,
509
518
  readWindowSplitLayout: async (workspaceName, input) => {
510
519
  assertOpen();
@@ -21,9 +21,9 @@
21
21
  <key>CFBundlePackageType</key>
22
22
  <string>APPL</string>
23
23
  <key>CFBundleShortVersionString</key>
24
- <string>2.9.13</string>
24
+ <string>2.9.15</string>
25
25
  <key>CFBundleVersion</key>
26
- <string>2.9.13</string>
26
+ <string>2.9.15</string>
27
27
  <key>LSMinimumSystemVersion</key>
28
28
  <string>11.0</string>
29
29
  <key>LSUIElement</key>
@@ -10,7 +10,7 @@
10
10
  </data>
11
11
  <key>Resources/Assets.car</key>
12
12
  <data>
13
- 6Tstp1dls1WRUQD+/DDuIhts8/g=
13
+ g6ppKlYeW830G3DwzRm9o0xINW8=
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
- 5CnAgYS5jeHfuf40WhEyRn9BbniU54houDrGdNEkNyI=
29
+ lo9t3TSww0mSpC4nN+YoeeUgaXGcFCrv53qBXtKpzEY=
30
30
  </data>
31
31
  </dict>
32
32
  </dict>
@@ -0,0 +1,186 @@
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.js";
5
+ import { resolveRuntimeNamespace, runtimeNamespaceEnvironment } from "./lib/runtime-namespace.js";
6
+ import { createPinnedWorkspaceTmuxRunner, resolveWorkspacePaneTmuxAuthority, } from "./lib/workspace-pane-creation.js";
7
+ /** Resolve once before creating a pane; ignore relative PATH entries. */
8
+ export function resolveTerminalBrowser(environment = process.env, platform = process.platform) {
9
+ const configured = environment.TMUX_IDE_TERMINAL_BROWSER_BIN;
10
+ const home = environment.HOME ?? homedir();
11
+ // Resolve the managed alias on every invocation so an app update selects its
12
+ // matching browser. Native assets remain outside the universal npm package.
13
+ const bundled = platform === "darwin" && isAbsolute(home)
14
+ ? [
15
+ join(home, "Library", "Application Support", "tmux-ide", "native-alpha", "TmuxIDE.app", "Contents", "Resources", "browser", "terminal-browser"),
16
+ ]
17
+ : [];
18
+ const candidates = configured
19
+ ? [configured]
20
+ : [
21
+ ...bundled,
22
+ ...(environment.PATH ?? "")
23
+ .split(delimiter)
24
+ .filter(isAbsolute)
25
+ .map((dir) => join(dir, "terminal-browser")),
26
+ ];
27
+ for (const candidate of candidates) {
28
+ try {
29
+ if (!isAbsolute(candidate))
30
+ continue;
31
+ accessSync(candidate, constants.X_OK);
32
+ const path = realpathSync(candidate);
33
+ if (statSync(path).isFile())
34
+ return path;
35
+ }
36
+ catch {
37
+ /* Try the next executable. */
38
+ }
39
+ }
40
+ throw new IdeError(configured
41
+ ? "TMUX_IDE_TERMINAL_BROWSER_BIN must name an absolute executable browser launcher."
42
+ : "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.", { code: "BROWSER_UNAVAILABLE" });
43
+ }
44
+ function hasControlCharacter(value) {
45
+ return Array.from(value).some((character) => character.charCodeAt(0) < 32 || character.charCodeAt(0) === 127);
46
+ }
47
+ export function validateBrowserOpen(url, options) {
48
+ if (url !== undefined &&
49
+ (!url || url.length > 16_384 || hasControlCharacter(url) || url.startsWith("-")))
50
+ throw new IdeError("Provide one URL, localhost address, or HTML file path.", {
51
+ code: "USAGE",
52
+ exitCode: 2,
53
+ });
54
+ if (options.split !== undefined && !["right", "down"].includes(options.split))
55
+ throw new IdeError("--split must be right or down.", { code: "USAGE", exitCode: 2 });
56
+ if (options.size !== undefined &&
57
+ (!/^\d+$/u.test(options.size) || Number(options.size) < 10 || Number(options.size) > 90))
58
+ throw new IdeError("--size must be a percentage from 10 to 90.", {
59
+ code: "USAGE",
60
+ exitCode: 2,
61
+ });
62
+ if (options.window && (options.split !== undefined || options.size !== undefined))
63
+ throw new IdeError("--window cannot be combined with --split or --size.", {
64
+ code: "USAGE",
65
+ exitCode: 2,
66
+ });
67
+ if (options.pane !== undefined && !/^%\d+$/u.test(options.pane))
68
+ throw new IdeError("--pane must be an exact tmux pane ID, such as %3.", {
69
+ code: "USAGE",
70
+ exitCode: 2,
71
+ });
72
+ if (options.session !== undefined &&
73
+ (!options.session || hasControlCharacter(options.session) || options.session.includes(":")))
74
+ throw new IdeError("--session must be an exact tmux session name.", {
75
+ code: "USAGE",
76
+ exitCode: 2,
77
+ });
78
+ }
79
+ export function createBrowserPane(run, executable, url, options, environment, cwd, namespace) {
80
+ validateBrowserOpen(url, options);
81
+ const inheritedPane = /^%\d+$/u.test(environment.TMUX_PANE ?? "")
82
+ ? environment.TMUX_PANE
83
+ : undefined;
84
+ const inheritedServer = /^(.*),(\d+),(\d+)$/u.exec(environment.TMUX ?? "");
85
+ const implicit = !options.pane && !options.session;
86
+ const target = options.pane ??
87
+ (options.session
88
+ ? `=${options.session}:`
89
+ : inheritedPane && inheritedServer
90
+ ? `$${inheritedServer[3]}:.${inheritedPane}`
91
+ : undefined);
92
+ if (!target)
93
+ throw new IdeError("Run tmux-ide open inside a tmux pane, or select a session with --session NAME.", { code: "BROWSER_TARGET_REQUIRED", exitCode: 2 });
94
+ // Resolve IDs before mutation. No implicit last-used session or window target.
95
+ const identity = run([
96
+ "display-message",
97
+ "-p",
98
+ "-t",
99
+ target,
100
+ "#{session_id}\t#{window_id}\t#{pane_id}\t#{pid}",
101
+ ])
102
+ .trim()
103
+ .split("\t");
104
+ const [session, window, sourcePane, serverPid] = identity;
105
+ if (identity.length !== 4 ||
106
+ !/^\$\d+$/u.test(session ?? "") ||
107
+ !/^@\d+$/u.test(window ?? "") ||
108
+ !/^%\d+$/u.test(sourcePane ?? ""))
109
+ throw new IdeError("The browser target is no longer available.", {
110
+ code: "BROWSER_TARGET_UNAVAILABLE",
111
+ });
112
+ if (implicit && serverPid !== inheritedServer?.[2])
113
+ throw new IdeError("The current pane belongs to a different tmux server. Select a session explicitly.", { code: "BROWSER_TARGET_MISMATCH", exitCode: 2 });
114
+ if (options.session && options.pane) {
115
+ const selected = run([
116
+ "display-message",
117
+ "-p",
118
+ "-t",
119
+ `=${options.session}:`,
120
+ "#{session_id}",
121
+ ]).trim();
122
+ if (selected !== session)
123
+ throw new IdeError("The pane does not belong to the requested session.", {
124
+ code: "BROWSER_TARGET_MISMATCH",
125
+ exitCode: 2,
126
+ });
127
+ }
128
+ const args = options.window
129
+ ? ["new-window", "-t", `${session}:`, "-n", "Browser"]
130
+ : [
131
+ "split-window",
132
+ options.split === "down" ? "-v" : "-h",
133
+ "-t",
134
+ `${session}:${window}.${sourcePane}`,
135
+ "-l",
136
+ `${options.size ?? "50"}%`,
137
+ ];
138
+ args.push("-c", resolve(cwd), "-P", "-F", "#{session_id}\t#{window_id}\t#{pane_id}");
139
+ // tmux supplies the new pane's TMUX and TMUX_PANE. Never copy the caller's
140
+ // native-surface route, which belongs to a different pane.
141
+ const childEnvironment = { ...namespace };
142
+ for (const key of [
143
+ "PATH",
144
+ "HOME",
145
+ "XDG_CONFIG_HOME",
146
+ "XDG_DATA_HOME",
147
+ "XDG_STATE_HOME",
148
+ "XDG_CACHE_HOME",
149
+ "XDG_RUNTIME_DIR",
150
+ ])
151
+ if (environment[key] !== undefined)
152
+ childEnvironment[key] = environment[key];
153
+ for (const [key, value] of Object.entries(childEnvironment))
154
+ args.push("-e", `${key}=${value}`);
155
+ // Multiple command arguments use tmux's direct exec path, not shell parsing.
156
+ args.push(executable, "open", ...(url === undefined ? [] : [url]), "--no-merge");
157
+ const [createdSession, createdWindow, pane] = run(args).trim().split("\t");
158
+ if (!/^%\d+$/u.test(pane ?? "") ||
159
+ createdSession !== session ||
160
+ !/^@\d+$/u.test(createdWindow ?? ""))
161
+ throw new IdeError("tmux did not return the created browser pane ID.", {
162
+ code: "BROWSER_CREATE_FAILED",
163
+ });
164
+ return { pane: pane, session: createdSession, window: createdWindow, status: "created" };
165
+ }
166
+ export function openBrowser(url, options = {}) {
167
+ validateBrowserOpen(url, options);
168
+ const executable = resolveTerminalBrowser();
169
+ try {
170
+ const namespace = { ...runtimeNamespaceEnvironment(resolveRuntimeNamespace()) };
171
+ const authority = resolveWorkspacePaneTmuxAuthority();
172
+ namespace.TMUX_IDE_BROWSER_TMUX = authority.executablePath;
173
+ const run = createPinnedWorkspaceTmuxRunner(authority, {
174
+ timeoutMs: 10_000,
175
+ });
176
+ return createBrowserPane(run, executable, url, options, process.env, process.cwd(), namespace);
177
+ }
178
+ catch (error) {
179
+ if (error instanceof IdeError)
180
+ throw error;
181
+ throw new IdeError("Could not create the browser pane. Check that the target tmux session is running.", {
182
+ code: "BROWSER_CREATE_FAILED",
183
+ cause: error instanceof Error ? error : undefined,
184
+ });
185
+ }
186
+ }
@@ -363,6 +363,7 @@ export class SessionChannel {
363
363
  async start() {
364
364
  await this.io.start();
365
365
  await this.captureAttachedSessionIdentity();
366
+ this.io.send("refresh-client -B 'tmux-ide-browser-icon:%*:#{@tmux_ide_browser_icon}'");
366
367
  // Changing border placement can resize PTYs without changing the layout
367
368
  // string, so tmux emits no layout-change. Subscribe to this window option.
368
369
  this.io.send("refresh-client -B 'tmux-ide-pane-borders:@*:#{pane-border-status}'");
@@ -2249,6 +2250,10 @@ export class SessionChannel {
2249
2250
  name === "session-renamed" ||
2250
2251
  name === "sessions-changed")
2251
2252
  this.opts.onWindowTopologyChanged?.();
2253
+ if (name === "subscription-changed" && rest.startsWith("tmux-ide-browser-icon ")) {
2254
+ this.scheduleSync();
2255
+ return;
2256
+ }
2252
2257
  if (name === "subscription-changed") {
2253
2258
  const policy = /^tmux-ide-scroll-on-clear\s+\$[0-9]+\s+@[0-9]+\s+[0-9]+\s+(%[0-9]+)\s+:\s+([01])\s*$/u.exec(rest);
2254
2259
  if (policy) {
@@ -2650,6 +2655,7 @@ export class SessionChannel {
2650
2655
  semanticPaneId: pane?.semanticId ?? null,
2651
2656
  displayName: display?.name ?? null,
2652
2657
  displayNameSource: display?.source ?? null,
2658
+ browserIcon: pane?.descriptor?.browserIcon ?? null,
2653
2659
  left: leaf.left,
2654
2660
  top: leaf.top,
2655
2661
  width: leaf.width,
@@ -955,8 +955,11 @@ export class PaneStreamLiveConnection {
955
955
  this.close(1011, "stream-unavailable");
956
956
  return;
957
957
  }
958
- const expectedPaneIds = [...this.#descriptor.panes].sort();
959
- if (expectedPaneIds.some((pane) => !known.has(pane))) {
958
+ // Layout authority covers the whole session, while content leases may select
959
+ // only one pane (for example a native browser observing its own canvas).
960
+ // Keep the full inventory fence without subscribing to unleased content.
961
+ const expectedPaneIds = [...known].sort();
962
+ if (this.#descriptor.panes.some((pane) => !known.has(pane))) {
960
963
  this.#failTopologyChanged();
961
964
  return;
962
965
  }
@@ -1317,7 +1320,7 @@ export class PaneStreamLiveConnection {
1317
1320
  this.#observeSurfaceDemand([channel.semanticPaneId]);
1318
1321
  // Renderers that cannot inflate see raw formats; their bytes are inflated
1319
1322
  // when served.
1320
- const zlib = this.#descriptor.terminalDelivery?.protocolVersions.includes(TERMINAL_GRAPHICS_ZLIB_CAPABILITY_VERSION) === true;
1323
+ const zlib = this.#descriptor.terminalDelivery?.protocolVersions.some((version) => version === TERMINAL_GRAPHICS_ZLIB_CAPABILITY_VERSION || version === 117) === true;
1321
1324
  channel.graphicsZlib = zlib;
1322
1325
  channel.graphicsLocal =
1323
1326
  !!this.#localImages &&
@@ -1793,6 +1796,9 @@ export class PaneStreamLiveConnection {
1793
1796
  pane: pane.semanticPaneId,
1794
1797
  displayName: pane.displayName,
1795
1798
  displayNameSource: pane.displayNameSource,
1799
+ ...(this.#descriptor.terminalDelivery?.protocolVersions.includes(117)
1800
+ ? { browserIcon: pane.browserIcon ?? null }
1801
+ : {}),
1796
1802
  left: pane.left,
1797
1803
  top: pane.top,
1798
1804
  width: pane.width,
@@ -0,0 +1,16 @@
1
+ /** Browser-provided 16×16 RGBA pixels, never a URL or filesystem path. */
2
+ export function browserIcon(value, alive = processAlive) {
3
+ const match = /^([1-9][0-9]{0,9}):([0-9a-f]{2048}|)$/u.exec(value);
4
+ if (!match || !alive(Number(match[1])))
5
+ return null;
6
+ return match[2];
7
+ }
8
+ function processAlive(pid) {
9
+ try {
10
+ process.kill(pid, 0);
11
+ return true;
12
+ }
13
+ catch {
14
+ return false;
15
+ }
16
+ }