tmux-ide 2.9.0-beta.19 → 2.9.0-beta.21
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/bin/cli.js +1979 -783
- package/package.json +4 -2
- package/packages/contracts/src/__tests__/daemon-wire.test.ts +32 -0
- package/packages/contracts/src/daemon-events.ts +12 -12
- package/packages/contracts/src/daemon-wire.ts +30 -0
- package/packages/daemon/dist/command-center/agent-status-watch.js +9 -2
- package/packages/daemon/dist/command-center/diagnostics.js +65 -0
- package/packages/daemon/dist/command-center/log-stream.js +9 -0
- package/packages/daemon/dist/command-center/resources/fleet-preview-route.js +22 -6
- package/packages/daemon/dist/command-center/server.js +7 -0
- package/packages/daemon/dist/doctor.js +62 -0
- package/packages/daemon/dist/lib/__tests__/installed-recovery-fixture.js +400 -0
- package/packages/daemon/dist/lib/app-config.js +4 -2
- package/packages/daemon/dist/lib/canonical-daemon.js +1 -0
- package/packages/daemon/dist/lib/daemon-embed.js +24 -0
- package/packages/daemon/dist/lib/daemon-provenance.js +575 -0
- package/packages/daemon/dist/lib/headless-daemon.js +1 -0
- package/packages/daemon/dist/lib/log-sanitize.js +199 -0
- package/packages/daemon/dist/lib/log.js +123 -13
- package/packages/daemon/dist/lib/soak-diagnostics.js +124 -0
- package/packages/daemon/dist/lib/soak-verdict.js +472 -0
- package/packages/daemon/dist/lib/terminal-host-color.js +33 -0
- package/packages/daemon/dist/lib/tmux-external-interaction-observer.js +187 -39
- package/packages/daemon/dist/lib/tmux-interaction-retention.js +21 -0
- package/packages/daemon/dist/lib/workspace-promotion.js +55 -37
- package/packages/daemon/dist/terminal/mirror/session-channel.js +2 -1
- package/packages/daemon/dist/tui/mirror/automatic-contrast.js +161 -0
- package/packages/daemon/dist/tui/mirror/open-tui-workspace-runtime-port.js +9 -3
- package/packages/daemon/dist/tui/mirror/pane-surface.jsx +7 -5
- package/packages/daemon/dist/tui/mirror/resize-transaction.js +48 -20
- package/packages/daemon/dist/tui/mirror/runtime/application-appearance-owner.js +40 -6
- package/packages/daemon/dist/tui/mirror/runtime/application-fleet-preview.js +4 -1
- package/packages/daemon/dist/tui/mirror/runtime/application-machine-sidebar.jsx +63 -42
- package/packages/daemon/dist/tui/mirror/runtime/application-terminal-interaction-controller.js +138 -67
- package/packages/daemon/dist/tui/mirror/runtime/application-terminal-palette-owner.js +44 -28
- package/packages/daemon/dist/tui/mirror/runtime/application-terminal-workspace.jsx +49 -11
- package/packages/daemon/dist/tui/mirror/runtime/semantic-shell-viewport-resize.js +140 -2
- package/packages/daemon/dist/tui/mirror/runtime/workspace-terminal-fast-lane.js +3 -1
- package/packages/daemon/dist/tui/mirror/semantic-pane-render-source.js +2 -1
- package/packages/daemon/dist/tui/mirror/theme.js +4 -31
- package/packages/daemon/dist/tui/mirror/workspace/terminal-pane-header.jsx +15 -8
- package/packages/daemon/dist/tui/team/wait-receipts.js +112 -33
- package/packages/daemon/src/command-center/agent-status-watch.ts +8 -2
- package/packages/daemon/src/command-center/diagnostics.ts +75 -0
- package/packages/daemon/src/command-center/log-stream.ts +8 -0
- package/packages/daemon/src/command-center/resources/fleet-preview-route.ts +27 -5
- package/packages/daemon/src/command-center/server.ts +8 -0
- package/packages/daemon/src/doctor.ts +70 -0
- package/packages/daemon/src/lib/app-config.ts +11 -3
- package/packages/daemon/src/lib/canonical-daemon.ts +1 -0
- package/packages/daemon/src/lib/daemon-embed.ts +30 -0
- package/packages/daemon/src/lib/daemon-provenance.ts +769 -0
- package/packages/daemon/src/lib/fleet-preview-model.ts +1 -0
- package/packages/daemon/src/lib/headless-daemon.ts +1 -0
- package/packages/daemon/src/lib/log-sanitize.ts +248 -0
- package/packages/daemon/src/lib/log.ts +165 -14
- package/packages/daemon/src/lib/soak-diagnostics.ts +183 -0
- package/packages/daemon/src/lib/soak-verdict.ts +785 -0
- package/packages/daemon/src/lib/terminal-host-color.ts +43 -0
- package/packages/daemon/src/lib/tmux-external-interaction-observer.ts +233 -37
- package/packages/daemon/src/lib/tmux-interaction-retention.ts +24 -0
- package/packages/daemon/src/lib/workspace-promotion.ts +74 -45
- package/packages/daemon/src/terminal/mirror/session-channel.ts +2 -1
- package/packages/daemon/src/tui/mirror/automatic-contrast.ts +180 -0
- package/packages/daemon/src/tui/mirror/open-tui-workspace-runtime-port.ts +26 -5
- package/packages/daemon/src/tui/mirror/pane-surface.tsx +9 -8
- package/packages/daemon/src/tui/mirror/resize-transaction.ts +46 -22
- package/packages/daemon/src/tui/mirror/runtime/application-appearance-owner.ts +45 -6
- package/packages/daemon/src/tui/mirror/runtime/application-fleet-preview.ts +4 -0
- package/packages/daemon/src/tui/mirror/runtime/application-fleet-switcher.tsx +2 -1
- package/packages/daemon/src/tui/mirror/runtime/application-machine-sidebar.tsx +99 -75
- package/packages/daemon/src/tui/mirror/runtime/application-palette-preview.tsx +13 -3
- package/packages/daemon/src/tui/mirror/runtime/application-reference-sheet.tsx +125 -0
- package/packages/daemon/src/tui/mirror/runtime/application-root-v2.tsx +2 -1
- package/packages/daemon/src/tui/mirror/runtime/application-shell-overlays.tsx +199 -157
- package/packages/daemon/src/tui/mirror/runtime/application-shell-view.tsx +2 -0
- package/packages/daemon/src/tui/mirror/runtime/application-terminal-interaction-controller.ts +148 -69
- package/packages/daemon/src/tui/mirror/runtime/application-terminal-palette-owner.ts +55 -28
- package/packages/daemon/src/tui/mirror/runtime/application-terminal-workspace.tsx +59 -17
- package/packages/daemon/src/tui/mirror/runtime/semantic-shell-viewport-resize.ts +151 -3
- package/packages/daemon/src/tui/mirror/runtime/workspace-terminal-fast-lane.ts +3 -1
- package/packages/daemon/src/tui/mirror/semantic-pane-render-source.ts +2 -1
- package/packages/daemon/src/tui/mirror/theme.ts +4 -39
- package/packages/daemon/src/tui/mirror/workspace/terminal-pane-header.tsx +21 -8
- package/packages/daemon/src/tui/team/wait-receipts.ts +115 -40
- package/packages/daemon-client/src/terminal-fast-lane.test.ts +18 -0
- package/packages/daemon-client/src/terminal-fast-lane.ts +7 -2
- package/packages/tmux-bridge/src/index.ts +2 -0
- package/packages/tmux-bridge/src/runner.test.ts +41 -0
- package/packages/tmux-bridge/src/runner.ts +41 -2
|
@@ -2,12 +2,11 @@
|
|
|
2
2
|
* Receipt-driven `wait agent-status` — the push twin of the polling loop in
|
|
3
3
|
* `wait.ts`.
|
|
4
4
|
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
* pushes the completion.
|
|
5
|
+
* Daemon completion receipts and session invalidations are wake-up hints, not
|
|
6
|
+
* proof that the whole session reached a status. Re-read the same aggregate
|
|
7
|
+
* session status as the polling path: another agent may still be working or
|
|
8
|
+
* blocked. Neither transport proves success of a submitted task. Bursts of
|
|
9
|
+
* hints share one read, with a persistent classification tracker.
|
|
11
10
|
*
|
|
12
11
|
* Honest degrade: this path only answers for the receipt-covered targets
|
|
13
12
|
* (`done` / `idle` — the "turn finished" statuses). Every other case — no
|
|
@@ -16,7 +15,7 @@
|
|
|
16
15
|
* existing polling implementation. A timeout is a real answer, not a fallback.
|
|
17
16
|
*
|
|
18
17
|
* Deps are injected (daemon record reader, liveness probe, socket factory,
|
|
19
|
-
*
|
|
18
|
+
* aggregate status read, clock) so the waiting logic unit-tests without a
|
|
20
19
|
* daemon; the exported defaults wire the real io.
|
|
21
20
|
*/
|
|
22
21
|
|
|
@@ -33,7 +32,7 @@ import { findSessionStatus } from "./report.ts";
|
|
|
33
32
|
import { listTeamSessions } from "./sessions.ts";
|
|
34
33
|
import { WAIT_DEFAULT_TIMEOUT_MS, type WaitAgentStatusResult } from "./wait.ts";
|
|
35
34
|
|
|
36
|
-
/** How long to
|
|
35
|
+
/** How long to allow socket open, hello and observer-install acknowledgement before falling back to polling. */
|
|
37
36
|
export const RECEIPT_CONNECT_TIMEOUT_MS = 1_500;
|
|
38
37
|
|
|
39
38
|
/** The statuses a turn-completed receipt can settle; all others need the poll. */
|
|
@@ -46,6 +45,7 @@ export interface ReceiptSocket {
|
|
|
46
45
|
on(event: "open" | "close", listener: () => void): unknown;
|
|
47
46
|
on(event: "error", listener: (err: Error) => void): unknown;
|
|
48
47
|
on(event: "message", listener: (data: unknown) => void): unknown;
|
|
48
|
+
send(data: string): void;
|
|
49
49
|
close(): void;
|
|
50
50
|
}
|
|
51
51
|
|
|
@@ -59,10 +59,10 @@ export interface WaitReceiptsOpts {
|
|
|
59
59
|
/** Socket factory (defaults to `ws` against the daemon's `/ws/events`). */
|
|
60
60
|
openSocket?: (url: string) => ReceiptSocket;
|
|
61
61
|
/**
|
|
62
|
-
*
|
|
62
|
+
* Aggregate session status read after observer acknowledgement and on coalesced hints. A turn completed
|
|
63
63
|
* before this process launched is answered immediately instead of waiting
|
|
64
64
|
* for a receipt that already fired. Defaults to the same fleet read the
|
|
65
|
-
* polling loop uses
|
|
65
|
+
* polling loop uses, with a tracker retained for the entire wait.
|
|
66
66
|
*/
|
|
67
67
|
currentStatus?: () => AgentStatus | null;
|
|
68
68
|
now?: () => number;
|
|
@@ -89,6 +89,17 @@ export async function waitForAgentStatusViaReceipts(
|
|
|
89
89
|
): Promise<WaitAgentStatusResult | null> {
|
|
90
90
|
if (!isReceiptCoveredStatus(want)) return null;
|
|
91
91
|
|
|
92
|
+
const timeoutMs = opts.timeoutMs ?? WAIT_DEFAULT_TIMEOUT_MS;
|
|
93
|
+
const now = opts.now ?? Date.now;
|
|
94
|
+
const started = now();
|
|
95
|
+
const timedOut = (): WaitAgentStatusResult => ({
|
|
96
|
+
ok: false,
|
|
97
|
+
session,
|
|
98
|
+
want,
|
|
99
|
+
status: null,
|
|
100
|
+
timedOutAfterMs: timeoutMs,
|
|
101
|
+
});
|
|
102
|
+
const remaining = (): number => Math.max(0, timeoutMs - (now() - started));
|
|
92
103
|
const readInfo = opts.readDaemonInfo ?? readCanonicalDaemonInfo;
|
|
93
104
|
let info: CanonicalDaemonInfo | null;
|
|
94
105
|
try {
|
|
@@ -98,9 +109,23 @@ export async function waitForAgentStatusViaReceipts(
|
|
|
98
109
|
}
|
|
99
110
|
if (!info) return null;
|
|
100
111
|
const probeAlive = opts.probeAlive ?? isCanonicalDaemonAlive;
|
|
101
|
-
|
|
112
|
+
let probeTimer: ReturnType<typeof setTimeout> | undefined;
|
|
113
|
+
try {
|
|
114
|
+
const alive = await Promise.race([
|
|
115
|
+
probeAlive(info),
|
|
116
|
+
new Promise<null>((resolve) => {
|
|
117
|
+
probeTimer = setTimeout(() => resolve(null), remaining());
|
|
118
|
+
probeTimer.unref?.();
|
|
119
|
+
}),
|
|
120
|
+
]);
|
|
121
|
+
if (alive === null || remaining() === 0) return timedOut();
|
|
122
|
+
if (!alive) return null;
|
|
123
|
+
} catch {
|
|
124
|
+
return null;
|
|
125
|
+
} finally {
|
|
126
|
+
clearTimeout(probeTimer);
|
|
127
|
+
}
|
|
102
128
|
|
|
103
|
-
const timeoutMs = opts.timeoutMs ?? WAIT_DEFAULT_TIMEOUT_MS;
|
|
104
129
|
const connectTimeoutMs = opts.connectTimeoutMs ?? RECEIPT_CONNECT_TIMEOUT_MS;
|
|
105
130
|
// A loopback-bound daemon admits same-machine upgrades without a token; a
|
|
106
131
|
// remote-bound daemon enforces its token even locally, so present the one
|
|
@@ -113,9 +138,9 @@ export async function waitForAgentStatusViaReceipts(
|
|
|
113
138
|
url,
|
|
114
139
|
authToken ? { headers: { Authorization: `Bearer ${authToken}` } } : undefined,
|
|
115
140
|
) as unknown as ReceiptSocket);
|
|
141
|
+
const tracker = createStatusTracker();
|
|
116
142
|
const currentStatus =
|
|
117
|
-
opts.currentStatus ??
|
|
118
|
-
(() => findSessionStatus(listTeamSessions(createStatusTracker()), session));
|
|
143
|
+
opts.currentStatus ?? (() => findSessionStatus(listTeamSessions(tracker), session));
|
|
119
144
|
|
|
120
145
|
const url = canonicalDaemonUrl("ws", info.bindHostname, info.port, "/ws/events");
|
|
121
146
|
let socket: ReceiptSocket;
|
|
@@ -127,15 +152,19 @@ export async function waitForAgentStatusViaReceipts(
|
|
|
127
152
|
|
|
128
153
|
return new Promise<WaitAgentStatusResult | null>((resolve) => {
|
|
129
154
|
let settled = false;
|
|
155
|
+
let subscribed = false;
|
|
156
|
+
let ready = false;
|
|
130
157
|
let lastStatus: AgentStatus | null = null;
|
|
131
158
|
let deadlineTimer: ReturnType<typeof setTimeout> | null = null;
|
|
132
159
|
let connectTimer: ReturnType<typeof setTimeout> | null = null;
|
|
160
|
+
let refreshTimer: ReturnType<typeof setTimeout> | null = null;
|
|
133
161
|
|
|
134
162
|
const settle = (result: WaitAgentStatusResult | null): void => {
|
|
135
163
|
if (settled) return;
|
|
136
164
|
settled = true;
|
|
137
165
|
if (connectTimer !== null) clearTimeout(connectTimer);
|
|
138
166
|
if (deadlineTimer !== null) clearTimeout(deadlineTimer);
|
|
167
|
+
if (refreshTimer !== null) clearTimeout(refreshTimer);
|
|
139
168
|
try {
|
|
140
169
|
socket.close();
|
|
141
170
|
} catch {
|
|
@@ -144,32 +173,25 @@ export async function waitForAgentStatusViaReceipts(
|
|
|
144
173
|
resolve(result);
|
|
145
174
|
};
|
|
146
175
|
|
|
147
|
-
|
|
148
|
-
connectTimer.unref?.();
|
|
149
|
-
|
|
150
|
-
socket.on("error", () => settle(null));
|
|
151
|
-
socket.on("close", () => settle(null));
|
|
152
|
-
|
|
153
|
-
socket.on("open", () => {
|
|
176
|
+
const refreshStatus = (): void => {
|
|
154
177
|
if (settled) return;
|
|
155
|
-
if (connectTimer !== null) clearTimeout(connectTimer);
|
|
156
|
-
deadlineTimer = setTimeout(
|
|
157
|
-
() => settle({ ok: false, session, want, status: lastStatus, timedOutAfterMs: timeoutMs }),
|
|
158
|
-
timeoutMs,
|
|
159
|
-
);
|
|
160
|
-
deadlineTimer.unref?.();
|
|
161
|
-
// The watcher baselines when the first client connects, so a receipt can
|
|
162
|
-
// only describe a transition AFTER this socket exists. Answer the
|
|
163
|
-
// already-finished case with one direct read.
|
|
164
178
|
try {
|
|
165
179
|
lastStatus = currentStatus();
|
|
166
180
|
} catch {
|
|
167
|
-
|
|
181
|
+
// This transport cannot establish aggregate status; use the fallback.
|
|
182
|
+
settle(null);
|
|
183
|
+
return;
|
|
168
184
|
}
|
|
169
|
-
if (lastStatus === want) {
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
});
|
|
185
|
+
if (lastStatus === want) settle({ ok: true, session, want, status: want });
|
|
186
|
+
};
|
|
187
|
+
|
|
188
|
+
deadlineTimer = setTimeout(() => settle({ ...timedOut(), status: lastStatus }), remaining());
|
|
189
|
+
deadlineTimer.unref?.();
|
|
190
|
+
connectTimer = setTimeout(() => settle(null), connectTimeoutMs);
|
|
191
|
+
connectTimer.unref?.();
|
|
192
|
+
|
|
193
|
+
socket.on("error", () => settle(null));
|
|
194
|
+
socket.on("close", () => settle(null));
|
|
173
195
|
|
|
174
196
|
socket.on("message", (data) => {
|
|
175
197
|
if (settled) return;
|
|
@@ -180,13 +202,66 @@ export async function waitForAgentStatusViaReceipts(
|
|
|
180
202
|
return; // not a protocol frame — ignore
|
|
181
203
|
}
|
|
182
204
|
const parsed = DaemonEventServerFrameSchemaZ.safeParse(raw);
|
|
183
|
-
if (!parsed.success)
|
|
205
|
+
if (!parsed.success) {
|
|
206
|
+
if (!ready) settle(null); // incompatible handshake/protocol
|
|
207
|
+
return;
|
|
208
|
+
}
|
|
184
209
|
const frame = parsed.data;
|
|
185
|
-
if (frame.type
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
settle({ ok: true, session, want, status: want });
|
|
210
|
+
if (frame.type === "protocol.error") {
|
|
211
|
+
settle(null);
|
|
212
|
+
return;
|
|
189
213
|
}
|
|
214
|
+
if (frame.type === "hello") {
|
|
215
|
+
if (subscribed) return;
|
|
216
|
+
if (
|
|
217
|
+
frame.daemon.protocolVersion !== info.protocolVersion ||
|
|
218
|
+
frame.daemon.instanceId !== info.instanceId
|
|
219
|
+
) {
|
|
220
|
+
settle(null);
|
|
221
|
+
return;
|
|
222
|
+
}
|
|
223
|
+
subscribed = true;
|
|
224
|
+
try {
|
|
225
|
+
socket.send(
|
|
226
|
+
JSON.stringify({
|
|
227
|
+
type: "subscribe",
|
|
228
|
+
sessions: [],
|
|
229
|
+
legacyEvents: true,
|
|
230
|
+
interests: [{ resource: "fleet-catalog", workspaceName: null }],
|
|
231
|
+
interestRevision: 1,
|
|
232
|
+
}),
|
|
233
|
+
);
|
|
234
|
+
} catch {
|
|
235
|
+
settle(null);
|
|
236
|
+
}
|
|
237
|
+
return;
|
|
238
|
+
}
|
|
239
|
+
if (frame.type === "resource.interests-ack") {
|
|
240
|
+
if (!subscribed || ready || frame.interestRevision !== 1) return;
|
|
241
|
+
if (frame.unavailableInterests.length > 0) {
|
|
242
|
+
settle(null);
|
|
243
|
+
return;
|
|
244
|
+
}
|
|
245
|
+
ready = true;
|
|
246
|
+
if (connectTimer !== null) clearTimeout(connectTimer);
|
|
247
|
+
// Observation is installed and baselined before this read. Hints that
|
|
248
|
+
// arrived before the barrier are covered by this current snapshot.
|
|
249
|
+
refreshStatus();
|
|
250
|
+
return;
|
|
251
|
+
}
|
|
252
|
+
if (!ready) return;
|
|
253
|
+
|
|
254
|
+
if (
|
|
255
|
+
(frame.type !== "agent.turn-completed" && frame.type !== "agent-status.changed") ||
|
|
256
|
+
frame.sessionName !== session
|
|
257
|
+
)
|
|
258
|
+
return;
|
|
259
|
+
if (refreshTimer !== null) return;
|
|
260
|
+
refreshTimer = setTimeout(() => {
|
|
261
|
+
refreshTimer = null;
|
|
262
|
+
refreshStatus();
|
|
263
|
+
}, 0);
|
|
264
|
+
refreshTimer.unref?.();
|
|
190
265
|
});
|
|
191
266
|
});
|
|
192
267
|
}
|
|
@@ -648,6 +648,24 @@ describe("terminal fast lane", () => {
|
|
|
648
648
|
});
|
|
649
649
|
});
|
|
650
650
|
|
|
651
|
+
it("keeps equal-sized scoped targets distinct across an in-flight resize", async () => {
|
|
652
|
+
const { lane, control } = rig();
|
|
653
|
+
control.owned.add("geometry");
|
|
654
|
+
const gate = deferred<void>();
|
|
655
|
+
control.resizeGates.push(gate.promise, Promise.resolve());
|
|
656
|
+
const first = lane.resize({ semanticWindowId: "window.a", cols: 80, rows: 24 });
|
|
657
|
+
const second = lane.resize({ semanticWindowId: "window.b", cols: 80, rows: 24 });
|
|
658
|
+
gate.resolve();
|
|
659
|
+
await settle();
|
|
660
|
+
expect(await first).toEqual({ status: "applied" });
|
|
661
|
+
expect(await second).toEqual({ status: "applied" });
|
|
662
|
+
expect(control.resizes.map(({ viewport }) => viewport.semanticWindowId)).toEqual([
|
|
663
|
+
"window.a",
|
|
664
|
+
"window.b",
|
|
665
|
+
]);
|
|
666
|
+
lane.dispose();
|
|
667
|
+
});
|
|
668
|
+
|
|
651
669
|
it("preserves an exact geometry authority conflict without treating it as applied", async () => {
|
|
652
670
|
const { lane, control } = rig();
|
|
653
671
|
control.owned.add("geometry");
|
|
@@ -73,6 +73,7 @@ export interface TerminalFastLaneControlPort {
|
|
|
73
73
|
}
|
|
74
74
|
|
|
75
75
|
export interface TerminalFastLaneViewport {
|
|
76
|
+
readonly semanticWindowId?: string;
|
|
76
77
|
readonly cols: number;
|
|
77
78
|
readonly rows: number;
|
|
78
79
|
}
|
|
@@ -225,7 +226,11 @@ function validViewport(viewport: TerminalFastLaneViewport): boolean {
|
|
|
225
226
|
Number.isInteger(viewport.cols) &&
|
|
226
227
|
Number.isInteger(viewport.rows) &&
|
|
227
228
|
viewport.cols > 0 &&
|
|
228
|
-
viewport.rows > 0
|
|
229
|
+
viewport.rows > 0 &&
|
|
230
|
+
(viewport.semanticWindowId === undefined ||
|
|
231
|
+
(typeof viewport.semanticWindowId === "string" &&
|
|
232
|
+
viewport.semanticWindowId.length > 0 &&
|
|
233
|
+
viewport.semanticWindowId.length <= 256))
|
|
229
234
|
);
|
|
230
235
|
}
|
|
231
236
|
|
|
@@ -744,7 +749,7 @@ export function createTerminalFastLane(options: TerminalFastLaneOptions): Termin
|
|
|
744
749
|
return Promise.resolve({ status: "failed", error: new TypeError("invalid viewport") });
|
|
745
750
|
}
|
|
746
751
|
const retained = Object.freeze({ ...viewport });
|
|
747
|
-
const key =
|
|
752
|
+
const key = JSON.stringify([retained.semanticWindowId ?? null, retained.cols, retained.rows]);
|
|
748
753
|
const epoch = generationEpoch;
|
|
749
754
|
mutableCounters.resizeAccepted += 1;
|
|
750
755
|
return new Promise((resolve) => {
|
|
@@ -23,6 +23,7 @@ import {
|
|
|
23
23
|
runSessionCommand,
|
|
24
24
|
runTmux,
|
|
25
25
|
runTmuxBinary,
|
|
26
|
+
runTmuxBinaryAsync,
|
|
26
27
|
} from "./index.ts";
|
|
27
28
|
|
|
28
29
|
let mockExec;
|
|
@@ -571,3 +572,43 @@ describe("error classification", () => {
|
|
|
571
572
|
expect(result).toEqual({ running: false, reason: "SESSION_NOT_FOUND" });
|
|
572
573
|
});
|
|
573
574
|
});
|
|
575
|
+
|
|
576
|
+
// --- Async pinned runner (real child processes; no executor mock) ---
|
|
577
|
+
|
|
578
|
+
describe("runTmuxBinaryAsync", () => {
|
|
579
|
+
it("resolves stdout of a pinned executable off the event loop", async () => {
|
|
580
|
+
await expect(runTmuxBinaryAsync("/bin/sh", ["-c", "printf ok"])).resolves.toBe("ok");
|
|
581
|
+
});
|
|
582
|
+
|
|
583
|
+
it("classifies a missing target exactly like the sync runner, keeping the cause", async () => {
|
|
584
|
+
let caught;
|
|
585
|
+
try {
|
|
586
|
+
await runTmuxBinaryAsync("/bin/sh", ["-c", 'echo "can\'t find session: nope" >&2; exit 1']);
|
|
587
|
+
} catch (error) {
|
|
588
|
+
caught = error;
|
|
589
|
+
}
|
|
590
|
+
expect(caught).toBeInstanceOf(TmuxError);
|
|
591
|
+
expect(caught.code).toBe("SESSION_NOT_FOUND");
|
|
592
|
+
expect(caught.cause).toBeInstanceOf(Error);
|
|
593
|
+
});
|
|
594
|
+
|
|
595
|
+
it("classifies an unreachable server as TMUX_UNAVAILABLE", async () => {
|
|
596
|
+
let caught;
|
|
597
|
+
try {
|
|
598
|
+
await runTmuxBinaryAsync("/bin/sh", ["-c", "echo 'no server running on /x' >&2; exit 1"]);
|
|
599
|
+
} catch (error) {
|
|
600
|
+
caught = error;
|
|
601
|
+
}
|
|
602
|
+
expect(caught).toBeInstanceOf(TmuxError);
|
|
603
|
+
expect(caught.code).toBe("TMUX_UNAVAILABLE");
|
|
604
|
+
});
|
|
605
|
+
|
|
606
|
+
it("sanitizes the client environment it hands the child", async () => {
|
|
607
|
+
const output = await runTmuxBinaryAsync(
|
|
608
|
+
"/bin/sh",
|
|
609
|
+
["-c", 'printf "%s|%s" "$COLORTERM" "$NO_COLOR"'],
|
|
610
|
+
{ env: { PATH: "/usr/bin:/bin", NO_COLOR: "1" } },
|
|
611
|
+
);
|
|
612
|
+
expect(output).toBe("truecolor|");
|
|
613
|
+
});
|
|
614
|
+
});
|
|
@@ -1,4 +1,10 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import {
|
|
2
|
+
execFile,
|
|
3
|
+
execFileSync,
|
|
4
|
+
spawn,
|
|
5
|
+
type ExecFileOptions,
|
|
6
|
+
type ExecFileSyncOptions,
|
|
7
|
+
} from "node:child_process";
|
|
2
8
|
import { TmuxError } from "./errors.ts";
|
|
3
9
|
|
|
4
10
|
const DEBUG = process.env.TMUX_IDE_DEBUG === "1";
|
|
@@ -99,7 +105,40 @@ export function runTmuxBinary(
|
|
|
99
105
|
}
|
|
100
106
|
}
|
|
101
107
|
|
|
102
|
-
|
|
108
|
+
/**
|
|
109
|
+
* Async twin of {@link runTmuxBinary}: the same pinned-executable contract and
|
|
110
|
+
* the same `TmuxError` classification, but the child runs off the event loop so
|
|
111
|
+
* a daemon mutation path never stalls other connected clients while tmux
|
|
112
|
+
* answers. The classified error keeps the raw execFile error as `cause`.
|
|
113
|
+
*/
|
|
114
|
+
export function runTmuxBinaryAsync(
|
|
115
|
+
executable: string,
|
|
116
|
+
args: string[],
|
|
117
|
+
options: ExecFileOptions & { readonly signal?: AbortSignal } = {},
|
|
118
|
+
): Promise<string> {
|
|
119
|
+
if (DEBUG || globalThis.__tmuxIdeVerbose) {
|
|
120
|
+
console.error(` [tmux] ${args.join(" ")}`);
|
|
121
|
+
}
|
|
122
|
+
return new Promise<string>((resolve, reject) => {
|
|
123
|
+
execFile(
|
|
124
|
+
executable,
|
|
125
|
+
args,
|
|
126
|
+
{
|
|
127
|
+
windowsHide: true,
|
|
128
|
+
...options,
|
|
129
|
+
encoding: "utf8",
|
|
130
|
+
env: sanitizeTmuxClientEnvironment(options.env ?? process.env),
|
|
131
|
+
},
|
|
132
|
+
(error, stdout) => {
|
|
133
|
+
if (error) reject(classifyTmuxError(error));
|
|
134
|
+
else resolve(stdout);
|
|
135
|
+
},
|
|
136
|
+
);
|
|
137
|
+
});
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/** Map a raw child-process failure to the shared `TmuxError` code taxonomy. */
|
|
141
|
+
export function classifyTmuxError(error: unknown): TmuxError {
|
|
103
142
|
const detail = getErrorDetail(error).toLowerCase();
|
|
104
143
|
|
|
105
144
|
if (SESSION_NOT_FOUND_PATTERNS.some((pattern) => detail.includes(pattern))) {
|