tmux-ide 2.9.15 → 2.9.17
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 +740 -185
- package/package.json +2 -1
- package/packages/contracts/src/tmux-server-scope.ts +54 -0
- package/packages/core/src/terminal-fnv64-wasm.ts +8 -1
- package/packages/core/src/terminal-replica-hash-cache.ts +108 -1
- package/packages/core/src/terminal-replica.ts +46 -10
- package/packages/daemon/dist/command-center/daemon-fleet-facts-observer.js +8 -1
- package/packages/daemon/dist/command-center/discovery.js +39 -6
- package/packages/daemon/dist/command-center/tmux-servers.js +46 -10
- package/packages/daemon/dist/lib/catalog-fleet-read.js +10 -0
- package/packages/daemon/dist/lib/daemon-embed.js +14 -3
- package/packages/daemon/dist/lib/daemon-service.js +13 -5
- 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-server-owner.js +16 -3
- package/packages/daemon/dist/lib/tmux-server-owners.js +20 -1
- package/packages/daemon/dist/lib/tui-binary.js +83 -17
- 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/AppIcon.icns +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 +4 -4
- package/packages/daemon/dist/open-browser.js +47 -3
- 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/discovery.ts +72 -7
- package/packages/daemon/src/command-center/tmux-servers.ts +55 -11
- package/packages/daemon/src/lib/catalog-fleet-read.ts +40 -0
- package/packages/daemon/src/lib/daemon-embed.ts +27 -12
- package/packages/daemon/src/lib/daemon-service.ts +17 -4
- 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-server-owner.ts +35 -6
- package/packages/daemon/src/lib/tmux-server-owners.ts +17 -1
- package/packages/daemon/src/lib/tui-binary.ts +114 -22
- package/packages/daemon/src/open-browser.ts +88 -3
- 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,152 @@
|
|
|
1
|
+
import { constants } from "node:fs";
|
|
2
|
+
import { lstat, open } from "node:fs/promises";
|
|
3
|
+
import { extname, isAbsolute, join } from "node:path";
|
|
4
|
+
import { TMUX_SERVER_SESSION_FAVICON_MAX_BYTES, } from "@tmux-ide/contracts";
|
|
5
|
+
/** Project-relative favicon locations, first match wins. */
|
|
6
|
+
export const SESSION_FAVICON_CANDIDATES = [
|
|
7
|
+
"favicon.svg",
|
|
8
|
+
"favicon.png",
|
|
9
|
+
"favicon.ico",
|
|
10
|
+
"public/favicon.svg",
|
|
11
|
+
"public/favicon.png",
|
|
12
|
+
"public/favicon.ico",
|
|
13
|
+
"app/icon.svg",
|
|
14
|
+
"app/icon.png",
|
|
15
|
+
"app/favicon.ico",
|
|
16
|
+
"src/app/icon.svg",
|
|
17
|
+
"src/app/icon.png",
|
|
18
|
+
"src/app/favicon.ico",
|
|
19
|
+
"static/favicon.svg",
|
|
20
|
+
"static/favicon.png",
|
|
21
|
+
"static/favicon.ico",
|
|
22
|
+
"assets/favicon.svg",
|
|
23
|
+
"assets/favicon.png",
|
|
24
|
+
"assets/favicon.ico",
|
|
25
|
+
"apple-touch-icon.png",
|
|
26
|
+
"public/apple-touch-icon.png",
|
|
27
|
+
"icon.svg",
|
|
28
|
+
"icon.png",
|
|
29
|
+
];
|
|
30
|
+
const MIME_BY_EXTENSION = {
|
|
31
|
+
".png": "image/png",
|
|
32
|
+
".svg": "image/svg+xml",
|
|
33
|
+
".ico": "image/x-icon",
|
|
34
|
+
".jpg": "image/jpeg",
|
|
35
|
+
".jpeg": "image/jpeg",
|
|
36
|
+
".webp": "image/webp",
|
|
37
|
+
".gif": "image/gif",
|
|
38
|
+
};
|
|
39
|
+
export const defaultSessionFaviconIo = {
|
|
40
|
+
lstat: (path) => lstat(path),
|
|
41
|
+
readNoFollow: async (path, maxBytes) => {
|
|
42
|
+
let handle;
|
|
43
|
+
try {
|
|
44
|
+
handle = await open(path, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK);
|
|
45
|
+
}
|
|
46
|
+
catch {
|
|
47
|
+
return null;
|
|
48
|
+
}
|
|
49
|
+
try {
|
|
50
|
+
const stats = await handle.stat();
|
|
51
|
+
if (!stats.isFile() || stats.size > maxBytes)
|
|
52
|
+
return null;
|
|
53
|
+
const bytes = await handle.readFile();
|
|
54
|
+
return bytes.byteLength > maxBytes ? null : bytes;
|
|
55
|
+
}
|
|
56
|
+
catch {
|
|
57
|
+
return null;
|
|
58
|
+
}
|
|
59
|
+
finally {
|
|
60
|
+
await handle.close().catch(() => undefined);
|
|
61
|
+
}
|
|
62
|
+
},
|
|
63
|
+
};
|
|
64
|
+
async function lstatOrNull(io, path) {
|
|
65
|
+
try {
|
|
66
|
+
return await io.lstat(path);
|
|
67
|
+
}
|
|
68
|
+
catch {
|
|
69
|
+
return null;
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
/**
|
|
73
|
+
* Finds a session's project favicon from its working directory. Only regular
|
|
74
|
+
* files and real directories inside the session path are considered; symlinks
|
|
75
|
+
* are never followed. Hits are revalidated by mtime and size, misses are
|
|
76
|
+
* remembered briefly, and the cache is bounded (least recently used first).
|
|
77
|
+
*/
|
|
78
|
+
export function createSessionFaviconResolver(options = {}) {
|
|
79
|
+
const io = options.io ?? defaultSessionFaviconIo;
|
|
80
|
+
const now = options.now ?? Date.now;
|
|
81
|
+
const missTtlMs = options.missTtlMs ?? 30_000;
|
|
82
|
+
const maxEntries = options.maxEntries ?? 256;
|
|
83
|
+
const cache = new Map();
|
|
84
|
+
const remember = (sessionPath, entry) => {
|
|
85
|
+
cache.delete(sessionPath);
|
|
86
|
+
cache.set(sessionPath, entry);
|
|
87
|
+
while (cache.size > maxEntries)
|
|
88
|
+
cache.delete(cache.keys().next().value);
|
|
89
|
+
};
|
|
90
|
+
const scan = async (sessionPath) => {
|
|
91
|
+
const directories = new Map();
|
|
92
|
+
for (const candidate of SESSION_FAVICON_CANDIDATES) {
|
|
93
|
+
const mime = MIME_BY_EXTENSION[extname(candidate)];
|
|
94
|
+
if (!mime)
|
|
95
|
+
continue;
|
|
96
|
+
const slash = candidate.lastIndexOf("/");
|
|
97
|
+
if (slash >= 0) {
|
|
98
|
+
const parts = candidate.slice(0, slash).split("/");
|
|
99
|
+
let real = true;
|
|
100
|
+
for (let index = 1; index <= parts.length && real; index += 1) {
|
|
101
|
+
const directory = parts.slice(0, index).join("/");
|
|
102
|
+
let known = directories.get(directory);
|
|
103
|
+
if (known === undefined) {
|
|
104
|
+
const stats = await lstatOrNull(io, join(sessionPath, directory));
|
|
105
|
+
known = stats !== null && stats.isDirectory() && !stats.isSymbolicLink();
|
|
106
|
+
directories.set(directory, known);
|
|
107
|
+
}
|
|
108
|
+
real = known;
|
|
109
|
+
}
|
|
110
|
+
if (!real)
|
|
111
|
+
continue;
|
|
112
|
+
}
|
|
113
|
+
const file = join(sessionPath, candidate);
|
|
114
|
+
const stats = await lstatOrNull(io, file);
|
|
115
|
+
if (!stats || !stats.isFile() || stats.size > TMUX_SERVER_SESSION_FAVICON_MAX_BYTES)
|
|
116
|
+
continue;
|
|
117
|
+
const bytes = await io.readNoFollow(file, TMUX_SERVER_SESSION_FAVICON_MAX_BYTES);
|
|
118
|
+
if (!bytes)
|
|
119
|
+
continue;
|
|
120
|
+
return {
|
|
121
|
+
kind: "hit",
|
|
122
|
+
file,
|
|
123
|
+
mtimeMs: stats.mtimeMs,
|
|
124
|
+
size: stats.size,
|
|
125
|
+
favicon: { mime, data: bytes.toString("base64"), size: bytes.byteLength },
|
|
126
|
+
};
|
|
127
|
+
}
|
|
128
|
+
return { kind: "miss", at: now() };
|
|
129
|
+
};
|
|
130
|
+
return async (sessionPath) => {
|
|
131
|
+
if (!sessionPath || !isAbsolute(sessionPath) || sessionPath.includes("\0"))
|
|
132
|
+
return null;
|
|
133
|
+
const cached = cache.get(sessionPath);
|
|
134
|
+
if (cached?.kind === "miss" && now() - cached.at < missTtlMs) {
|
|
135
|
+
remember(sessionPath, cached);
|
|
136
|
+
return null;
|
|
137
|
+
}
|
|
138
|
+
if (cached?.kind === "hit") {
|
|
139
|
+
const stats = await lstatOrNull(io, cached.file);
|
|
140
|
+
if (stats &&
|
|
141
|
+
stats.isFile() &&
|
|
142
|
+
stats.mtimeMs === cached.mtimeMs &&
|
|
143
|
+
stats.size === cached.size) {
|
|
144
|
+
remember(sessionPath, cached);
|
|
145
|
+
return cached.favicon;
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
const entry = await scan(sessionPath);
|
|
149
|
+
remember(sessionPath, entry);
|
|
150
|
+
return entry.kind === "hit" ? entry.favicon : null;
|
|
151
|
+
};
|
|
152
|
+
}
|
|
@@ -13,8 +13,8 @@ import { resolveRuntimeNamespace } from "./runtime-namespace.js";
|
|
|
13
13
|
*
|
|
14
14
|
* The dir is FULLY MANAGED: overwriting our own `~/.claude/skills/tmux-ide` is
|
|
15
15
|
* correct, and this NEVER touches anything outside it (user-authored skills in
|
|
16
|
-
* sibling dirs are safe). The version marker is
|
|
17
|
-
*
|
|
16
|
+
* sibling dirs are safe). The version marker is an HTML comment under
|
|
17
|
+
* SKILL.md's frontmatter ({@link VERSION_MARKER_RE}); {@link parseSkillVersion} reads it and
|
|
18
18
|
* {@link rewriteVersionMarker} substitutes it at copy time.
|
|
19
19
|
*
|
|
20
20
|
* The parse/render helpers are PURE and tested against fixtures; {@link syncSkill}
|
|
@@ -66,7 +66,7 @@ export function defaultSkillSource() {
|
|
|
66
66
|
// Pure — version marker
|
|
67
67
|
// ---------------------------------------------------------------------------
|
|
68
68
|
/**
|
|
69
|
-
* The version marker line (an HTML comment
|
|
69
|
+
* The version marker line (an HTML comment under SKILL.md's frontmatter). The sync
|
|
70
70
|
* machinery reads it to know which version an installed copy is, and rewrites it
|
|
71
71
|
* to the installed package version at copy time.
|
|
72
72
|
*/
|
|
@@ -69,7 +69,8 @@ export function createNativeTmuxServerCatalog(workspaceRegistry, run, assertOpen
|
|
|
69
69
|
"list-panes",
|
|
70
70
|
"-a",
|
|
71
71
|
"-F",
|
|
72
|
-
|
|
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}",
|
|
73
74
|
]
|
|
74
75
|
.map(shellEscape)
|
|
75
76
|
.join(" "),
|
|
@@ -80,7 +81,8 @@ export function createNativeTmuxServerCatalog(workspaceRegistry, run, assertOpen
|
|
|
80
81
|
// Command errors, unsupported formats and unwitnessed blank output stay failures.
|
|
81
82
|
const lines = raw.trim() === "tmux-ide-empty-catalog-v1" ? [] : raw.trim().split("\n");
|
|
82
83
|
for (const line of lines) {
|
|
83
|
-
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");
|
|
84
86
|
if (!pid ||
|
|
85
87
|
!/^\d+$/.test(pid) ||
|
|
86
88
|
!id ||
|
|
@@ -90,6 +92,8 @@ export function createNativeTmuxServerCatalog(workspaceRegistry, run, assertOpen
|
|
|
90
92
|
!name ||
|
|
91
93
|
!pane ||
|
|
92
94
|
!/^%\d+$/.test(pane) ||
|
|
95
|
+
!window ||
|
|
96
|
+
!/^@\d+$/.test(window) ||
|
|
93
97
|
!dir)
|
|
94
98
|
throw new Error("Invalid native server catalog");
|
|
95
99
|
if (!isVisibleFleetSession(name))
|
|
@@ -98,9 +102,12 @@ export function createNativeTmuxServerCatalog(workspaceRegistry, run, assertOpen
|
|
|
98
102
|
const row = rows.get(liveSessionId) ?? {
|
|
99
103
|
summary: { liveSessionId, sessionName: name, paneCount: 0 },
|
|
100
104
|
panes: new Set(),
|
|
105
|
+
windows: new Set(),
|
|
101
106
|
dir,
|
|
107
|
+
activity: activity && /^\d+$/.test(activity) ? Number(activity) : null,
|
|
102
108
|
};
|
|
103
109
|
row.panes.add(pane);
|
|
110
|
+
row.windows.add(window);
|
|
104
111
|
rows.set(liveSessionId, row);
|
|
105
112
|
}
|
|
106
113
|
const names = new Set([...rows.values()].map(({ summary }) => summary.sessionName));
|
|
@@ -115,7 +122,13 @@ export function createNativeTmuxServerCatalog(workspaceRegistry, run, assertOpen
|
|
|
115
122
|
projectDir: dir,
|
|
116
123
|
persistence: "volatile",
|
|
117
124
|
});
|
|
118
|
-
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
|
+
}));
|
|
119
132
|
};
|
|
120
133
|
}
|
|
121
134
|
/** One proven server incarnation. Never elects an endpoint or changes ambient tmux authority. */
|
|
@@ -24,6 +24,8 @@ export class TmuxServerOwners {
|
|
|
24
24
|
constructor(options) {
|
|
25
25
|
this.options = options;
|
|
26
26
|
}
|
|
27
|
+
#queuedRefresh = null;
|
|
28
|
+
#queuedRefreshTail = null;
|
|
27
29
|
#serial(work) {
|
|
28
30
|
const run = this.#tail.then(() => {
|
|
29
31
|
if (this.#disposed)
|
|
@@ -134,12 +136,29 @@ export class TmuxServerOwners {
|
|
|
134
136
|
this.#entries.set(scope.serverId, { registration, scope, observation, owner });
|
|
135
137
|
}
|
|
136
138
|
refresh() {
|
|
137
|
-
|
|
139
|
+
// A refresh still waiting its turn has not probed anything yet, so it is
|
|
140
|
+
// as fresh for a later caller as a new one: share it instead of queuing
|
|
141
|
+
// another full refresh behind it (#709). Once it starts, callers queue anew.
|
|
142
|
+
// Share it only while nothing has been queued behind it, so a refresh
|
|
143
|
+
// stays ordered after any mutation queued before the caller asked.
|
|
144
|
+
if (this.#queuedRefresh && this.#tail === this.#queuedRefreshTail)
|
|
145
|
+
return this.#queuedRefresh;
|
|
146
|
+
const queued = this.#serial(async () => {
|
|
147
|
+
if (this.#queuedRefresh === queued)
|
|
148
|
+
this.#queuedRefresh = null;
|
|
138
149
|
// Sequential admission bounds subprocess work and avoids alias races.
|
|
139
150
|
for (const entry of this.#entries.values())
|
|
140
151
|
await this.#refresh(entry);
|
|
141
152
|
return this.list();
|
|
142
153
|
});
|
|
154
|
+
this.#queuedRefresh = queued;
|
|
155
|
+
this.#queuedRefreshTail = this.#tail;
|
|
156
|
+
const settle = () => {
|
|
157
|
+
if (this.#queuedRefresh === queued)
|
|
158
|
+
this.#queuedRefresh = null;
|
|
159
|
+
};
|
|
160
|
+
queued.then(settle, settle);
|
|
161
|
+
return queued;
|
|
143
162
|
}
|
|
144
163
|
current(scopeInput) {
|
|
145
164
|
const scope = TmuxServerScopeSchemaZ.parse(scopeInput);
|
|
@@ -26,8 +26,18 @@ export const MIN_TUI_BINARY_BYTES = 10 * 1024 * 1024;
|
|
|
26
26
|
/** Hard download/decompression ceilings: release binaries are currently ~70MB. */
|
|
27
27
|
export const MAX_TUI_COMPRESSED_BYTES = 128 * 1024 * 1024;
|
|
28
28
|
export const MAX_TUI_BINARY_BYTES = 256 * 1024 * 1024;
|
|
29
|
-
|
|
29
|
+
/** Abort a request when no bytes arrive for this long; a slow transfer never trips it. */
|
|
30
|
+
export const TUI_DOWNLOAD_STALL_TIMEOUT_MS = 30_000;
|
|
31
|
+
/** One budget for the manifest, the binary, and every retry: ~145 KB/s at the size ceiling. */
|
|
32
|
+
export const TUI_DOWNLOAD_TOTAL_TIMEOUT_MS = 15 * 60_000;
|
|
33
|
+
/** Attempts per file; only transient failures are retried. */
|
|
34
|
+
export const TUI_DOWNLOAD_MAX_ATTEMPTS = 3;
|
|
35
|
+
export const TUI_DOWNLOAD_RETRY_DELAY_MS = 1_000;
|
|
30
36
|
const MAX_CHECKSUM_MANIFEST_BYTES = 4 * 1024;
|
|
37
|
+
/** A second installer says why it is quiet once the wait is longer than a lock handover. */
|
|
38
|
+
const TUI_DOWNLOAD_LOCK_NOTICE_MS = 2_000;
|
|
39
|
+
/** Lets a lock waiter outlast the holder's verify and install after its download budget. */
|
|
40
|
+
const TUI_DOWNLOAD_LOCK_MARGIN_MS = 5_000;
|
|
31
41
|
const SUPPORTED = {
|
|
32
42
|
"darwin-arm64": "darwin-arm64",
|
|
33
43
|
"linux-x64": "linux-x64",
|
|
@@ -109,6 +119,9 @@ export function findDownloadedTui(version = getCurrentVersion(), options = {}) {
|
|
|
109
119
|
purgeCachedBinary(path);
|
|
110
120
|
return null;
|
|
111
121
|
}
|
|
122
|
+
/** A failure a retry cannot fix: the release asset is missing or breaks a hard limit. */
|
|
123
|
+
class PermanentDownloadError extends Error {
|
|
124
|
+
}
|
|
112
125
|
function checksumPath(path) {
|
|
113
126
|
return `${path}.sha256`;
|
|
114
127
|
}
|
|
@@ -198,32 +211,53 @@ function purgeCachedBinary(path) {
|
|
|
198
211
|
}
|
|
199
212
|
}
|
|
200
213
|
}
|
|
201
|
-
|
|
214
|
+
function isTransientHttpStatus(status) {
|
|
215
|
+
return status === 408 || status === 429 || status >= 500;
|
|
216
|
+
}
|
|
217
|
+
async function readBoundedResponse(fetchImpl, url, maxBytes, policy) {
|
|
202
218
|
const controller = new AbortController();
|
|
203
|
-
|
|
219
|
+
let total = 0;
|
|
220
|
+
let timeoutMessage = null;
|
|
221
|
+
const abortWith = (message) => () => {
|
|
222
|
+
timeoutMessage = message();
|
|
223
|
+
controller.abort();
|
|
224
|
+
};
|
|
225
|
+
const overallTimer = setTimeout(abortWith(() => `download did not finish within ${policy.totalTimeoutMs}ms`), Math.max(0, policy.deadline - Date.now()));
|
|
226
|
+
const onStall = abortWith(() => `download stalled: no data for ${policy.stallTimeoutMs}ms after ${total} bytes`);
|
|
227
|
+
let stallTimer = setTimeout(onStall, policy.stallTimeoutMs);
|
|
228
|
+
const markProgress = () => {
|
|
229
|
+
clearTimeout(stallTimer);
|
|
230
|
+
stallTimer = setTimeout(onStall, policy.stallTimeoutMs);
|
|
231
|
+
};
|
|
204
232
|
try {
|
|
205
233
|
const response = await fetchImpl(url, { signal: controller.signal });
|
|
206
234
|
if (!response.ok) {
|
|
207
|
-
|
|
235
|
+
const message = `HTTP ${response.status} ${response.statusText}`;
|
|
236
|
+
if (isTransientHttpStatus(response.status))
|
|
237
|
+
throw new Error(message);
|
|
238
|
+
throw new PermanentDownloadError(message);
|
|
208
239
|
}
|
|
240
|
+
markProgress();
|
|
209
241
|
const declaredLength = Number(response.headers.get("content-length"));
|
|
210
242
|
if (Number.isFinite(declaredLength) && declaredLength > maxBytes) {
|
|
211
|
-
throw new
|
|
243
|
+
throw new PermanentDownloadError(`declared size ${declaredLength} exceeds ${maxBytes} bytes`);
|
|
212
244
|
}
|
|
213
245
|
if (!response.body)
|
|
214
246
|
throw new Error("response has no body");
|
|
215
247
|
const chunks = [];
|
|
216
|
-
let total = 0;
|
|
217
248
|
const reader = response.body.getReader();
|
|
218
249
|
try {
|
|
219
250
|
while (true) {
|
|
220
251
|
const { done, value } = await reader.read();
|
|
221
252
|
if (done)
|
|
222
253
|
break;
|
|
254
|
+
if (value.byteLength === 0)
|
|
255
|
+
continue;
|
|
256
|
+
markProgress();
|
|
223
257
|
total += value.byteLength;
|
|
224
258
|
if (total > maxBytes) {
|
|
225
259
|
await reader.cancel();
|
|
226
|
-
throw new
|
|
260
|
+
throw new PermanentDownloadError(`download exceeds ${maxBytes} bytes`);
|
|
227
261
|
}
|
|
228
262
|
chunks.push(Buffer.from(value));
|
|
229
263
|
}
|
|
@@ -234,13 +268,33 @@ async function readBoundedResponse(fetchImpl, url, maxBytes, timeoutMs) {
|
|
|
234
268
|
return Buffer.concat(chunks, total);
|
|
235
269
|
}
|
|
236
270
|
catch (error) {
|
|
237
|
-
if (
|
|
238
|
-
throw new Error(
|
|
239
|
-
}
|
|
271
|
+
if (timeoutMessage)
|
|
272
|
+
throw new Error(timeoutMessage, { cause: error });
|
|
240
273
|
throw error;
|
|
241
274
|
}
|
|
242
275
|
finally {
|
|
243
|
-
clearTimeout(
|
|
276
|
+
clearTimeout(overallTimer);
|
|
277
|
+
clearTimeout(stallTimer);
|
|
278
|
+
// Releases the connection of any response whose body was not fully read.
|
|
279
|
+
controller.abort();
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
async function readWithRetries(fetchImpl, url, maxBytes, policy, log) {
|
|
283
|
+
for (let attempt = 1;; attempt++) {
|
|
284
|
+
try {
|
|
285
|
+
return await readBoundedResponse(fetchImpl, url, maxBytes, policy);
|
|
286
|
+
}
|
|
287
|
+
catch (error) {
|
|
288
|
+
const delayMs = policy.retryDelayMs * attempt;
|
|
289
|
+
if (error instanceof PermanentDownloadError ||
|
|
290
|
+
attempt >= policy.maxAttempts ||
|
|
291
|
+
Date.now() + delayMs >= policy.deadline) {
|
|
292
|
+
throw error;
|
|
293
|
+
}
|
|
294
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
295
|
+
log(`download attempt ${attempt}/${policy.maxAttempts} failed (${message}); retrying`);
|
|
296
|
+
await new Promise((resolve) => setTimeout(resolve, delayMs));
|
|
297
|
+
}
|
|
244
298
|
}
|
|
245
299
|
}
|
|
246
300
|
/**
|
|
@@ -270,10 +324,11 @@ export async function downloadTuiBinary(opts = {}) {
|
|
|
270
324
|
minBinaryBytes: opts.limits?.minBinaryBytes ?? MIN_TUI_BINARY_BYTES,
|
|
271
325
|
maxCompressedBytes: opts.limits?.maxCompressedBytes ?? MAX_TUI_COMPRESSED_BYTES,
|
|
272
326
|
maxBinaryBytes: opts.limits?.maxBinaryBytes ?? MAX_TUI_BINARY_BYTES,
|
|
273
|
-
timeoutMs: opts.timeoutMs ?? TUI_DOWNLOAD_TIMEOUT_MS,
|
|
274
327
|
};
|
|
328
|
+
const totalTimeoutMs = opts.totalTimeoutMs ?? TUI_DOWNLOAD_TOTAL_TIMEOUT_MS;
|
|
275
329
|
mkdirSync(dirname(dest), { recursive: true });
|
|
276
|
-
const
|
|
330
|
+
const lockNotice = setTimeout(() => log("waiting for another tmux-ide download of this version to finish"), TUI_DOWNLOAD_LOCK_NOTICE_MS);
|
|
331
|
+
const releaseLock = await acquirePrivateOperationLock(downloadLockPath(dest), totalTimeoutMs + TUI_DOWNLOAD_LOCK_MARGIN_MS).finally(() => clearTimeout(lockNotice));
|
|
277
332
|
const suffix = `${process.pid}.${randomUUID()}.tmp`;
|
|
278
333
|
const binaryTmp = `${dest}.${suffix}`;
|
|
279
334
|
const checksumTmp = `${checksumPath(dest)}.${suffix}`;
|
|
@@ -283,17 +338,28 @@ export async function downloadTuiBinary(opts = {}) {
|
|
|
283
338
|
return { path: dest, bytes: cached.bytes };
|
|
284
339
|
purgeCachedBinary(dest);
|
|
285
340
|
const fetchImpl = opts.fetch ?? globalThis.fetch;
|
|
341
|
+
// Starts after the lock so the budget matches what a lock waiter allows for.
|
|
342
|
+
const policy = {
|
|
343
|
+
stallTimeoutMs: opts.stallTimeoutMs ?? TUI_DOWNLOAD_STALL_TIMEOUT_MS,
|
|
344
|
+
deadline: Date.now() + totalTimeoutMs,
|
|
345
|
+
totalTimeoutMs,
|
|
346
|
+
maxAttempts: opts.maxAttempts ?? TUI_DOWNLOAD_MAX_ATTEMPTS,
|
|
347
|
+
retryDelayMs: opts.retryDelayMs ?? TUI_DOWNLOAD_RETRY_DELAY_MS,
|
|
348
|
+
};
|
|
286
349
|
log(`downloading checksum manifest ${checksumUrl}`);
|
|
287
350
|
let manifestBytes;
|
|
288
351
|
let gz;
|
|
289
352
|
try {
|
|
290
|
-
manifestBytes = await
|
|
291
|
-
|
|
353
|
+
manifestBytes = await readWithRetries(fetchImpl, checksumUrl, MAX_CHECKSUM_MANIFEST_BYTES, policy, log);
|
|
354
|
+
log(`downloading ${url}`);
|
|
355
|
+
gz = await readWithRetries(fetchImpl, url, limits.maxCompressedBytes, policy, log);
|
|
292
356
|
}
|
|
293
357
|
catch (error) {
|
|
294
358
|
const message = error instanceof Error ? error.message : String(error);
|
|
295
|
-
|
|
296
|
-
|
|
359
|
+
const hint = error instanceof PermanentDownloadError
|
|
360
|
+
? "Check that the release published both exact-version assets."
|
|
361
|
+
: "Check your network connection, then retry with `tmux-ide update --tui-binary`, or run the installer again if tmux-ide is not installed yet.";
|
|
362
|
+
throw new Error(`could not download the TUI binary and checksum for v${version} (${message}). ${hint}`, { cause: error });
|
|
297
363
|
}
|
|
298
364
|
const manifest = parseChecksumManifest(manifestBytes, { version, tag });
|
|
299
365
|
if (sha256(gz) !== manifest.compressedSha256) {
|
|
@@ -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.17</string>
|
|
25
25
|
<key>CFBundleVersion</key>
|
|
26
|
-
<string>2.9.
|
|
26
|
+
<string>2.9.17</string>
|
|
27
27
|
<key>LSMinimumSystemVersion</key>
|
|
28
28
|
<string>11.0</string>
|
|
29
29
|
<key>LSUIElement</key>
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
package/packages/daemon/dist/native/TmuxIdeNotifier.app/Contents/_CodeSignature/CodeResources
CHANGED
|
@@ -6,11 +6,11 @@
|
|
|
6
6
|
<dict>
|
|
7
7
|
<key>Resources/AppIcon.icns</key>
|
|
8
8
|
<data>
|
|
9
|
-
|
|
9
|
+
/fan3KhjkAUR40oxTxGrk4b03wY=
|
|
10
10
|
</data>
|
|
11
11
|
<key>Resources/Assets.car</key>
|
|
12
12
|
<data>
|
|
13
|
-
|
|
13
|
+
YZSYPnzkfPLVemp3jiRDnR5WhhQ=
|
|
14
14
|
</data>
|
|
15
15
|
</dict>
|
|
16
16
|
<key>files2</key>
|
|
@@ -19,14 +19,14 @@
|
|
|
19
19
|
<dict>
|
|
20
20
|
<key>hash2</key>
|
|
21
21
|
<data>
|
|
22
|
-
|
|
22
|
+
zDoKzf7n2cp6cQedgfmNiTPwTj+gON7DS7H8ihtNVCE=
|
|
23
23
|
</data>
|
|
24
24
|
</dict>
|
|
25
25
|
<key>Resources/Assets.car</key>
|
|
26
26
|
<dict>
|
|
27
27
|
<key>hash2</key>
|
|
28
28
|
<data>
|
|
29
|
-
|
|
29
|
+
zV9+zCqfvUhHrz90MuHUjqBvx93oA7g6BxgO8hDUlN8=
|
|
30
30
|
</data>
|
|
31
31
|
</dict>
|
|
32
32
|
</dict>
|
|
@@ -76,6 +76,42 @@ export function validateBrowserOpen(url, options) {
|
|
|
76
76
|
exitCode: 2,
|
|
77
77
|
});
|
|
78
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
|
+
}
|
|
79
115
|
export function createBrowserPane(run, executable, url, options, environment, cwd, namespace) {
|
|
80
116
|
validateBrowserOpen(url, options);
|
|
81
117
|
const inheritedPane = /^%\d+$/u.test(environment.TMUX_PANE ?? "")
|
|
@@ -163,9 +199,16 @@ export function createBrowserPane(run, executable, url, options, environment, cw
|
|
|
163
199
|
});
|
|
164
200
|
return { pane: pane, session: createdSession, window: createdWindow, status: "created" };
|
|
165
201
|
}
|
|
166
|
-
export function openBrowser(url, options = {}) {
|
|
202
|
+
export async function openBrowser(url, options = {}, dependencies = {}) {
|
|
167
203
|
validateBrowserOpen(url, options);
|
|
168
|
-
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) {
|
|
169
212
|
try {
|
|
170
213
|
const namespace = { ...runtimeNamespaceEnvironment(resolveRuntimeNamespace()) };
|
|
171
214
|
const authority = resolveWorkspacePaneTmuxAuthority();
|
|
@@ -173,7 +216,8 @@ export function openBrowser(url, options = {}) {
|
|
|
173
216
|
const run = createPinnedWorkspaceTmuxRunner(authority, {
|
|
174
217
|
timeoutMs: 10_000,
|
|
175
218
|
});
|
|
176
|
-
|
|
219
|
+
const result = createBrowserPane(run, executable, url, options, process.env, process.cwd(), namespace);
|
|
220
|
+
return { result, run };
|
|
177
221
|
}
|
|
178
222
|
catch (error) {
|
|
179
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
|
+
}
|