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
|
@@ -59,6 +59,8 @@ export class TmuxServerOwners<T extends DisposableTmuxServerOwner> {
|
|
|
59
59
|
#disposed = false;
|
|
60
60
|
#disposePromise: Promise<void> | null = null;
|
|
61
61
|
constructor(readonly options: TmuxServerOwnersOptions<T>) {}
|
|
62
|
+
#queuedRefresh: Promise<readonly TmuxServerDescriptor[]> | null = null;
|
|
63
|
+
#queuedRefreshTail: Promise<unknown> | null = null;
|
|
62
64
|
#serial<R>(work: () => Promise<R>): Promise<R> {
|
|
63
65
|
const run = this.#tail.then(() => {
|
|
64
66
|
if (this.#disposed) throw new TmuxServerScopeError("disposed");
|
|
@@ -178,11 +180,25 @@ export class TmuxServerOwners<T extends DisposableTmuxServerOwner> {
|
|
|
178
180
|
this.#entries.set(scope.serverId, { registration, scope, observation, owner });
|
|
179
181
|
}
|
|
180
182
|
refresh(): Promise<readonly TmuxServerDescriptor[]> {
|
|
181
|
-
|
|
183
|
+
// A refresh still waiting its turn has not probed anything yet, so it is
|
|
184
|
+
// as fresh for a later caller as a new one: share it instead of queuing
|
|
185
|
+
// another full refresh behind it (#709). Once it starts, callers queue anew.
|
|
186
|
+
// Share it only while nothing has been queued behind it, so a refresh
|
|
187
|
+
// stays ordered after any mutation queued before the caller asked.
|
|
188
|
+
if (this.#queuedRefresh && this.#tail === this.#queuedRefreshTail) return this.#queuedRefresh;
|
|
189
|
+
const queued = this.#serial(async () => {
|
|
190
|
+
if (this.#queuedRefresh === queued) this.#queuedRefresh = null;
|
|
182
191
|
// Sequential admission bounds subprocess work and avoids alias races.
|
|
183
192
|
for (const entry of this.#entries.values()) await this.#refresh(entry);
|
|
184
193
|
return this.list();
|
|
185
194
|
});
|
|
195
|
+
this.#queuedRefresh = queued;
|
|
196
|
+
this.#queuedRefreshTail = this.#tail;
|
|
197
|
+
const settle = () => {
|
|
198
|
+
if (this.#queuedRefresh === queued) this.#queuedRefresh = null;
|
|
199
|
+
};
|
|
200
|
+
queued.then(settle, settle);
|
|
201
|
+
return queued;
|
|
186
202
|
}
|
|
187
203
|
current(scopeInput: TmuxServerScope): T {
|
|
188
204
|
const scope = TmuxServerScopeSchemaZ.parse(scopeInput);
|
|
@@ -43,9 +43,19 @@ export const MIN_TUI_BINARY_BYTES = 10 * 1024 * 1024;
|
|
|
43
43
|
/** Hard download/decompression ceilings: release binaries are currently ~70MB. */
|
|
44
44
|
export const MAX_TUI_COMPRESSED_BYTES = 128 * 1024 * 1024;
|
|
45
45
|
export const MAX_TUI_BINARY_BYTES = 256 * 1024 * 1024;
|
|
46
|
-
|
|
46
|
+
/** Abort a request when no bytes arrive for this long; a slow transfer never trips it. */
|
|
47
|
+
export const TUI_DOWNLOAD_STALL_TIMEOUT_MS = 30_000;
|
|
48
|
+
/** One budget for the manifest, the binary, and every retry: ~145 KB/s at the size ceiling. */
|
|
49
|
+
export const TUI_DOWNLOAD_TOTAL_TIMEOUT_MS = 15 * 60_000;
|
|
50
|
+
/** Attempts per file; only transient failures are retried. */
|
|
51
|
+
export const TUI_DOWNLOAD_MAX_ATTEMPTS = 3;
|
|
52
|
+
export const TUI_DOWNLOAD_RETRY_DELAY_MS = 1_000;
|
|
47
53
|
|
|
48
54
|
const MAX_CHECKSUM_MANIFEST_BYTES = 4 * 1024;
|
|
55
|
+
/** A second installer says why it is quiet once the wait is longer than a lock handover. */
|
|
56
|
+
const TUI_DOWNLOAD_LOCK_NOTICE_MS = 2_000;
|
|
57
|
+
/** Lets a lock waiter outlast the holder's verify and install after its download budget. */
|
|
58
|
+
const TUI_DOWNLOAD_LOCK_MARGIN_MS = 5_000;
|
|
49
59
|
|
|
50
60
|
const SUPPORTED: Record<string, TuiPlatformTag> = {
|
|
51
61
|
"darwin-arm64": "darwin-arm64",
|
|
@@ -162,9 +172,19 @@ interface DownloadLimits {
|
|
|
162
172
|
readonly minBinaryBytes: number;
|
|
163
173
|
readonly maxCompressedBytes: number;
|
|
164
174
|
readonly maxBinaryBytes: number;
|
|
165
|
-
readonly timeoutMs: number;
|
|
166
175
|
}
|
|
167
176
|
|
|
177
|
+
interface DownloadPolicy {
|
|
178
|
+
readonly stallTimeoutMs: number;
|
|
179
|
+
readonly deadline: number;
|
|
180
|
+
readonly totalTimeoutMs: number;
|
|
181
|
+
readonly maxAttempts: number;
|
|
182
|
+
readonly retryDelayMs: number;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/** A failure a retry cannot fix: the release asset is missing or breaks a hard limit. */
|
|
186
|
+
class PermanentDownloadError extends Error {}
|
|
187
|
+
|
|
168
188
|
type FetchLike = (input: string | URL | Request, init?: RequestInit) => Promise<Response>;
|
|
169
189
|
|
|
170
190
|
function checksumPath(path: string): string {
|
|
@@ -269,36 +289,61 @@ function purgeCachedBinary(path: string): void {
|
|
|
269
289
|
}
|
|
270
290
|
}
|
|
271
291
|
|
|
292
|
+
function isTransientHttpStatus(status: number): boolean {
|
|
293
|
+
return status === 408 || status === 429 || status >= 500;
|
|
294
|
+
}
|
|
295
|
+
|
|
272
296
|
async function readBoundedResponse(
|
|
273
297
|
fetchImpl: FetchLike,
|
|
274
298
|
url: string,
|
|
275
299
|
maxBytes: number,
|
|
276
|
-
|
|
300
|
+
policy: DownloadPolicy,
|
|
277
301
|
): Promise<Buffer> {
|
|
278
302
|
const controller = new AbortController();
|
|
279
|
-
|
|
303
|
+
let total = 0;
|
|
304
|
+
let timeoutMessage: string | null = null;
|
|
305
|
+
const abortWith = (message: () => string) => () => {
|
|
306
|
+
timeoutMessage = message();
|
|
307
|
+
controller.abort();
|
|
308
|
+
};
|
|
309
|
+
const overallTimer = setTimeout(
|
|
310
|
+
abortWith(() => `download did not finish within ${policy.totalTimeoutMs}ms`),
|
|
311
|
+
Math.max(0, policy.deadline - Date.now()),
|
|
312
|
+
);
|
|
313
|
+
const onStall = abortWith(
|
|
314
|
+
() => `download stalled: no data for ${policy.stallTimeoutMs}ms after ${total} bytes`,
|
|
315
|
+
);
|
|
316
|
+
let stallTimer = setTimeout(onStall, policy.stallTimeoutMs);
|
|
317
|
+
const markProgress = () => {
|
|
318
|
+
clearTimeout(stallTimer);
|
|
319
|
+
stallTimer = setTimeout(onStall, policy.stallTimeoutMs);
|
|
320
|
+
};
|
|
280
321
|
try {
|
|
281
322
|
const response = await fetchImpl(url, { signal: controller.signal });
|
|
282
323
|
if (!response.ok) {
|
|
283
|
-
|
|
324
|
+
const message = `HTTP ${response.status} ${response.statusText}`;
|
|
325
|
+
if (isTransientHttpStatus(response.status)) throw new Error(message);
|
|
326
|
+
throw new PermanentDownloadError(message);
|
|
284
327
|
}
|
|
328
|
+
markProgress();
|
|
285
329
|
const declaredLength = Number(response.headers.get("content-length"));
|
|
286
330
|
if (Number.isFinite(declaredLength) && declaredLength > maxBytes) {
|
|
287
|
-
throw new
|
|
331
|
+
throw new PermanentDownloadError(`declared size ${declaredLength} exceeds ${maxBytes} bytes`);
|
|
288
332
|
}
|
|
289
333
|
if (!response.body) throw new Error("response has no body");
|
|
290
334
|
|
|
291
335
|
const chunks: Buffer[] = [];
|
|
292
|
-
let total = 0;
|
|
293
336
|
const reader = response.body.getReader();
|
|
294
337
|
try {
|
|
295
338
|
while (true) {
|
|
296
339
|
const { done, value } = await reader.read();
|
|
297
340
|
if (done) break;
|
|
341
|
+
if (value.byteLength === 0) continue;
|
|
342
|
+
markProgress();
|
|
298
343
|
total += value.byteLength;
|
|
299
344
|
if (total > maxBytes) {
|
|
300
345
|
await reader.cancel();
|
|
301
|
-
throw new
|
|
346
|
+
throw new PermanentDownloadError(`download exceeds ${maxBytes} bytes`);
|
|
302
347
|
}
|
|
303
348
|
chunks.push(Buffer.from(value));
|
|
304
349
|
}
|
|
@@ -307,12 +352,39 @@ async function readBoundedResponse(
|
|
|
307
352
|
}
|
|
308
353
|
return Buffer.concat(chunks, total);
|
|
309
354
|
} catch (error) {
|
|
310
|
-
if (
|
|
311
|
-
throw new Error(`download timed out after ${timeoutMs}ms`, { cause: error });
|
|
312
|
-
}
|
|
355
|
+
if (timeoutMessage) throw new Error(timeoutMessage, { cause: error });
|
|
313
356
|
throw error;
|
|
314
357
|
} finally {
|
|
315
|
-
clearTimeout(
|
|
358
|
+
clearTimeout(overallTimer);
|
|
359
|
+
clearTimeout(stallTimer);
|
|
360
|
+
// Releases the connection of any response whose body was not fully read.
|
|
361
|
+
controller.abort();
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
async function readWithRetries(
|
|
366
|
+
fetchImpl: FetchLike,
|
|
367
|
+
url: string,
|
|
368
|
+
maxBytes: number,
|
|
369
|
+
policy: DownloadPolicy,
|
|
370
|
+
log: (msg: string) => void,
|
|
371
|
+
): Promise<Buffer> {
|
|
372
|
+
for (let attempt = 1; ; attempt++) {
|
|
373
|
+
try {
|
|
374
|
+
return await readBoundedResponse(fetchImpl, url, maxBytes, policy);
|
|
375
|
+
} catch (error) {
|
|
376
|
+
const delayMs = policy.retryDelayMs * attempt;
|
|
377
|
+
if (
|
|
378
|
+
error instanceof PermanentDownloadError ||
|
|
379
|
+
attempt >= policy.maxAttempts ||
|
|
380
|
+
Date.now() + delayMs >= policy.deadline
|
|
381
|
+
) {
|
|
382
|
+
throw error;
|
|
383
|
+
}
|
|
384
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
385
|
+
log(`download attempt ${attempt}/${policy.maxAttempts} failed (${message}); retrying`);
|
|
386
|
+
await new Promise((resolve) => setTimeout(resolve, delayMs));
|
|
387
|
+
}
|
|
316
388
|
}
|
|
317
389
|
}
|
|
318
390
|
|
|
@@ -333,8 +405,11 @@ export async function downloadTuiBinary(
|
|
|
333
405
|
home?: string;
|
|
334
406
|
tag?: TuiPlatformTag | null;
|
|
335
407
|
fetch?: FetchLike;
|
|
336
|
-
|
|
337
|
-
|
|
408
|
+
stallTimeoutMs?: number;
|
|
409
|
+
totalTimeoutMs?: number;
|
|
410
|
+
maxAttempts?: number;
|
|
411
|
+
retryDelayMs?: number;
|
|
412
|
+
limits?: Partial<DownloadLimits>;
|
|
338
413
|
} = {},
|
|
339
414
|
): Promise<{ path: string; bytes: number }> {
|
|
340
415
|
if (resolveRuntimeNamespace().development)
|
|
@@ -356,13 +431,17 @@ export async function downloadTuiBinary(
|
|
|
356
431
|
minBinaryBytes: opts.limits?.minBinaryBytes ?? MIN_TUI_BINARY_BYTES,
|
|
357
432
|
maxCompressedBytes: opts.limits?.maxCompressedBytes ?? MAX_TUI_COMPRESSED_BYTES,
|
|
358
433
|
maxBinaryBytes: opts.limits?.maxBinaryBytes ?? MAX_TUI_BINARY_BYTES,
|
|
359
|
-
timeoutMs: opts.timeoutMs ?? TUI_DOWNLOAD_TIMEOUT_MS,
|
|
360
434
|
};
|
|
435
|
+
const totalTimeoutMs = opts.totalTimeoutMs ?? TUI_DOWNLOAD_TOTAL_TIMEOUT_MS;
|
|
361
436
|
mkdirSync(dirname(dest), { recursive: true });
|
|
437
|
+
const lockNotice = setTimeout(
|
|
438
|
+
() => log("waiting for another tmux-ide download of this version to finish"),
|
|
439
|
+
TUI_DOWNLOAD_LOCK_NOTICE_MS,
|
|
440
|
+
);
|
|
362
441
|
const releaseLock = await acquirePrivateOperationLock(
|
|
363
442
|
downloadLockPath(dest),
|
|
364
|
-
|
|
365
|
-
);
|
|
443
|
+
totalTimeoutMs + TUI_DOWNLOAD_LOCK_MARGIN_MS,
|
|
444
|
+
).finally(() => clearTimeout(lockNotice));
|
|
366
445
|
const suffix = `${process.pid}.${randomUUID()}.tmp`;
|
|
367
446
|
const binaryTmp = `${dest}.${suffix}`;
|
|
368
447
|
const checksumTmp = `${checksumPath(dest)}.${suffix}`;
|
|
@@ -372,22 +451,35 @@ export async function downloadTuiBinary(
|
|
|
372
451
|
purgeCachedBinary(dest);
|
|
373
452
|
|
|
374
453
|
const fetchImpl = opts.fetch ?? globalThis.fetch;
|
|
454
|
+
// Starts after the lock so the budget matches what a lock waiter allows for.
|
|
455
|
+
const policy: DownloadPolicy = {
|
|
456
|
+
stallTimeoutMs: opts.stallTimeoutMs ?? TUI_DOWNLOAD_STALL_TIMEOUT_MS,
|
|
457
|
+
deadline: Date.now() + totalTimeoutMs,
|
|
458
|
+
totalTimeoutMs,
|
|
459
|
+
maxAttempts: opts.maxAttempts ?? TUI_DOWNLOAD_MAX_ATTEMPTS,
|
|
460
|
+
retryDelayMs: opts.retryDelayMs ?? TUI_DOWNLOAD_RETRY_DELAY_MS,
|
|
461
|
+
};
|
|
375
462
|
log(`downloading checksum manifest ${checksumUrl}`);
|
|
376
463
|
let manifestBytes: Buffer;
|
|
377
464
|
let gz: Buffer;
|
|
378
465
|
try {
|
|
379
|
-
manifestBytes = await
|
|
466
|
+
manifestBytes = await readWithRetries(
|
|
380
467
|
fetchImpl,
|
|
381
468
|
checksumUrl,
|
|
382
469
|
MAX_CHECKSUM_MANIFEST_BYTES,
|
|
383
|
-
|
|
470
|
+
policy,
|
|
471
|
+
log,
|
|
384
472
|
);
|
|
385
|
-
|
|
473
|
+
log(`downloading ${url}`);
|
|
474
|
+
gz = await readWithRetries(fetchImpl, url, limits.maxCompressedBytes, policy, log);
|
|
386
475
|
} catch (error) {
|
|
387
476
|
const message = error instanceof Error ? error.message : String(error);
|
|
477
|
+
const hint =
|
|
478
|
+
error instanceof PermanentDownloadError
|
|
479
|
+
? "Check that the release published both exact-version assets."
|
|
480
|
+
: "Check your network connection, then retry with `tmux-ide update --tui-binary`, or run the installer again if tmux-ide is not installed yet.";
|
|
388
481
|
throw new Error(
|
|
389
|
-
`could not download the TUI binary and checksum for v${version} (${message}).
|
|
390
|
-
`Check that the release published both exact-version assets.`,
|
|
482
|
+
`could not download the TUI binary and checksum for v${version} (${message}). ${hint}`,
|
|
391
483
|
{ cause: error },
|
|
392
484
|
);
|
|
393
485
|
}
|
|
@@ -115,6 +115,54 @@ export function validateBrowserOpen(url: string | undefined, options: OpenBrowse
|
|
|
115
115
|
}
|
|
116
116
|
|
|
117
117
|
type Run = (args: readonly string[]) => string;
|
|
118
|
+
|
|
119
|
+
/** Printed when the opened browser never publishes tmux-ide's native controls. */
|
|
120
|
+
export const NO_NATIVE_CONTROLS_NOTICE =
|
|
121
|
+
"This browser has no tmux-ide controls; install the native app for back/forward/reload.";
|
|
122
|
+
|
|
123
|
+
/** A browser bundled in a native app publishes its controls; a standalone one may not. */
|
|
124
|
+
function isBundledBrowser(executable: string): boolean {
|
|
125
|
+
return /\/[^/]+\.app\/Contents\/Resources\/browser\//u.test(executable);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
export interface ControlsWait {
|
|
129
|
+
sleep?: (ms: number) => Promise<void>;
|
|
130
|
+
/** Milliseconds; Date.now by default. */
|
|
131
|
+
now?: () => number;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/** How long a standalone browser has to publish its controls. */
|
|
135
|
+
const STANDALONE_WAIT_MS = 2000;
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* Watches the new pane for @tmux_ide_browser_control, which only a browser with
|
|
139
|
+
* native controls sets. A browser bundled in an app is never reported: if it is
|
|
140
|
+
* slow, its controls appear when it is ready. A standalone browser gets 2 s of
|
|
141
|
+
* wall-clock time, however slow tmux answers, and then the notice. Returns
|
|
142
|
+
* null once the option appears or when the pane is already gone.
|
|
143
|
+
*/
|
|
144
|
+
export async function nativeControlsNotice(
|
|
145
|
+
run: Run,
|
|
146
|
+
pane: string,
|
|
147
|
+
executable: string,
|
|
148
|
+
wait: ControlsWait = {},
|
|
149
|
+
): Promise<string | null> {
|
|
150
|
+
if (isBundledBrowser(executable)) return null;
|
|
151
|
+
const sleep = wait.sleep ?? ((ms: number) => new Promise<void>((r) => setTimeout(r, ms)));
|
|
152
|
+
const now = wait.now ?? Date.now;
|
|
153
|
+
const deadline = now() + STANDALONE_WAIT_MS;
|
|
154
|
+
for (;;) {
|
|
155
|
+
let control: string;
|
|
156
|
+
try {
|
|
157
|
+
control = run(["display-message", "-p", "-t", pane, "#{@tmux_ide_browser_control}"]).trim();
|
|
158
|
+
} catch {
|
|
159
|
+
return null;
|
|
160
|
+
}
|
|
161
|
+
if (control) return null;
|
|
162
|
+
if (now() >= deadline) return NO_NATIVE_CONTROLS_NOTICE;
|
|
163
|
+
await sleep(250);
|
|
164
|
+
}
|
|
165
|
+
}
|
|
118
166
|
export function createBrowserPane(
|
|
119
167
|
run: Run,
|
|
120
168
|
executable: string,
|
|
@@ -220,9 +268,37 @@ export function createBrowserPane(
|
|
|
220
268
|
return { pane: pane!, session: createdSession!, window: createdWindow!, status: "created" };
|
|
221
269
|
}
|
|
222
270
|
|
|
223
|
-
|
|
271
|
+
/** Seams for tests; the CLI uses the defaults. */
|
|
272
|
+
export interface OpenBrowserDependencies {
|
|
273
|
+
notice?: (line: string) => void;
|
|
274
|
+
executable?: () => string;
|
|
275
|
+
create?: typeof createPinnedBrowserPane;
|
|
276
|
+
wait?: ControlsWait;
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
export async function openBrowser(
|
|
280
|
+
url: string | undefined,
|
|
281
|
+
options: OpenBrowserOptions = {},
|
|
282
|
+
dependencies: OpenBrowserDependencies = {},
|
|
283
|
+
) {
|
|
224
284
|
validateBrowserOpen(url, options);
|
|
225
|
-
const executable = resolveTerminalBrowser();
|
|
285
|
+
const executable = (dependencies.executable ?? resolveTerminalBrowser)();
|
|
286
|
+
const { result, run } = (dependencies.create ?? createPinnedBrowserPane)(
|
|
287
|
+
executable,
|
|
288
|
+
url,
|
|
289
|
+
options,
|
|
290
|
+
);
|
|
291
|
+
const message = await nativeControlsNotice(run, result.pane, executable, dependencies.wait);
|
|
292
|
+
if (message)
|
|
293
|
+
(dependencies.notice ?? ((line: string) => process.stderr.write(`${line}\n`)))(message);
|
|
294
|
+
return result;
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
function createPinnedBrowserPane(
|
|
298
|
+
executable: string,
|
|
299
|
+
url: string | undefined,
|
|
300
|
+
options: OpenBrowserOptions,
|
|
301
|
+
): { result: ReturnType<typeof createBrowserPane>; run: Run } {
|
|
226
302
|
try {
|
|
227
303
|
const namespace = { ...runtimeNamespaceEnvironment(resolveRuntimeNamespace()) };
|
|
228
304
|
const authority = resolveWorkspacePaneTmuxAuthority();
|
|
@@ -230,7 +306,16 @@ export function openBrowser(url: string | undefined, options: OpenBrowserOptions
|
|
|
230
306
|
const run = createPinnedWorkspaceTmuxRunner(authority, {
|
|
231
307
|
timeoutMs: 10_000,
|
|
232
308
|
});
|
|
233
|
-
|
|
309
|
+
const result = createBrowserPane(
|
|
310
|
+
run,
|
|
311
|
+
executable,
|
|
312
|
+
url,
|
|
313
|
+
options,
|
|
314
|
+
process.env,
|
|
315
|
+
process.cwd(),
|
|
316
|
+
namespace,
|
|
317
|
+
);
|
|
318
|
+
return { result, run };
|
|
234
319
|
} catch (error) {
|
|
235
320
|
if (error instanceof IdeError) throw error;
|
|
236
321
|
throw new IdeError(
|
|
@@ -28,6 +28,7 @@ import { inflateSync } from "node:zlib";
|
|
|
28
28
|
import { PaneStreamLayoutSnapshotFrameSchemaZ, type WindowLinkTopology } from "@tmux-ide/contracts";
|
|
29
29
|
import type { TerminalDeliveryGraphics } from "../session-runtime/terminal-delivery-hub.ts";
|
|
30
30
|
import { z } from "zod";
|
|
31
|
+
import { logger } from "../../lib/log.ts";
|
|
31
32
|
import {
|
|
32
33
|
PANE_STREAM_MAX_HELD_DELTAS,
|
|
33
34
|
PANE_STREAM_MAX_OUTPUT_BYTES,
|
|
@@ -988,6 +989,11 @@ class PreAuthAdmission implements PaneStreamPreAuthAdmission {
|
|
|
988
989
|
this.#open = false;
|
|
989
990
|
this.#cancelDeadline();
|
|
990
991
|
const socket = this.#socket;
|
|
992
|
+
logger.info("pane-stream", "admission closed", {
|
|
993
|
+
code,
|
|
994
|
+
reason,
|
|
995
|
+
hostClientId: this.hostClientId,
|
|
996
|
+
});
|
|
991
997
|
this.#detach();
|
|
992
998
|
this.#onRelease(this);
|
|
993
999
|
if (socket) safeCloseSocket(socket, code, reason);
|
|
@@ -1386,6 +1392,12 @@ export class PaneStreamLiveConnection {
|
|
|
1386
1392
|
close(code = 1000, reason = "stream-closed"): void {
|
|
1387
1393
|
if (this.#closed) return;
|
|
1388
1394
|
this.#recordDiagnosticLifecycle("pane-stream-terminal", code, reason);
|
|
1395
|
+
logger.info("pane-stream", "stream closed", {
|
|
1396
|
+
code,
|
|
1397
|
+
reason,
|
|
1398
|
+
session: this.#descriptor.sessionName,
|
|
1399
|
+
clientId: this.#clientId,
|
|
1400
|
+
});
|
|
1389
1401
|
this.#closed = true;
|
|
1390
1402
|
for (const demand of this.#surfaceDemands.values()) demand.close();
|
|
1391
1403
|
this.#surfaceDemands.clear();
|
|
@@ -12,6 +12,7 @@ import { encodeTerminalProjectionCooperatively } from "@tmux-ide/core";
|
|
|
12
12
|
type DeliveryEnvelope = TerminalDeliveryEnvelope | TerminalProjectionEnvelope;
|
|
13
13
|
import type { KittyImage } from "../graphics/kitty-graphics.ts";
|
|
14
14
|
import { StockCaptureTabUnavailableError } from "./stock-capture-fidelity.ts";
|
|
15
|
+
import { logger } from "../../lib/log.ts";
|
|
15
16
|
import {
|
|
16
17
|
MAX_RETAINED_NATIVE_BACKING_BYTES,
|
|
17
18
|
takeNativeSeedBacking,
|
|
@@ -512,6 +513,11 @@ export class SessionRuntimeTerminalDeliveryHub {
|
|
|
512
513
|
} catch (error) {
|
|
513
514
|
if (!(error instanceof StockCaptureTabUnavailableError)) throw error;
|
|
514
515
|
// Negotiation still settles this pane; no invented canonical seed is published.
|
|
516
|
+
logDeliveryFault(
|
|
517
|
+
{ paneId: semanticPaneId, deliveryWorkspaceName: address },
|
|
518
|
+
"source-closed",
|
|
519
|
+
"capture-unavailable",
|
|
520
|
+
);
|
|
515
521
|
await accept({
|
|
516
522
|
type: "terminal.delivery.fault",
|
|
517
523
|
reason: "source-closed",
|
|
@@ -697,6 +703,7 @@ export class SessionRuntimeTerminalDeliveryHub {
|
|
|
697
703
|
this.#cancelEncoding(client);
|
|
698
704
|
client.outgoing.length = 0;
|
|
699
705
|
client.inFlight = null;
|
|
706
|
+
logDeliveryFault(client, "source-closed", "source-restarted");
|
|
700
707
|
this.#enqueue(client, {
|
|
701
708
|
type: "terminal.delivery.fault",
|
|
702
709
|
reason: "source-closed",
|
|
@@ -2008,6 +2015,7 @@ export class SessionRuntimeTerminalDeliveryHub {
|
|
|
2008
2015
|
message: string,
|
|
2009
2016
|
selectionObservation: SemanticSelectionObservation | null = null,
|
|
2010
2017
|
): void {
|
|
2018
|
+
logDeliveryFault(client, reason);
|
|
2011
2019
|
if (this.#observability.enabled)
|
|
2012
2020
|
try {
|
|
2013
2021
|
const atMicros = this.#observability.nowMicros();
|
|
@@ -2694,3 +2702,20 @@ function joinBytes(chunks: readonly Uint8Array[]): Uint8Array {
|
|
|
2694
2702
|
}
|
|
2695
2703
|
return joined;
|
|
2696
2704
|
}
|
|
2705
|
+
|
|
2706
|
+
/**
|
|
2707
|
+
* One info line per fault a client is sent, so a client's drop has a daemon-side
|
|
2708
|
+
* record. Ids and fixed reasons only: the fault message can carry source text.
|
|
2709
|
+
*/
|
|
2710
|
+
function logDeliveryFault(
|
|
2711
|
+
client: Pick<ClientState, "paneId" | "deliveryWorkspaceName">,
|
|
2712
|
+
reason: "state-too-large" | "source-closed" | "protocol-violation",
|
|
2713
|
+
cause?: "source-restarted" | "capture-unavailable",
|
|
2714
|
+
): void {
|
|
2715
|
+
logger.info("terminal-delivery", "fault sent to client", {
|
|
2716
|
+
reason,
|
|
2717
|
+
...(cause ? { cause } : {}),
|
|
2718
|
+
pane: client.paneId,
|
|
2719
|
+
workspace: client.deliveryWorkspaceName,
|
|
2720
|
+
});
|
|
2721
|
+
}
|
package/skill/SKILL.md
CHANGED
|
@@ -1,8 +1,13 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: tmux-ide
|
|
3
|
+
description: "Use tmux-ide from a coding agent to report your own working/blocked/done status, read the agent fleet, message other panes, restore sessions after a crash, and set up worktrees and workspace layouts."
|
|
4
|
+
---
|
|
5
|
+
|
|
1
6
|
# tmux-ide — Claude Code Skill
|
|
2
7
|
|
|
3
8
|
<!-- tmux-ide-skill-version: 2.6.0 -->
|
|
4
9
|
|
|
5
|
-
tmux-ide is **the open-source workspace for coding agents
|
|
10
|
+
tmux-ide is **the open-source tmux workspace for coding agents**. The
|
|
6
11
|
app (`tmux-ide app`) shows every agent across local and SSH machines with
|
|
7
12
|
ground-truth working/blocked/done status, and drives the live tmux session.
|
|
8
13
|
`tmux-ide adopt` adds the same status to plain tmux clients as tmux chrome: a
|