tmux-ide 2.9.14 → 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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "tmux-ide",
3
- "version": "2.9.14",
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>;
@@ -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.14</string>
24
+ <string>2.9.15</string>
25
25
  <key>CFBundleVersion</key>
26
- <string>2.9.14</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
- vWBHvsqTbQOxNEIBI9tGJCW1B9s=
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
- 9TKARXnkH7u4v7AEsGxJfv64Q3jE3NOm+N2tQHYvvIE=
29
+ lo9t3TSww0mSpC4nN+YoeeUgaXGcFCrv53qBXtKpzEY=
30
30
  </data>
31
31
  </dict>
32
32
  </dict>
@@ -1,17 +1,29 @@
1
1
  import { accessSync, constants, realpathSync, statSync } from "node:fs";
2
2
  import { delimiter, isAbsolute, join, resolve } from "node:path";
3
+ import { homedir } from "node:os";
3
4
  import { IdeError } from "./lib/errors.js";
4
5
  import { resolveRuntimeNamespace, runtimeNamespaceEnvironment } from "./lib/runtime-namespace.js";
5
6
  import { createPinnedWorkspaceTmuxRunner, resolveWorkspacePaneTmuxAuthority, } from "./lib/workspace-pane-creation.js";
6
7
  /** Resolve once before creating a pane; ignore relative PATH entries. */
7
- export function resolveTerminalBrowser(environment = process.env) {
8
+ export function resolveTerminalBrowser(environment = process.env, platform = process.platform) {
8
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
+ : [];
9
18
  const candidates = configured
10
19
  ? [configured]
11
- : (environment.PATH ?? "")
12
- .split(delimiter)
13
- .filter(isAbsolute)
14
- .map((dir) => join(dir, "terminal-browser"));
20
+ : [
21
+ ...bundled,
22
+ ...(environment.PATH ?? "")
23
+ .split(delimiter)
24
+ .filter(isAbsolute)
25
+ .map((dir) => join(dir, "terminal-browser")),
26
+ ];
15
27
  for (const candidate of candidates) {
16
28
  try {
17
29
  if (!isAbsolute(candidate))
@@ -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
+ }