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,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";
|
|
@@ -68,7 +69,8 @@ export function createNativeTmuxServerCatalog(workspaceRegistry, run, assertOpen
|
|
|
68
69
|
"list-panes",
|
|
69
70
|
"-a",
|
|
70
71
|
"-F",
|
|
71
|
-
|
|
72
|
+
// session_path stays last: tmux rejects tabs in session names but not in paths.
|
|
73
|
+
"#{pid}\t#{session_id}\t#{session_created}\t#{session_name}\t#{pane_id}\t#{window_id}\t#{session_activity}\t#{session_path}",
|
|
72
74
|
]
|
|
73
75
|
.map(shellEscape)
|
|
74
76
|
.join(" "),
|
|
@@ -79,7 +81,8 @@ export function createNativeTmuxServerCatalog(workspaceRegistry, run, assertOpen
|
|
|
79
81
|
// Command errors, unsupported formats and unwitnessed blank output stay failures.
|
|
80
82
|
const lines = raw.trim() === "tmux-ide-empty-catalog-v1" ? [] : raw.trim().split("\n");
|
|
81
83
|
for (const line of lines) {
|
|
82
|
-
const [pid, id, created, name, pane,
|
|
84
|
+
const [pid, id, created, name, pane, window, activity, ...pathParts] = line.split("\t");
|
|
85
|
+
const dir = pathParts.join("\t");
|
|
83
86
|
if (!pid ||
|
|
84
87
|
!/^\d+$/.test(pid) ||
|
|
85
88
|
!id ||
|
|
@@ -89,6 +92,8 @@ export function createNativeTmuxServerCatalog(workspaceRegistry, run, assertOpen
|
|
|
89
92
|
!name ||
|
|
90
93
|
!pane ||
|
|
91
94
|
!/^%\d+$/.test(pane) ||
|
|
95
|
+
!window ||
|
|
96
|
+
!/^@\d+$/.test(window) ||
|
|
92
97
|
!dir)
|
|
93
98
|
throw new Error("Invalid native server catalog");
|
|
94
99
|
if (!isVisibleFleetSession(name))
|
|
@@ -97,9 +102,12 @@ export function createNativeTmuxServerCatalog(workspaceRegistry, run, assertOpen
|
|
|
97
102
|
const row = rows.get(liveSessionId) ?? {
|
|
98
103
|
summary: { liveSessionId, sessionName: name, paneCount: 0 },
|
|
99
104
|
panes: new Set(),
|
|
105
|
+
windows: new Set(),
|
|
100
106
|
dir,
|
|
107
|
+
activity: activity && /^\d+$/.test(activity) ? Number(activity) : null,
|
|
101
108
|
};
|
|
102
109
|
row.panes.add(pane);
|
|
110
|
+
row.windows.add(window);
|
|
103
111
|
rows.set(liveSessionId, row);
|
|
104
112
|
}
|
|
105
113
|
const names = new Set([...rows.values()].map(({ summary }) => summary.sessionName));
|
|
@@ -114,7 +122,13 @@ export function createNativeTmuxServerCatalog(workspaceRegistry, run, assertOpen
|
|
|
114
122
|
projectDir: dir,
|
|
115
123
|
persistence: "volatile",
|
|
116
124
|
});
|
|
117
|
-
return [...rows.values()].map(({ summary, panes }) => ({
|
|
125
|
+
return [...rows.values()].map(({ summary, panes, windows, dir, activity }) => ({
|
|
126
|
+
...summary,
|
|
127
|
+
paneCount: panes.size,
|
|
128
|
+
windowCount: windows.size,
|
|
129
|
+
lastActivity: activity,
|
|
130
|
+
sessionPath: dir,
|
|
131
|
+
}));
|
|
118
132
|
};
|
|
119
133
|
}
|
|
120
134
|
/** One proven server incarnation. Never elects an endpoint or changes ambient tmux authority. */
|
|
@@ -505,6 +519,14 @@ export async function createNativeTmuxServerOwner(options) {
|
|
|
505
519
|
run: (args, signal) => generationRunAsync(args, signal),
|
|
506
520
|
}),
|
|
507
521
|
resolvePaneEditorContext,
|
|
522
|
+
nativeBrowser: createNativeBrowserResolver({
|
|
523
|
+
generation,
|
|
524
|
+
assertOpen,
|
|
525
|
+
registry: workspaceRegistry,
|
|
526
|
+
catalog,
|
|
527
|
+
inventory: terminalInventoryRuntime,
|
|
528
|
+
run: generationRunAsync,
|
|
529
|
+
}),
|
|
508
530
|
catalog,
|
|
509
531
|
readWindowSplitLayout: async (workspaceName, input) => {
|
|
510
532
|
assertOpen();
|
|
@@ -21,9 +21,9 @@
|
|
|
21
21
|
<key>CFBundlePackageType</key>
|
|
22
22
|
<string>APPL</string>
|
|
23
23
|
<key>CFBundleShortVersionString</key>
|
|
24
|
-
<string>2.9.
|
|
24
|
+
<string>2.9.16</string>
|
|
25
25
|
<key>CFBundleVersion</key>
|
|
26
|
-
<string>2.9.
|
|
26
|
+
<string>2.9.16</string>
|
|
27
27
|
<key>LSMinimumSystemVersion</key>
|
|
28
28
|
<string>11.0</string>
|
|
29
29
|
<key>LSUIElement</key>
|
|
Binary file
|
|
Binary file
|
package/packages/daemon/dist/native/TmuxIdeNotifier.app/Contents/_CodeSignature/CodeResources
CHANGED
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
</data>
|
|
11
11
|
<key>Resources/Assets.car</key>
|
|
12
12
|
<data>
|
|
13
|
-
|
|
13
|
+
80sBn8yzZzR4ZHE3L4qFvRPqA+A=
|
|
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
|
-
|
|
29
|
+
PKQV15gCKuH8bIwdxGZgHEhWb0Rwq62t/R9Ozfnv/BA=
|
|
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
|
-
:
|
|
12
|
-
|
|
13
|
-
.
|
|
14
|
-
|
|
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))
|
|
@@ -64,6 +76,42 @@ export function validateBrowserOpen(url, options) {
|
|
|
64
76
|
exitCode: 2,
|
|
65
77
|
});
|
|
66
78
|
}
|
|
79
|
+
/** Printed when the opened browser never publishes tmux-ide's native controls. */
|
|
80
|
+
export const NO_NATIVE_CONTROLS_NOTICE = "This browser has no tmux-ide controls; install the native app for back/forward/reload.";
|
|
81
|
+
/** A browser bundled in a native app publishes its controls; a standalone one may not. */
|
|
82
|
+
function isBundledBrowser(executable) {
|
|
83
|
+
return /\/[^/]+\.app\/Contents\/Resources\/browser\//u.test(executable);
|
|
84
|
+
}
|
|
85
|
+
/** How long a standalone browser has to publish its controls. */
|
|
86
|
+
const STANDALONE_WAIT_MS = 2000;
|
|
87
|
+
/**
|
|
88
|
+
* Watches the new pane for @tmux_ide_browser_control, which only a browser with
|
|
89
|
+
* native controls sets. A browser bundled in an app is never reported: if it is
|
|
90
|
+
* slow, its controls appear when it is ready. A standalone browser gets 2 s of
|
|
91
|
+
* wall-clock time, however slow tmux answers, and then the notice. Returns
|
|
92
|
+
* null once the option appears or when the pane is already gone.
|
|
93
|
+
*/
|
|
94
|
+
export async function nativeControlsNotice(run, pane, executable, wait = {}) {
|
|
95
|
+
if (isBundledBrowser(executable))
|
|
96
|
+
return null;
|
|
97
|
+
const sleep = wait.sleep ?? ((ms) => new Promise((r) => setTimeout(r, ms)));
|
|
98
|
+
const now = wait.now ?? Date.now;
|
|
99
|
+
const deadline = now() + STANDALONE_WAIT_MS;
|
|
100
|
+
for (;;) {
|
|
101
|
+
let control;
|
|
102
|
+
try {
|
|
103
|
+
control = run(["display-message", "-p", "-t", pane, "#{@tmux_ide_browser_control}"]).trim();
|
|
104
|
+
}
|
|
105
|
+
catch {
|
|
106
|
+
return null;
|
|
107
|
+
}
|
|
108
|
+
if (control)
|
|
109
|
+
return null;
|
|
110
|
+
if (now() >= deadline)
|
|
111
|
+
return NO_NATIVE_CONTROLS_NOTICE;
|
|
112
|
+
await sleep(250);
|
|
113
|
+
}
|
|
114
|
+
}
|
|
67
115
|
export function createBrowserPane(run, executable, url, options, environment, cwd, namespace) {
|
|
68
116
|
validateBrowserOpen(url, options);
|
|
69
117
|
const inheritedPane = /^%\d+$/u.test(environment.TMUX_PANE ?? "")
|
|
@@ -151,9 +199,16 @@ export function createBrowserPane(run, executable, url, options, environment, cw
|
|
|
151
199
|
});
|
|
152
200
|
return { pane: pane, session: createdSession, window: createdWindow, status: "created" };
|
|
153
201
|
}
|
|
154
|
-
export function openBrowser(url, options = {}) {
|
|
202
|
+
export async function openBrowser(url, options = {}, dependencies = {}) {
|
|
155
203
|
validateBrowserOpen(url, options);
|
|
156
|
-
const executable = resolveTerminalBrowser();
|
|
204
|
+
const executable = (dependencies.executable ?? resolveTerminalBrowser)();
|
|
205
|
+
const { result, run } = (dependencies.create ?? createPinnedBrowserPane)(executable, url, options);
|
|
206
|
+
const message = await nativeControlsNotice(run, result.pane, executable, dependencies.wait);
|
|
207
|
+
if (message)
|
|
208
|
+
(dependencies.notice ?? ((line) => process.stderr.write(`${line}\n`)))(message);
|
|
209
|
+
return result;
|
|
210
|
+
}
|
|
211
|
+
function createPinnedBrowserPane(executable, url, options) {
|
|
157
212
|
try {
|
|
158
213
|
const namespace = { ...runtimeNamespaceEnvironment(resolveRuntimeNamespace()) };
|
|
159
214
|
const authority = resolveWorkspacePaneTmuxAuthority();
|
|
@@ -161,7 +216,8 @@ export function openBrowser(url, options = {}) {
|
|
|
161
216
|
const run = createPinnedWorkspaceTmuxRunner(authority, {
|
|
162
217
|
timeoutMs: 10_000,
|
|
163
218
|
});
|
|
164
|
-
|
|
219
|
+
const result = createBrowserPane(run, executable, url, options, process.env, process.cwd(), namespace);
|
|
220
|
+
return { result, run };
|
|
165
221
|
}
|
|
166
222
|
catch (error) {
|
|
167
223
|
if (error instanceof IdeError)
|
|
@@ -6,6 +6,7 @@ import { PANE_STREAM_INPUT_LIFETIME_CAPABILITY_VERSION, PANE_STREAM_INPUT_BYTES_
|
|
|
6
6
|
import { inflateSync } from "node:zlib";
|
|
7
7
|
import { PaneStreamLayoutSnapshotFrameSchemaZ } from "@tmux-ide/contracts";
|
|
8
8
|
import { z } from "zod";
|
|
9
|
+
import { logger } from "../../lib/log.js";
|
|
9
10
|
import { PANE_STREAM_MAX_HELD_DELTAS, PANE_STREAM_MAX_OUTPUT_BYTES, PANE_STREAM_MAX_PANES, PANE_STREAM_MAX_SEED_BYTES, PANE_STREAM_PROTOCOL_VERSION, PANE_STREAM_REDEEM_PATH, PANE_STREAM_WEBSOCKET_SUBPROTOCOL, TERMINAL_GRAPHICS_BLOB_CHUNK_BYTES, TERMINAL_DELIVERY_BINARY_CAPABILITY_VERSION, TERMINAL_DELIVERY_BINARY_LOCAL_CAPABILITY_VERSION, TERMINAL_GRAPHICS_BINARY_CAPABILITY_VERSION, TERMINAL_GRAPHICS_LOCAL_CAPABILITY_VERSION, encodeTerminalGraphicsBinary, encodeTerminalDeliveryBinaryChunk, TERMINAL_GRAPHICS_CELL_PIXELS_CAPABILITY_VERSION, TERMINAL_GRAPHICS_PRECISE_PIXELS_CAPABILITY_VERSION, TERMINAL_GRAPHICS_LEGACY_MAX_IMAGE_BYTES, TERMINAL_GRAPHICS_ZLIB_CAPABILITY_VERSION, PaneStreamClientFrameSchemaZ, PaneStreamLoopbackWebSocketUrlSchemaZ, PaneStreamRedeemFrameSchemaZ, PaneStreamServerFrameSchemaZ, sharedMonotonicMicros, } from "@tmux-ide/contracts";
|
|
10
11
|
import { canonicalOriginOrNull, digestSecret, digestsEqual, rawDataByteLength, rawDataToBuffer, safeCloseSocket, strictJsonParse, } from "../attachments/admission-util.js";
|
|
11
12
|
import { SessionRuntimeControllerLeaseError } from "../session-runtime/registry.js";
|
|
@@ -547,6 +548,11 @@ class PreAuthAdmission {
|
|
|
547
548
|
this.#open = false;
|
|
548
549
|
this.#cancelDeadline();
|
|
549
550
|
const socket = this.#socket;
|
|
551
|
+
logger.info("pane-stream", "admission closed", {
|
|
552
|
+
code,
|
|
553
|
+
reason,
|
|
554
|
+
hostClientId: this.hostClientId,
|
|
555
|
+
});
|
|
550
556
|
this.#detach();
|
|
551
557
|
this.#onRelease(this);
|
|
552
558
|
if (socket)
|
|
@@ -825,6 +831,12 @@ export class PaneStreamLiveConnection {
|
|
|
825
831
|
if (this.#closed)
|
|
826
832
|
return;
|
|
827
833
|
this.#recordDiagnosticLifecycle("pane-stream-terminal", code, reason);
|
|
834
|
+
logger.info("pane-stream", "stream closed", {
|
|
835
|
+
code,
|
|
836
|
+
reason,
|
|
837
|
+
session: this.#descriptor.sessionName,
|
|
838
|
+
clientId: this.#clientId,
|
|
839
|
+
});
|
|
828
840
|
this.#closed = true;
|
|
829
841
|
for (const demand of this.#surfaceDemands.values())
|
|
830
842
|
demand.close();
|
|
@@ -2,6 +2,7 @@ import { prepareProjection } from "./terminal-projection-delivery.js";
|
|
|
2
2
|
import { TerminalProjectionEnvelopeSchemaZ, TerminalProjectionReceiptSchemaZ, TerminalProjectionMaterializeSchemaZ, } from "@tmux-ide/contracts";
|
|
3
3
|
import { encodeTerminalProjectionCooperatively } from "@tmux-ide/core";
|
|
4
4
|
import { StockCaptureTabUnavailableError } from "./stock-capture-fidelity.js";
|
|
5
|
+
import { logger } from "../../lib/log.js";
|
|
5
6
|
import { MAX_RETAINED_NATIVE_BACKING_BYTES, takeNativeSeedBacking, } from "./native-seed-backing.js";
|
|
6
7
|
import { WorkspaceIdSchemaZ, TERMINAL_GRAPHICS_CAPABILITY_VERSION, TERMINAL_DELIVERY_PATCH_TO_SEED_BYTES, TERMINAL_DELIVERY_MAX_REPRESENTATION_BYTES, TerminalDeliveryEnvelopeSchemaZ, TerminalDeliveryAckSchemaZ, TerminalDeliveryNackSchemaZ, TerminalDeliveryOfferSchemaZ, TerminalDeliveryVisibilitySchemaZ, } from "@tmux-ide/contracts";
|
|
7
8
|
import { TerminalDeliveryStateTooLargeError, terminalReplicaRowsEqual, TERMINAL_COMPACT_COOPERATIVE_SEED_CELLS, applyTerminalReplicaUpdate, applyTerminalReplicaUpdateCooperatively, terminalReplicaUpdateNeedsCooperativeReduction, encodeBinarySemanticTerminalUpdate, encodeBinarySemanticTerminalUpdateCooperatively, encodeCompactSemanticTerminalUpdate, encodeCompactSemanticTerminalUpdateCooperatively, terminalSemanticUpdateNeedsCooperativeEncoding, encodeAnsiTerminalRepresentation, encodeSemanticTerminalUpdate, hashTerminalDeliveryRepresentation, negotiateTerminalDelivery, preaccountSemanticTerminalUpdateBytes, } from "@tmux-ide/core";
|
|
@@ -196,6 +197,7 @@ export class SessionRuntimeTerminalDeliveryHub {
|
|
|
196
197
|
if (!(error instanceof StockCaptureTabUnavailableError))
|
|
197
198
|
throw error;
|
|
198
199
|
// Negotiation still settles this pane; no invented canonical seed is published.
|
|
200
|
+
logDeliveryFault({ paneId: semanticPaneId, deliveryWorkspaceName: address }, "source-closed", "capture-unavailable");
|
|
199
201
|
await accept({
|
|
200
202
|
type: "terminal.delivery.fault",
|
|
201
203
|
reason: "source-closed",
|
|
@@ -350,6 +352,7 @@ export class SessionRuntimeTerminalDeliveryHub {
|
|
|
350
352
|
this.#cancelEncoding(client);
|
|
351
353
|
client.outgoing.length = 0;
|
|
352
354
|
client.inFlight = null;
|
|
355
|
+
logDeliveryFault(client, "source-closed", "source-restarted");
|
|
353
356
|
this.#enqueue(client, {
|
|
354
357
|
type: "terminal.delivery.fault",
|
|
355
358
|
reason: "source-closed",
|
|
@@ -1442,6 +1445,7 @@ export class SessionRuntimeTerminalDeliveryHub {
|
|
|
1442
1445
|
this.#recordDeliveryStatus(client, this.#panes.get(client.paneId));
|
|
1443
1446
|
}
|
|
1444
1447
|
#fault(client, reason, message, selectionObservation = null) {
|
|
1448
|
+
logDeliveryFault(client, reason);
|
|
1445
1449
|
if (this.#observability.enabled)
|
|
1446
1450
|
try {
|
|
1447
1451
|
const atMicros = this.#observability.nowMicros();
|
|
@@ -2038,3 +2042,15 @@ function joinBytes(chunks) {
|
|
|
2038
2042
|
}
|
|
2039
2043
|
return joined;
|
|
2040
2044
|
}
|
|
2045
|
+
/**
|
|
2046
|
+
* One info line per fault a client is sent, so a client's drop has a daemon-side
|
|
2047
|
+
* record. Ids and fixed reasons only: the fault message can carry source text.
|
|
2048
|
+
*/
|
|
2049
|
+
function logDeliveryFault(client, reason, cause) {
|
|
2050
|
+
logger.info("terminal-delivery", "fault sent to client", {
|
|
2051
|
+
reason,
|
|
2052
|
+
...(cause ? { cause } : {}),
|
|
2053
|
+
pane: client.paneId,
|
|
2054
|
+
workspace: client.deliveryWorkspaceName,
|
|
2055
|
+
});
|
|
2056
|
+
}
|
|
@@ -90,7 +90,7 @@ export class DaemonFleetFactsObserver {
|
|
|
90
90
|
|
|
91
91
|
constructor(options: DaemonFleetFactsObserverOptions) {
|
|
92
92
|
this.#options = options;
|
|
93
|
-
this.#intervalMs = options.intervalMs ??
|
|
93
|
+
this.#intervalMs = options.intervalMs ?? FLEET_FACTS_POLL_MS;
|
|
94
94
|
this.#setTimer =
|
|
95
95
|
options.setTimer ??
|
|
96
96
|
((callback, delayMs) => {
|
|
@@ -549,6 +549,14 @@ export function createDefaultFleetFactsReaders(): Pick<
|
|
|
549
549
|
return createSharedFleetFactsReaders(execute);
|
|
550
550
|
}
|
|
551
551
|
|
|
552
|
+
/**
|
|
553
|
+
* How often subscribed clients' tmux facts (sessions, panes, agent stamps) are
|
|
554
|
+
* read. Hook-stamped agent changes reach clients within one tick, so 500 ms
|
|
555
|
+
* keeps a state flip visible within 1 s (#716). One `list-panes -a` per tick,
|
|
556
|
+
* only while a client is subscribed.
|
|
557
|
+
*/
|
|
558
|
+
export const FLEET_FACTS_POLL_MS = 500;
|
|
559
|
+
|
|
552
560
|
export const SESSION_COMPOSITION_TMUX_ARGS = [
|
|
553
561
|
"list-panes",
|
|
554
562
|
"-a",
|
|
@@ -22,6 +22,7 @@ import {
|
|
|
22
22
|
TmuxServerScopeSchemaZ,
|
|
23
23
|
TmuxServersResourceSchemaZ,
|
|
24
24
|
TmuxServerSessionsResourceSchemaZ,
|
|
25
|
+
TmuxServerSessionsResourceV2SchemaZ,
|
|
25
26
|
WorkspaceMultiplexerMutationRequestSchemaZ,
|
|
26
27
|
WorkspacePaneCreateMutationRequestSchemaZ,
|
|
27
28
|
WorkspaceSessionCreateArgumentsSchemaZ,
|
|
@@ -32,6 +33,7 @@ import {
|
|
|
32
33
|
TmuxServerScopeError,
|
|
33
34
|
} from "../lib/tmux-server-owners.ts";
|
|
34
35
|
import type { NativeTmuxServerOwner } from "../lib/tmux-server-owner.ts";
|
|
36
|
+
import { createSessionFaviconResolver, type SessionFavicon } from "../lib/session-favicon.ts";
|
|
35
37
|
import { WorkspaceMultiplexerError } from "../lib/workspace-multiplexer-verbs.ts";
|
|
36
38
|
import { WorkspacePaneCreationError } from "../lib/workspace-pane-creation.ts";
|
|
37
39
|
import { ownerBearerMatches } from "./owner-authority.ts";
|
|
@@ -41,7 +43,11 @@ import { projectTerminalRuntimeInventory } from "./resources/terminal-runtime-in
|
|
|
41
43
|
export interface TmuxServerRoutesOptions {
|
|
42
44
|
readonly ownerToken: string | null;
|
|
43
45
|
readonly owners: TmuxServerOwners<NativeTmuxServerOwner>;
|
|
46
|
+
/** Project favicon lookup for the version 2 sessions resource. */
|
|
47
|
+
readonly resolveSessionFavicon?: (sessionPath: string) => Promise<SessionFavicon | null>;
|
|
44
48
|
}
|
|
49
|
+
/** Total raw favicon bytes one version 2 sessions response may carry. */
|
|
50
|
+
export const SESSIONS_RESOURCE_FAVICON_BUDGET_BYTES = 512 * 1024;
|
|
45
51
|
const registrationSchema = TmuxServerRegistrationSchemaZ.omit({ serverId: true }).strict();
|
|
46
52
|
const workspaceSchema = z
|
|
47
53
|
.string()
|
|
@@ -94,6 +100,7 @@ function failure(c: Context, error: unknown): Response {
|
|
|
94
100
|
/** Owner capability is mandatory even when the ordinary local HTTP surface is open. */
|
|
95
101
|
export function mountTmuxServerRoutes(app: Hono, options: TmuxServerRoutesOptions): void {
|
|
96
102
|
const base = TMUX_SERVERS_API_PATH;
|
|
103
|
+
const resolveSessionFavicon = options.resolveSessionFavicon ?? createSessionFaviconResolver();
|
|
97
104
|
const authorize = async (c: Context, next: () => Promise<void>) => {
|
|
98
105
|
c.header("Cache-Control", "no-store");
|
|
99
106
|
if (!ownerBearerMatches(c.req.header("Authorization"), options.ownerToken))
|
|
@@ -150,6 +157,17 @@ export function mountTmuxServerRoutes(app: Hono, options: TmuxServerRoutesOption
|
|
|
150
157
|
owners: options.owners,
|
|
151
158
|
readBody: (request) => boundedJson(request, 8 * 1024),
|
|
152
159
|
});
|
|
160
|
+
app.post(
|
|
161
|
+
`${scoped}/native-browser`,
|
|
162
|
+
route(async (c) => {
|
|
163
|
+
const server = scope(c);
|
|
164
|
+
const input = await boundedJson(c.req.raw, 24 * 1024);
|
|
165
|
+
const state = await options.owners.withOwner(server, (owner) =>
|
|
166
|
+
owner.nativeBrowser(input, c.req.raw.signal),
|
|
167
|
+
);
|
|
168
|
+
return c.json({ version: 1, state });
|
|
169
|
+
}),
|
|
170
|
+
);
|
|
153
171
|
// Structured read only: this never acquires a channel or changes tmux geometry.
|
|
154
172
|
app.post(
|
|
155
173
|
`${scoped}/split-layout/:workspaceName`,
|
|
@@ -260,19 +278,56 @@ export function mountTmuxServerRoutes(app: Hono, options: TmuxServerRoutesOption
|
|
|
260
278
|
`${scoped}/sessions`,
|
|
261
279
|
route(async (c) => {
|
|
262
280
|
const server = scope(c);
|
|
281
|
+
const v2 = c.req.query("version") === "2";
|
|
282
|
+
const { rows, favicons } = await options.owners.withOwner(server, async (owner) => {
|
|
283
|
+
const sessions = await owner.catalog();
|
|
284
|
+
const workspaces = owner.workspaceRegistry.list();
|
|
285
|
+
const rows = sessions.map((session) => {
|
|
286
|
+
const aliases = workspaces.filter(
|
|
287
|
+
(workspace) => workspace.sessionName === session.sessionName,
|
|
288
|
+
);
|
|
289
|
+
return { session, workspaceName: aliases.length === 1 ? aliases[0]!.name : null };
|
|
290
|
+
});
|
|
291
|
+
const favicons = v2
|
|
292
|
+
? await Promise.all(
|
|
293
|
+
sessions.map((session) =>
|
|
294
|
+
resolveSessionFavicon(session.sessionPath).catch(() => null),
|
|
295
|
+
),
|
|
296
|
+
)
|
|
297
|
+
: [];
|
|
298
|
+
return { rows, favicons };
|
|
299
|
+
});
|
|
300
|
+
if (!v2)
|
|
301
|
+
return c.json(
|
|
302
|
+
TmuxServerSessionsResourceSchemaZ.parse({
|
|
303
|
+
version: 1,
|
|
304
|
+
server,
|
|
305
|
+
sessions: rows.map(({ session, workspaceName }) => ({
|
|
306
|
+
liveSessionId: session.liveSessionId,
|
|
307
|
+
sessionName: session.sessionName,
|
|
308
|
+
paneCount: session.paneCount,
|
|
309
|
+
workspaceName,
|
|
310
|
+
})),
|
|
311
|
+
}),
|
|
312
|
+
);
|
|
313
|
+
let budget = SESSIONS_RESOURCE_FAVICON_BUDGET_BYTES;
|
|
263
314
|
return c.json(
|
|
264
|
-
|
|
265
|
-
version:
|
|
315
|
+
TmuxServerSessionsResourceV2SchemaZ.parse({
|
|
316
|
+
version: 2,
|
|
266
317
|
server,
|
|
267
|
-
sessions:
|
|
268
|
-
const
|
|
269
|
-
const
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
318
|
+
sessions: rows.map(({ session, workspaceName }, index) => {
|
|
319
|
+
const found = favicons[index] ?? null;
|
|
320
|
+
const favicon = found && found.size <= budget ? found : null;
|
|
321
|
+
if (favicon) budget -= favicon.size;
|
|
322
|
+
return {
|
|
323
|
+
liveSessionId: session.liveSessionId,
|
|
324
|
+
sessionName: session.sessionName,
|
|
325
|
+
workspaceName,
|
|
326
|
+
paneCount: session.paneCount,
|
|
327
|
+
windowCount: session.windowCount,
|
|
328
|
+
lastActivity: session.lastActivity,
|
|
329
|
+
favicon: favicon ? { mime: favicon.mime, data: favicon.data } : null,
|
|
330
|
+
};
|
|
276
331
|
}),
|
|
277
332
|
}),
|
|
278
333
|
);
|
|
@@ -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);
|