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.
Files changed (90) hide show
  1. package/bin/cli.js +1979 -783
  2. package/package.json +4 -2
  3. package/packages/contracts/src/__tests__/daemon-wire.test.ts +32 -0
  4. package/packages/contracts/src/daemon-events.ts +12 -12
  5. package/packages/contracts/src/daemon-wire.ts +30 -0
  6. package/packages/daemon/dist/command-center/agent-status-watch.js +9 -2
  7. package/packages/daemon/dist/command-center/diagnostics.js +65 -0
  8. package/packages/daemon/dist/command-center/log-stream.js +9 -0
  9. package/packages/daemon/dist/command-center/resources/fleet-preview-route.js +22 -6
  10. package/packages/daemon/dist/command-center/server.js +7 -0
  11. package/packages/daemon/dist/doctor.js +62 -0
  12. package/packages/daemon/dist/lib/__tests__/installed-recovery-fixture.js +400 -0
  13. package/packages/daemon/dist/lib/app-config.js +4 -2
  14. package/packages/daemon/dist/lib/canonical-daemon.js +1 -0
  15. package/packages/daemon/dist/lib/daemon-embed.js +24 -0
  16. package/packages/daemon/dist/lib/daemon-provenance.js +575 -0
  17. package/packages/daemon/dist/lib/headless-daemon.js +1 -0
  18. package/packages/daemon/dist/lib/log-sanitize.js +199 -0
  19. package/packages/daemon/dist/lib/log.js +123 -13
  20. package/packages/daemon/dist/lib/soak-diagnostics.js +124 -0
  21. package/packages/daemon/dist/lib/soak-verdict.js +472 -0
  22. package/packages/daemon/dist/lib/terminal-host-color.js +33 -0
  23. package/packages/daemon/dist/lib/tmux-external-interaction-observer.js +187 -39
  24. package/packages/daemon/dist/lib/tmux-interaction-retention.js +21 -0
  25. package/packages/daemon/dist/lib/workspace-promotion.js +55 -37
  26. package/packages/daemon/dist/terminal/mirror/session-channel.js +2 -1
  27. package/packages/daemon/dist/tui/mirror/automatic-contrast.js +161 -0
  28. package/packages/daemon/dist/tui/mirror/open-tui-workspace-runtime-port.js +9 -3
  29. package/packages/daemon/dist/tui/mirror/pane-surface.jsx +7 -5
  30. package/packages/daemon/dist/tui/mirror/resize-transaction.js +48 -20
  31. package/packages/daemon/dist/tui/mirror/runtime/application-appearance-owner.js +40 -6
  32. package/packages/daemon/dist/tui/mirror/runtime/application-fleet-preview.js +4 -1
  33. package/packages/daemon/dist/tui/mirror/runtime/application-machine-sidebar.jsx +63 -42
  34. package/packages/daemon/dist/tui/mirror/runtime/application-terminal-interaction-controller.js +138 -67
  35. package/packages/daemon/dist/tui/mirror/runtime/application-terminal-palette-owner.js +44 -28
  36. package/packages/daemon/dist/tui/mirror/runtime/application-terminal-workspace.jsx +49 -11
  37. package/packages/daemon/dist/tui/mirror/runtime/semantic-shell-viewport-resize.js +140 -2
  38. package/packages/daemon/dist/tui/mirror/runtime/workspace-terminal-fast-lane.js +3 -1
  39. package/packages/daemon/dist/tui/mirror/semantic-pane-render-source.js +2 -1
  40. package/packages/daemon/dist/tui/mirror/theme.js +4 -31
  41. package/packages/daemon/dist/tui/mirror/workspace/terminal-pane-header.jsx +15 -8
  42. package/packages/daemon/dist/tui/team/wait-receipts.js +112 -33
  43. package/packages/daemon/src/command-center/agent-status-watch.ts +8 -2
  44. package/packages/daemon/src/command-center/diagnostics.ts +75 -0
  45. package/packages/daemon/src/command-center/log-stream.ts +8 -0
  46. package/packages/daemon/src/command-center/resources/fleet-preview-route.ts +27 -5
  47. package/packages/daemon/src/command-center/server.ts +8 -0
  48. package/packages/daemon/src/doctor.ts +70 -0
  49. package/packages/daemon/src/lib/app-config.ts +11 -3
  50. package/packages/daemon/src/lib/canonical-daemon.ts +1 -0
  51. package/packages/daemon/src/lib/daemon-embed.ts +30 -0
  52. package/packages/daemon/src/lib/daemon-provenance.ts +769 -0
  53. package/packages/daemon/src/lib/fleet-preview-model.ts +1 -0
  54. package/packages/daemon/src/lib/headless-daemon.ts +1 -0
  55. package/packages/daemon/src/lib/log-sanitize.ts +248 -0
  56. package/packages/daemon/src/lib/log.ts +165 -14
  57. package/packages/daemon/src/lib/soak-diagnostics.ts +183 -0
  58. package/packages/daemon/src/lib/soak-verdict.ts +785 -0
  59. package/packages/daemon/src/lib/terminal-host-color.ts +43 -0
  60. package/packages/daemon/src/lib/tmux-external-interaction-observer.ts +233 -37
  61. package/packages/daemon/src/lib/tmux-interaction-retention.ts +24 -0
  62. package/packages/daemon/src/lib/workspace-promotion.ts +74 -45
  63. package/packages/daemon/src/terminal/mirror/session-channel.ts +2 -1
  64. package/packages/daemon/src/tui/mirror/automatic-contrast.ts +180 -0
  65. package/packages/daemon/src/tui/mirror/open-tui-workspace-runtime-port.ts +26 -5
  66. package/packages/daemon/src/tui/mirror/pane-surface.tsx +9 -8
  67. package/packages/daemon/src/tui/mirror/resize-transaction.ts +46 -22
  68. package/packages/daemon/src/tui/mirror/runtime/application-appearance-owner.ts +45 -6
  69. package/packages/daemon/src/tui/mirror/runtime/application-fleet-preview.ts +4 -0
  70. package/packages/daemon/src/tui/mirror/runtime/application-fleet-switcher.tsx +2 -1
  71. package/packages/daemon/src/tui/mirror/runtime/application-machine-sidebar.tsx +99 -75
  72. package/packages/daemon/src/tui/mirror/runtime/application-palette-preview.tsx +13 -3
  73. package/packages/daemon/src/tui/mirror/runtime/application-reference-sheet.tsx +125 -0
  74. package/packages/daemon/src/tui/mirror/runtime/application-root-v2.tsx +2 -1
  75. package/packages/daemon/src/tui/mirror/runtime/application-shell-overlays.tsx +199 -157
  76. package/packages/daemon/src/tui/mirror/runtime/application-shell-view.tsx +2 -0
  77. package/packages/daemon/src/tui/mirror/runtime/application-terminal-interaction-controller.ts +148 -69
  78. package/packages/daemon/src/tui/mirror/runtime/application-terminal-palette-owner.ts +55 -28
  79. package/packages/daemon/src/tui/mirror/runtime/application-terminal-workspace.tsx +59 -17
  80. package/packages/daemon/src/tui/mirror/runtime/semantic-shell-viewport-resize.ts +151 -3
  81. package/packages/daemon/src/tui/mirror/runtime/workspace-terminal-fast-lane.ts +3 -1
  82. package/packages/daemon/src/tui/mirror/semantic-pane-render-source.ts +2 -1
  83. package/packages/daemon/src/tui/mirror/theme.ts +4 -39
  84. package/packages/daemon/src/tui/mirror/workspace/terminal-pane-header.tsx +21 -8
  85. package/packages/daemon/src/tui/team/wait-receipts.ts +115 -40
  86. package/packages/daemon-client/src/terminal-fast-lane.test.ts +18 -0
  87. package/packages/daemon-client/src/terminal-fast-lane.ts +7 -2
  88. package/packages/tmux-bridge/src/index.ts +2 -0
  89. package/packages/tmux-bridge/src/runner.test.ts +41 -0
  90. 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
- * When a canonical daemon is running, its agent-status watcher already
6
- * observes every `@agent_state` transition and emits a typed
7
- * `agent.turn-completed` receipt on the `/ws/events` bus. Waiting on that
8
- * receipt replaces the CLI's local poll (which re-scrapes the whole fleet
9
- * every 750 ms) with a single WebSocket that sits idle until the daemon
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
- * one-shot status read, clock) so the waiting logic unit-tests without a
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 give the daemon socket to open before falling back to polling. */
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
- * One-shot status read run AFTER the socket opens, so a turn that completed
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 (a single iteration, not a loop).
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
- if (!(await probeAlive(info))) return null;
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
- connectTimer = setTimeout(() => settle(null), connectTimeoutMs);
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
- lastStatus = null;
181
+ // This transport cannot establish aggregate status; use the fallback.
182
+ settle(null);
183
+ return;
168
184
  }
169
- if (lastStatus === want) {
170
- settle({ ok: true, session, want, status: want });
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) return;
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 !== "agent.turn-completed" || frame.sessionName !== session) return;
186
- lastStatus = frame.toStatus;
187
- if (frame.toStatus === want) {
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 = `${retained.cols}x${retained.rows}`;
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) => {
@@ -2,6 +2,8 @@ export { TmuxError } from "./errors.ts";
2
2
  export {
3
3
  runTmux,
4
4
  runTmuxBinary,
5
+ runTmuxBinaryAsync,
6
+ classifyTmuxError,
5
7
  sanitizeTmuxClientEnvironment,
6
8
  _setExecutor,
7
9
  _setSpawner,
@@ -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 { execFileSync, spawn, type ExecFileSyncOptions } from "node:child_process";
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
- function classifyTmuxError(error: unknown): TmuxError {
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))) {