tmux-ide 2.9.3 → 2.9.5

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 (85) hide show
  1. package/LICENSE +1 -1
  2. package/README.md +120 -139
  3. package/bin/cli.js +4847 -3585
  4. package/package.json +16 -9
  5. package/packages/contracts/src/session-runtime.ts +2 -0
  6. package/packages/contracts/src/terminal-delivery.ts +4 -0
  7. package/packages/core/src/terminal-compact-capability.ts +31 -1
  8. package/packages/core/src/terminal-delivery.ts +234 -32
  9. package/packages/core/src/terminal-replica-hash-cache.ts +162 -21
  10. package/packages/core/src/terminal-replica-owned-row.ts +165 -10
  11. package/packages/core/src/terminal-replica.ts +43 -21
  12. package/packages/daemon/dist/lib/daemon-embed.js +19 -34
  13. package/packages/daemon/dist/lib/runtime-trace-writer.js +133 -0
  14. package/packages/daemon/dist/lib/tmux-client-execution.js +37 -2
  15. package/packages/daemon/dist/lib/tmux-server-owner.js +20 -4
  16. package/packages/daemon/dist/native/TmuxIdeNotifier.app/Contents/Info.plist +2 -2
  17. package/packages/daemon/dist/native/TmuxIdeNotifier.app/Contents/MacOS/tmux-ide-notifier +0 -0
  18. package/packages/daemon/dist/native/TmuxIdeNotifier.app/Contents/Resources/Assets.car +0 -0
  19. package/packages/daemon/dist/native/TmuxIdeNotifier.app/Contents/_CodeSignature/CodeResources +2 -2
  20. package/packages/daemon/dist/native/tmux/darwin-arm64/manifest.json +5 -1
  21. package/packages/daemon/dist/native/tmux/darwin-arm64/tmux +0 -0
  22. package/packages/daemon/dist/native/tmux/darwin-x64/manifest.json +5 -1
  23. package/packages/daemon/dist/native/tmux/darwin-x64/tmux +0 -0
  24. package/packages/daemon/dist/native/tmux/linux-arm64/manifest.json +5 -1
  25. package/packages/daemon/dist/native/tmux/linux-arm64/tmux +0 -0
  26. package/packages/daemon/dist/native/tmux/linux-x64/licenses/libevent_core-2.1.so.7.txt +0 -0
  27. package/packages/daemon/dist/native/tmux/linux-x64/manifest.json +5 -1
  28. package/packages/daemon/dist/native/tmux/linux-x64/tmux +0 -0
  29. package/packages/daemon/dist/terminal/attachments/native-runtime.js +6 -6
  30. package/packages/daemon/dist/terminal/mirror/control-channel.js +379 -75
  31. package/packages/daemon/dist/terminal/mirror/mirror-service.js +43 -3
  32. package/packages/daemon/dist/terminal/mirror/native-grid-projection.js +20 -11
  33. package/packages/daemon/dist/terminal/mirror/session-channel.js +639 -811
  34. package/packages/daemon/dist/terminal/mirror/stock-pane-snapshot.js +177 -0
  35. package/packages/daemon/dist/terminal/session-runtime/causal-cell-ledger.js +1 -1
  36. package/packages/daemon/dist/terminal/session-runtime/registry.js +145 -15
  37. package/packages/daemon/dist/terminal/session-runtime/stock-capture-fidelity.js +12 -0
  38. package/packages/daemon/dist/terminal/session-runtime/terminal-delivery-hub.js +128 -10
  39. package/packages/daemon/dist/terminal/session-runtime/terminal-replica-interpreter.js +21 -4
  40. package/packages/daemon/dist/terminal/session-runtime/terminal-replica-owner.js +19 -2
  41. package/packages/daemon/dist/terminal/session-runtime/xterm-terminal-interpreter-backend.js +17 -19
  42. package/packages/daemon/dist/tui/integrations/claude-agent-teams.js +264 -0
  43. package/packages/daemon/dist/tui/mirror/open-tui-workspace-runtime-port.js +64 -4
  44. package/packages/daemon/dist/tui/mirror/pane-surface.jsx +10 -1
  45. package/packages/daemon/dist/tui/mirror/reference-performance-trace.js +117 -71
  46. package/packages/daemon/dist/tui/mirror/runtime/application-terminal-input-ingress.js +27 -3
  47. package/packages/daemon/dist/tui/mirror/runtime/pane-scoped-terminal-surface.jsx +17 -4
  48. package/packages/daemon/dist/tui/mirror/runtime/terminal-fast-lane-renderer-adapter.js +19 -0
  49. package/packages/daemon/dist/tui/mirror/runtime/terminal-input-adapter.js +10 -2
  50. package/packages/daemon/dist/tui/mirror/testing/styled-frame-oracle.js +156 -0
  51. package/packages/daemon/src/lib/daemon-embed.ts +22 -34
  52. package/packages/daemon/src/lib/runtime-trace-writer.ts +132 -0
  53. package/packages/daemon/src/lib/tmux-client-execution.ts +47 -2
  54. package/packages/daemon/src/lib/tmux-server-owner.ts +19 -3
  55. package/packages/daemon/src/terminal/attachments/native-runtime.ts +6 -0
  56. package/packages/daemon/src/terminal/mirror/control-channel.ts +428 -84
  57. package/packages/daemon/src/terminal/mirror/events.ts +7 -7
  58. package/packages/daemon/src/terminal/mirror/mirror-service.ts +49 -3
  59. package/packages/daemon/src/terminal/mirror/native-grid-projection.ts +21 -11
  60. package/packages/daemon/src/terminal/mirror/pane-feed.ts +9 -4
  61. package/packages/daemon/src/terminal/mirror/session-channel.ts +705 -859
  62. package/packages/daemon/src/terminal/mirror/stock-pane-snapshot.ts +228 -0
  63. package/packages/daemon/src/terminal/session-runtime/causal-cell-ledger.ts +1 -1
  64. package/packages/daemon/src/terminal/session-runtime/registry.ts +203 -32
  65. package/packages/daemon/src/terminal/session-runtime/runtime-observability.ts +7 -0
  66. package/packages/daemon/src/terminal/session-runtime/stock-capture-fidelity.ts +13 -0
  67. package/packages/daemon/src/terminal/session-runtime/terminal-delivery-hub.ts +142 -8
  68. package/packages/daemon/src/terminal/session-runtime/terminal-replica-interpreter.ts +26 -3
  69. package/packages/daemon/src/terminal/session-runtime/terminal-replica-owner.ts +25 -1
  70. package/packages/daemon/src/terminal/session-runtime/xterm-terminal-interpreter-backend.ts +26 -20
  71. package/packages/daemon/src/tui/integrations/claude-agent-teams.ts +334 -0
  72. package/packages/daemon/src/tui/mirror/open-tui-workspace-runtime-port.ts +63 -4
  73. package/packages/daemon/src/tui/mirror/pane-surface.tsx +9 -1
  74. package/packages/daemon/src/tui/mirror/reference-performance-trace.ts +141 -90
  75. package/packages/daemon/src/tui/mirror/runtime/application-terminal-input-ingress.ts +27 -4
  76. package/packages/daemon/src/tui/mirror/runtime/pane-scoped-terminal-surface.tsx +61 -31
  77. package/packages/daemon/src/tui/mirror/runtime/terminal-fast-lane-renderer-adapter.ts +31 -3
  78. package/packages/daemon/src/tui/mirror/runtime/terminal-input-adapter.ts +9 -6
  79. package/packages/daemon/src/tui/mirror/testing/styled-frame-oracle.ts +209 -0
  80. package/packages/daemon-client/src/terminal-fast-lane.ts +53 -4
  81. package/packages/daemon-client/src/workspace-client-types.ts +1 -0
  82. package/packages/daemon-client/src/workspace-client.ts +78 -5
  83. package/packages/tmux-bridge/src/index.ts +1 -0
  84. package/packages/tmux-bridge/src/runner.ts +25 -1
  85. package/skill/SKILL.md +30 -30
@@ -48,6 +48,19 @@ export function createApplicationTerminalInputIngress(interaction, generation, s
48
48
  left.clientGeneration === right.clientGeneration &&
49
49
  left.session === right.session &&
50
50
  left.pane === right.pane;
51
+ // Rejection is an outcome for this exact terminal scope, not queue occupancy.
52
+ // Successful delivery of the admitted prefix must not hide lost input.
53
+ let rejectedRecoveryScope = null;
54
+ const rejectedRecoveryNote = "some terminal input was not sent during recovery";
55
+ const rejectedRecoveryAdmissionNote = `terminal input queue full · ${rejectedRecoveryNote}`;
56
+ const clearRejectedRecovery = () => {
57
+ if (!rejectedRecoveryScope)
58
+ return;
59
+ rejectedRecoveryScope = null;
60
+ setNote((current) => current === rejectedRecoveryNote || current === rejectedRecoveryAdmissionNote
61
+ ? null
62
+ : current);
63
+ };
51
64
  let recoveryVersion = 0;
52
65
  const cancelRecovery = () => {
53
66
  recoveryVersion++;
@@ -62,6 +75,8 @@ export function createApplicationTerminalInputIngress(interaction, generation, s
62
75
  const target = scope();
63
76
  if (!target)
64
77
  return false;
78
+ if (rejectedRecoveryScope && !sameScope(rejectedRecoveryScope, target))
79
+ clearRejectedRecovery();
65
80
  if (recovery && !sameScope(recovery.scope, target))
66
81
  cancelRecovery();
67
82
  if (!recovery) {
@@ -74,7 +89,8 @@ export function createApplicationTerminalInputIngress(interaction, generation, s
74
89
  }
75
90
  const weight = bytes.byteLength;
76
91
  if (recovery.admitted >= 64 || recovery.weight + weight > 1024 * 1024) {
77
- setNote("terminal input queue full · wait for the session to connect");
92
+ rejectedRecoveryScope = target;
93
+ setNote(rejectedRecoveryAdmissionNote);
78
94
  return true;
79
95
  }
80
96
  recovery.weight += weight;
@@ -96,7 +112,8 @@ export function createApplicationTerminalInputIngress(interaction, generation, s
96
112
  }
97
113
  return true;
98
114
  });
99
- setNote(`${target.session} · terminal ${kind} queued`);
115
+ if (!rejectedRecoveryScope)
116
+ setNote(`${target.session} · terminal ${kind} queued`);
100
117
  return true;
101
118
  };
102
119
  const flushRecovery = () => {
@@ -130,7 +147,11 @@ export function createApplicationTerminalInputIngress(interaction, generation, s
130
147
  }
131
148
  if (version === recoveryVersion) {
132
149
  cancelRecovery();
133
- setNote(null);
150
+ if (rejectedRecoveryScope) {
151
+ setNote((current) => current === rejectedRecoveryAdmissionNote ? rejectedRecoveryNote : current);
152
+ }
153
+ else
154
+ setNote(null);
134
155
  }
135
156
  })();
136
157
  };
@@ -165,6 +186,7 @@ export function createApplicationTerminalInputIngress(interaction, generation, s
165
186
  };
166
187
  const cancelInteractionInput = () => {
167
188
  cancelRecovery();
189
+ clearRejectedRecovery();
168
190
  interaction.cancelPendingInput();
169
191
  };
170
192
  return {
@@ -191,6 +213,8 @@ export function createApplicationTerminalInputIngress(interaction, generation, s
191
213
  });
192
214
  },
193
215
  adopt(terminalsActive = true) {
216
+ if (rejectedRecoveryScope && !sameScope(rejectedRecoveryScope, scope()))
217
+ clearRejectedRecovery();
194
218
  if (!terminalsActive)
195
219
  cancelInteractionInput();
196
220
  flush();
@@ -1,14 +1,19 @@
1
1
  /* @jsxImportSource @opentui/solid */
2
- import { createRenderEffect, onCleanup, untrack } from "solid-js";
2
+ import { createRenderEffect, createSignal, Show, onCleanup, untrack, } from "solid-js";
3
3
  /** One Solid owner per terminal pane; terminal output never wakes the root shell. */
4
4
  export function PaneScopedTerminalSurface(props) {
5
5
  let surface;
6
+ const [unavailable, setUnavailable] = createSignal(null);
6
7
  let activationEpoch = 0;
7
8
  let wasActive = false;
8
9
  let hadCanonicalSnapshot = false;
9
10
  const isActive = () => typeof props.active === "function" ? props.active() : props.active !== false;
10
11
  const currentPresentationGeneration = () => `${props.presentationGeneration ?? "stable"}:activation-${activationEpoch}`;
11
12
  const ensureRetainedCanonicalState = (adapter, paneId) => {
13
+ const message = adapter.paneUnavailable?.(paneId) ?? null;
14
+ setUnavailable(message);
15
+ if (message)
16
+ return;
12
17
  const identity = adapter.renderSource.paneCanonicalIdentity?.(paneId) ?? null;
13
18
  if (identity) {
14
19
  hadCanonicalSnapshot = true;
@@ -56,12 +61,20 @@ export function PaneScopedTerminalSurface(props) {
56
61
  surface.sourceEpoch = props.sourceEpoch + props.adapter.paneSourceEpoch();
57
62
  ensureRetainedCanonicalState(props.adapter, props.paneId);
58
63
  });
59
- return (<pane_surface ref={(renderable) => {
64
+ return (<box width={props.width} height={props.height}>
65
+ <pane_surface ref={(renderable) => {
60
66
  surface = renderable;
61
67
  renderable.contentVersion = props.adapter.paneVersion(props.paneId);
62
- renderable.presentationVersion = props.adapter.panePresentationVersion?.(props.paneId) ?? 0;
68
+ renderable.presentationVersion =
69
+ props.adapter.panePresentationVersion?.(props.paneId) ?? 0;
63
70
  renderable.sourceEpoch = props.sourceEpoch + props.adapter.paneSourceEpoch();
64
71
  renderable.presentationGeneration = currentPresentationGeneration();
65
72
  ensureRetainedCanonicalState(props.adapter, props.paneId);
66
- }} width={props.width} height={props.height} mirror={props.adapter.renderSource} paneId={props.paneId} defaultFg={props.defaultFg} defaultBg={props.defaultBg} terminalPalette={props.terminalPalette} searchHl={props.searchHl} searchCur={props.searchCur} scrollOffset={props.scrollOffset} viewportOrigin={props.viewportOrigin} paneFocused={props.paneFocused} contentVersion={props.adapter.paneVersion(props.paneId)} presentationVersion={props.adapter.panePresentationVersion?.(props.paneId) ?? 0} sourceEpoch={props.sourceEpoch + props.adapter.paneSourceEpoch()} rendererEpoch={props.sourceEpoch} hostFocusTransitionOwner={props.hostFocusTransitionOwner} selRange={props.selRange} copyCursor={props.copyCursor} search={props.search}/>);
73
+ }} width={props.width} height={props.height} mirror={props.adapter.renderSource} paneId={props.paneId} defaultFg={props.defaultFg} defaultBg={props.defaultBg} terminalPalette={props.terminalPalette} searchHl={props.searchHl} searchCur={props.searchCur} scrollOffset={props.scrollOffset} viewportOrigin={props.viewportOrigin} paneFocused={props.paneFocused} contentVersion={props.adapter.paneVersion(props.paneId)} presentationVersion={props.adapter.panePresentationVersion?.(props.paneId) ?? 0} sourceEpoch={props.sourceEpoch + props.adapter.paneSourceEpoch()} rendererEpoch={props.sourceEpoch} hostFocusTransitionOwner={props.hostFocusTransitionOwner} selRange={props.selRange} copyCursor={props.copyCursor} search={props.search}/>
74
+ <Show when={unavailable()}>
75
+ {(message) => (<box position="absolute" left={0} top={0} width={props.width} height={props.height} backgroundColor={`#${props.defaultBg.toString(16).padStart(6, "0")}`} padding={1}>
76
+ <text fg={`#${props.defaultFg.toString(16).padStart(6, "0")}`}>{message()}</text>
77
+ </box>)}
78
+ </Show>
79
+ </box>);
67
80
  }
@@ -63,6 +63,9 @@ export class TerminalFastLaneRendererAdapter {
63
63
  this.#causalCellLedger = causalCellLedger;
64
64
  this.#resourceSampler = resourceSampler;
65
65
  }
66
+ paneUnavailable(paneId) {
67
+ return this.#lane.paneUnavailable(paneId);
68
+ }
66
69
  paneVersion(paneId) {
67
70
  return this.#panes.get(paneId)?.version ?? 0;
68
71
  }
@@ -295,6 +298,22 @@ export class TerminalFastLaneRendererAdapter {
295
298
  if (!interest.release) {
296
299
  interest.release = this.#lane.subscribePane(paneId, (publication) => {
297
300
  this.#publish(interest, publication);
301
+ }, () => {
302
+ interest.retainedView?.capture?.abort();
303
+ interest.retainedView = undefined;
304
+ interest.state = null;
305
+ interest.pendingTrace = null;
306
+ interest.pendingHostFrame = null;
307
+ interest.rowProjectionCache?.clear();
308
+ interest.version++;
309
+ for (const listener of [...interest.listeners]) {
310
+ try {
311
+ listener(interest.version, this.#sourceEpoch, interest.presentationVersion, "content");
312
+ }
313
+ catch {
314
+ /* Availability observers cannot interrupt sibling delivery. */
315
+ }
316
+ }
298
317
  });
299
318
  }
300
319
  // The lane owns a generation-scoped canonical replica even while a pane is
@@ -40,11 +40,19 @@ export function terminalInputsForPaste(text) {
40
40
  throw new TypeError("terminal paste must not contain NUL");
41
41
  const framed = `\u001b[200~${text}\u001b[201~`;
42
42
  const inputs = [];
43
- for (let offset = 0; offset < framed.length; offset += SESSION_RUNTIME_MAX_TERMINAL_INPUT_TEXT_CHARS) {
43
+ for (let offset = 0; offset < framed.length;) {
44
+ let end = Math.min(offset + SESSION_RUNTIME_MAX_TERMINAL_INPUT_TEXT_CHARS, framed.length);
45
+ // Messages can be encoded independently. Keep a supplementary code point
46
+ // intact instead of relying on the receiver to rejoin its UTF-16 halves.
47
+ const last = framed.charCodeAt(end - 1);
48
+ const next = framed.charCodeAt(end);
49
+ if (last >= 0xd800 && last <= 0xdbff && next >= 0xdc00 && next <= 0xdfff)
50
+ end -= 1;
44
51
  inputs.push(SessionRuntimeTerminalInputSchemaZ.parse({
45
52
  kind: "text",
46
- data: framed.slice(offset, offset + SESSION_RUNTIME_MAX_TERMINAL_INPUT_TEXT_CHARS),
53
+ data: framed.slice(offset, end),
47
54
  }));
55
+ offset = end;
48
56
  }
49
57
  return Object.freeze(inputs);
50
58
  }
@@ -0,0 +1,156 @@
1
+ function check(condition, message) {
2
+ if (!condition)
3
+ throw Error(message);
4
+ }
5
+ const blank = () => ({ text: "", width: 1, fg: "ffffff", bg: "000000", bold: false });
6
+ export function literalStyledFrame(marker, cols = 40) {
7
+ check(cols === 24 || cols === 40, "literal geometry");
8
+ check(marker.length <= cols, "marker width");
9
+ const cells = Array.from({ length: 8 }, () => Array.from({ length: cols }, blank));
10
+ [...marker].forEach((text, x) => (cells[0][x] = { ...blank(), text }));
11
+ const initial = marker === "BEFORE_SSH";
12
+ const glyphs = initial ? ["X", "界", "", "é", "Z"] : ["Y", "q", "", "é", "R"];
13
+ glyphs.forEach((text, x) => (cells[1][x] = {
14
+ text,
15
+ width: initial ? (x === 1 ? 2 : x === 2 ? 0 : 1) : 1,
16
+ fg: text ? "123456" : "ffffff",
17
+ bg: "214365",
18
+ bold: !!text,
19
+ }));
20
+ for (let x = 0; x < cols; x++)
21
+ cells[3][x] = { ...blank(), text: x < 2 ? String(x + 1) : "", bg: "445566" };
22
+ return { cells, cursor: { x: 4, y: 2, visible: true } };
23
+ }
24
+ export function styledBytes(marker) {
25
+ return `\x1b[0m\x1b[2J\x1b[H${marker}\x1b[2;1H\x1b[1;38;2;18;52;86;48;2;33;67;101m${marker === "BEFORE_SSH" ? "X界éZ" : "Yq éR"}\x1b[0m\x1b[4;1H\x1b[48;2;68;85;102m123456\x1b[4;3H\x1b[K\x1b[0m\x1b[3;5H\x1b]2;${marker}\x07`;
26
+ }
27
+ export function readCompletedFrame(raw) {
28
+ check((raw.cols === 24 || raw.cols === 40) && raw.rows === 8, "geometry");
29
+ const size = raw.cols * raw.rows;
30
+ check(raw.char.length === size &&
31
+ raw.attributes.length === size &&
32
+ raw.fg.length === size * 4 &&
33
+ raw.bg.length === size * 4, "buffer lengths");
34
+ const lines = raw.text.replace(/\n$/u, "").split("\n");
35
+ check(lines.length === 8, "line count");
36
+ const segmenter = new Intl.Segmenter("en", { granularity: "grapheme" });
37
+ const rgb = (a, i) => {
38
+ const c = a.slice(i * 4, i * 4 + 4);
39
+ check(c.length === 4 && c.every((v) => Number.isInteger(v) && v >= 0 && v <= 255) && c[3] === 255, "RGBA");
40
+ return c
41
+ .slice(0, 3)
42
+ .map((v) => v.toString(16).padStart(2, "0"))
43
+ .join("");
44
+ };
45
+ const continuation = (n) => n === 0 || n >>> 30 === 3;
46
+ const cells = lines.map((line, y) => {
47
+ const text = [...segmenter.segment(line)].map((s) => s.segment);
48
+ let pos = 0;
49
+ const row = Array.from({ length: raw.cols }, (_, x) => {
50
+ const i = y * raw.cols + x, cp = raw.char[i];
51
+ const cont = continuation(cp) && x > 0 && !continuation(raw.char[i - 1]) && raw.char[i - 1] !== 32;
52
+ let glyph = "";
53
+ if (cont) {
54
+ if (cp === 0) {
55
+ check(text[pos] === " ", "direct continuation text");
56
+ pos++;
57
+ }
58
+ }
59
+ else {
60
+ glyph = text[pos++] ?? "";
61
+ check(/^(?:[\x20-\x7e]|界|é)$/u.test(glyph), "unsupported grapheme");
62
+ }
63
+ const width = cont ? 0 : x < raw.cols - 1 && continuation(raw.char[i + 1]) ? 2 : 1;
64
+ const kind = cp >>> 30;
65
+ if (cont) {
66
+ const prev = raw.char[i - 1];
67
+ check(prev === 0x754c || (prev >>> 30 === 2 && ((prev >>> 28) & 3) === 1), "continuation owner");
68
+ if (kind === 3)
69
+ check(((cp >>> 26) & 3) === 1 &&
70
+ ((cp >>> 28) & 3) === 0 &&
71
+ (cp & 0x03ffffff) === (prev & 0x03ffffff), "interned continuation");
72
+ }
73
+ else if (kind === 0) {
74
+ check(cp !== 0 && glyph.codePointAt(0) === cp && [...glyph].length === 1, "direct codepoint mismatch");
75
+ check(width === (glyph === "界" ? 2 : 1), "direct width");
76
+ }
77
+ else if (kind === 2) {
78
+ check(glyph === "é" && ((cp >>> 26) & 3) === 0 && ((cp >>> 28) & 3) === 0 && width === 1, "interned grapheme shape");
79
+ }
80
+ else
81
+ check(false, "unsupported raw char flags");
82
+ const visible = glyph !== " " && glyph !== "";
83
+ check((raw.attributes[i] & ~1) === 0, "unsupported attributes");
84
+ return {
85
+ text: visible ? glyph : "",
86
+ width,
87
+ fg: visible ? rgb(raw.fg, i) : "ffffff",
88
+ bg: rgb(raw.bg, i),
89
+ bold: visible && (raw.attributes[i] & 1) !== 0,
90
+ };
91
+ });
92
+ check(pos === text.length, "unconsumed text");
93
+ return row;
94
+ });
95
+ return {
96
+ cells,
97
+ cursor: { x: raw.cursor.x - 1, y: raw.cursor.y - 1, visible: raw.cursor.visible },
98
+ };
99
+ }
100
+ export function compareVisual(actual, expected) {
101
+ check(JSON.stringify(actual.cursor) === JSON.stringify(expected.cursor), "cursor mismatch");
102
+ check(actual.cells.length === expected.cells.length, "rows mismatch");
103
+ actual.cells.forEach((row, y) => {
104
+ check(row.length === expected.cells[y].length, "cols mismatch");
105
+ row.forEach((cell, x) => {
106
+ for (const key of ["text", "width", "fg", "bg", "bold"])
107
+ check(cell[key] === expected.cells[y][x][key], `${y}:${x}.${key} mismatch`);
108
+ });
109
+ });
110
+ }
111
+ export function nativeVisualFrame(frame) {
112
+ check((frame.cols === 24 || frame.cols === 40) && frame.rows === 8, "native geometry");
113
+ const color = (value, defaultValue) => {
114
+ if (value === "default")
115
+ return defaultValue;
116
+ check(/^rgb:[0-9a-f]{6}$/u.test(value), "native color vocabulary");
117
+ return value.slice(4);
118
+ };
119
+ return {
120
+ cells: frame.cells.map((row) => row.map((c) => {
121
+ const visible = c.text !== "" && c.text !== " ";
122
+ check(c.attributes.every((a) => a === "bold"), "native attribute vocabulary");
123
+ return {
124
+ text: visible ? c.text : "",
125
+ width: c.width,
126
+ fg: visible ? color(c.foreground, "ffffff") : "ffffff",
127
+ bg: color(c.background, "000000"),
128
+ bold: visible && c.attributes.includes("bold"),
129
+ };
130
+ })),
131
+ cursor: { x: frame.cursor[0], y: frame.cursor[1], visible: true },
132
+ };
133
+ }
134
+ /** Declared workspace chrome: one tab row plus one title row, then eight native rows. */
135
+ export function cropWorkspaceCompletedFrame(raw) {
136
+ check((raw.cols === 24 || raw.cols === 40) && raw.rows === 10, "workspace geometry");
137
+ const size = raw.cols * raw.rows;
138
+ check(raw.char.length === size &&
139
+ raw.attributes.length === size &&
140
+ raw.fg.length === size * 4 &&
141
+ raw.bg.length === size * 4, "workspace buffer lengths");
142
+ const lines = raw.text.replace(/\n$/u, "").split("\n");
143
+ check(lines.length === 10, "workspace line count");
144
+ check(raw.cursor.y >= 3 && raw.cursor.y <= 10, "workspace cursor outside content");
145
+ const offset = 2 * raw.cols;
146
+ return {
147
+ cols: raw.cols,
148
+ rows: 8,
149
+ char: raw.char.slice(offset),
150
+ attributes: raw.attributes.slice(offset),
151
+ fg: raw.fg.slice(offset * 4),
152
+ bg: raw.bg.slice(offset * 4),
153
+ text: lines.slice(2).join("\n"),
154
+ cursor: { ...raw.cursor, y: raw.cursor.y - 2 },
155
+ };
156
+ }
@@ -45,6 +45,7 @@ import { startOwnedEmbeddedDaemon } from "./embedded-daemon-lifecycle.ts";
45
45
  import { execFileSync } from "node:child_process";
46
46
  import { randomBytes, randomUUID } from "node:crypto";
47
47
  import { createWriteStream } from "node:fs";
48
+ import { createRuntimeTraceWriter } from "./runtime-trace-writer.ts";
48
49
  import { createServer, type Server } from "node:http";
49
50
  import { createRequire } from "node:module";
50
51
  import type { Socket } from "node:net";
@@ -1404,14 +1405,11 @@ async function startEmbeddedDaemonGeneration(
1404
1405
  if (serverOwners?.defaultRetired) throw new Error("Default tmux owner is retired");
1405
1406
  },
1406
1407
  });
1407
- let runtimeTraceStream: ReturnType<typeof createWriteStream> | null = null;
1408
- const closeRuntimeTraceStream = async (): Promise<void> => {
1408
+ let runtimeTraceWriter: ReturnType<typeof createRuntimeTraceWriter> | null = null;
1409
+ const closeRuntimeTraceStream = async (incomplete = false): Promise<void> => {
1409
1410
  externalInteractionObserver.setDiagnostics(null);
1410
1411
  setFleetFactsObserverDiagnostics(null);
1411
- const stream = runtimeTraceStream;
1412
- runtimeTraceStream = null;
1413
- if (!stream || stream.closed || stream.destroyed) return;
1414
- await new Promise<void>((resolve) => stream.end(resolve));
1412
+ await runtimeTraceWriter?.close(incomplete);
1415
1413
  };
1416
1414
  let startedServer: Awaited<ReturnType<typeof startHttpServer>>;
1417
1415
  try {
@@ -1455,16 +1453,12 @@ async function startEmbeddedDaemonGeneration(
1455
1453
  );
1456
1454
  };
1457
1455
  const runtimeTracePath = process.env.TMUX_IDE_SESSION_RUNTIME_TRACE_LOG;
1458
- runtimeTraceStream = runtimeTracePath
1459
- ? createWriteStream(runtimeTracePath, { flags: "a", highWaterMark: 64 * 1_024 })
1456
+ runtimeTraceWriter = runtimeTracePath
1457
+ ? createRuntimeTraceWriter(
1458
+ createWriteStream(runtimeTracePath, { flags: "a", highWaterMark: 64 * 1024 }),
1459
+ { processId: `daemon:${process.pid}`, daemonInstanceId: instanceId },
1460
+ )
1460
1461
  : null;
1461
- let runtimeTraceSaturated = false;
1462
- runtimeTraceStream?.on("error", () => {
1463
- runtimeTraceSaturated = true;
1464
- });
1465
- runtimeTraceStream?.on("drain", () => {
1466
- runtimeTraceSaturated = false;
1467
- });
1468
1462
  const runtimeObservability = runtimeTracePath
1469
1463
  ? createSessionRuntimeObservability({
1470
1464
  // The JSONL stream is the qualifying record. Keep only a small
@@ -1473,28 +1467,22 @@ async function startEmbeddedDaemonGeneration(
1473
1467
  // pauses on the daemon's input/output event loop.
1474
1468
  capacity: 1_024,
1475
1469
  onSpan: (span) => {
1476
- if (!runtimeTraceStream || runtimeTraceSaturated) return;
1477
- runtimeTraceSaturated = !runtimeTraceStream.write(
1478
- `${JSON.stringify({ version: 1, type: "performance.stage", ...span })}\n`,
1479
- );
1470
+ runtimeTraceWriter?.append(() => ({
1471
+ version: 1,
1472
+ type: "performance.stage",
1473
+ ...span,
1474
+ }));
1480
1475
  },
1481
1476
  })
1482
1477
  : undefined;
1483
1478
  if (runtimeTracePath) {
1484
1479
  const publishObserverDiagnostic = (event: object): void => {
1485
- try {
1486
- if (!runtimeTraceStream || runtimeTraceSaturated) return;
1487
- runtimeTraceSaturated = !runtimeTraceStream.write(
1488
- `${JSON.stringify({
1489
- version: 1,
1490
- type: "performance.daemon-observer",
1491
- ...event,
1492
- generation: instanceId,
1493
- })}\n`,
1494
- );
1495
- } catch {
1496
- // Qualification diagnostics never alter daemon observation.
1497
- }
1480
+ runtimeTraceWriter?.append(() => ({
1481
+ version: 1,
1482
+ type: "performance.daemon-observer",
1483
+ ...event,
1484
+ generation: instanceId,
1485
+ }));
1498
1486
  };
1499
1487
  const diagnostics = {
1500
1488
  nowMicros: () => Math.floor(performance.now() * 1_000),
@@ -1975,7 +1963,7 @@ async function startEmbeddedDaemonGeneration(
1975
1963
  appWindowMutation.dispose(),
1976
1964
  workspaceMultiplexer.dispose(),
1977
1965
  disposeInteractionObservation(),
1978
- closeRuntimeTraceStream(),
1966
+ closeRuntimeTraceStream(true),
1979
1967
  ]);
1980
1968
  // The pane-stream coordinator may still hold runtime consumers while it
1981
1969
  // drains. Preserve the normal shutdown order on startup rollback too:
@@ -2044,7 +2032,7 @@ async function startEmbeddedDaemonGeneration(
2044
2032
  appWindowMutationDisposal,
2045
2033
  workspaceMultiplexerDisposal,
2046
2034
  externalInteractionDisposal,
2047
- closeRuntimeTraceStream(),
2035
+ closeRuntimeTraceStream(true),
2048
2036
  Promise.resolve().then(() => closeClients()),
2049
2037
  ...[...sockets].map((socket) => Promise.resolve().then(() => socket.destroy())),
2050
2038
  Promise.race([closePromise, delay(100)]),
@@ -0,0 +1,132 @@
1
+ import type { Writable } from "node:stream";
2
+
3
+ const MAX_RECORD_BYTES = 64 * 1024;
4
+
5
+ /** Opt-in diagnostics: one stream buffer, no additional queue or producer backpressure. */
6
+ export function createRuntimeTraceWriter(
7
+ stream: Writable,
8
+ identity: { processId: string; daemonInstanceId: string },
9
+ closeTimeoutMs = 1000,
10
+ ) {
11
+ const counts = {
12
+ offeredRecords: 0,
13
+ acceptedRecords: 0,
14
+ droppedRecords: 0,
15
+ saturatedDrops: 0,
16
+ failedDrops: 0,
17
+ oversizedRecords: 0,
18
+ constructionFailures: 0,
19
+ rejectedWrites: 0,
20
+ writeFailures: 0,
21
+ };
22
+ let saturated = false;
23
+ let failed = false;
24
+ let closed = false;
25
+ let closing: Promise<void> | undefined;
26
+ let closeFailure: ((error: Error) => void) | undefined;
27
+ const snapshot = () => ({ ...counts, failed, saturated, writableLength: stream.writableLength });
28
+ stream.on("drain", () => {
29
+ if (!failed) saturated = false;
30
+ });
31
+ // Retain the error listener through destroy: a late stream error must not escape cleanup.
32
+ stream.on("error", (error: Error) => {
33
+ counts.writeFailures++;
34
+ failed = true;
35
+ closeFailure?.(error);
36
+ });
37
+ return {
38
+ snapshot,
39
+ append(build: () => unknown): void {
40
+ if (closed) return;
41
+ counts.offeredRecords++;
42
+ if (failed || stream.destroyed) {
43
+ counts.failedDrops++;
44
+ counts.droppedRecords++;
45
+ return;
46
+ }
47
+ if (saturated) {
48
+ counts.saturatedDrops++;
49
+ counts.droppedRecords++;
50
+ return;
51
+ }
52
+ let line: string;
53
+ try {
54
+ const json = JSON.stringify(build());
55
+ if (json === undefined) throw new Error("Trace record is not JSON");
56
+ line = `${json}\n`;
57
+ if (Buffer.byteLength(line) > MAX_RECORD_BYTES) {
58
+ counts.oversizedRecords++;
59
+ counts.droppedRecords++;
60
+ return;
61
+ }
62
+ } catch {
63
+ counts.constructionFailures++;
64
+ counts.droppedRecords++;
65
+ failed = true;
66
+ return;
67
+ }
68
+ try {
69
+ saturated = !stream.write(line);
70
+ counts.acceptedRecords++;
71
+ } catch {
72
+ counts.rejectedWrites++;
73
+ counts.droppedRecords++;
74
+ failed = true;
75
+ }
76
+ },
77
+ close(incomplete = false): Promise<void> {
78
+ if (closing) return closing;
79
+ closed = true;
80
+ if (incomplete) failed = true;
81
+ closing = new Promise<void>((resolve, reject) => {
82
+ let settled = false;
83
+ const finish = (error?: Error) => {
84
+ if (settled) return;
85
+ settled = true;
86
+ clearTimeout(timer);
87
+ stream.off("drain", end);
88
+ stream.off("close", prematureClose);
89
+ closeFailure = undefined;
90
+ if (error) {
91
+ failed = true;
92
+ stream.destroy();
93
+ reject(error);
94
+ } else resolve();
95
+ };
96
+ const prematureClose = () =>
97
+ finish(new Error("Runtime trace closed before final summary completion"));
98
+ const end = () => {
99
+ try {
100
+ if (stream.destroyed || !stream.writable)
101
+ throw new Error("Runtime trace stream unavailable");
102
+ const summary = {
103
+ version: 1,
104
+ type: "performance.daemon-trace.summary",
105
+ ...identity,
106
+ ...snapshot(),
107
+ maxRecordBytes: MAX_RECORD_BYTES,
108
+ acceptedRecordsExcludeSummary: true,
109
+ shutdownKind: incomplete ? "startup-rollback" : "normal",
110
+ ringTailOverwrites: "not-file-loss",
111
+ };
112
+ stream.end(`${JSON.stringify(summary)}\n`, () =>
113
+ finish(failed ? new Error("Runtime trace recorded failures") : undefined),
114
+ );
115
+ } catch (error) {
116
+ finish(error instanceof Error ? error : new Error("Runtime trace end failed"));
117
+ }
118
+ };
119
+ const timer = setTimeout(
120
+ () => finish(new Error("Runtime trace close timed out")),
121
+ closeTimeoutMs,
122
+ );
123
+ closeFailure = finish;
124
+ stream.on("close", prematureClose);
125
+ if (stream.destroyed) finish(new Error("Runtime trace stream destroyed"));
126
+ else if (saturated && stream.writableNeedDrain) stream.once("drain", end);
127
+ else end();
128
+ });
129
+ return closing;
130
+ },
131
+ };
132
+ }
@@ -1,7 +1,12 @@
1
+ import { fileURLToPath } from "node:url";
1
2
  import { accessSync, constants, realpathSync, statSync } from "node:fs";
2
- import { delimiter, isAbsolute, join } from "node:path";
3
+ import { delimiter, isAbsolute, join, resolve } from "node:path";
3
4
  import { resolveRuntimeNamespace } from "./runtime-namespace.ts";
4
- import { resolveBundledTmux, bundledTmuxResourceEnvironment } from "./bundled-tmux.ts";
5
+ import {
6
+ resolveBundledTmux,
7
+ bundledTmuxResourceEnvironment,
8
+ withBundledTmuxResources,
9
+ } from "./bundled-tmux.ts";
5
10
 
6
11
  export function resolveTmuxExecutable(): string {
7
12
  if (resolveRuntimeNamespace().development) return resolveBundledTmux()!;
@@ -57,3 +62,43 @@ export function tmuxClientEnvironment(
57
62
  }
58
63
  return { ...environment, ...bundledTmuxResourceEnvironment(executable) };
59
64
  }
65
+
66
+ /** Ordinary CLI commands retain PATH selection; bundled tmux fills a missing system client. */
67
+ export function resolveOrdinaryTmuxClient(
68
+ environment: NodeJS.ProcessEnv,
69
+ cwd?: string | URL,
70
+ ): {
71
+ executable: string;
72
+ environment: NodeJS.ProcessEnv;
73
+ } {
74
+ const configured = environment.TMUX_IDE_TMUX_BIN;
75
+ const candidates = configured
76
+ ? [configured]
77
+ : (environment.PATH ?? "/usr/bin:/bin")
78
+ .split(delimiter)
79
+ .map((entry) =>
80
+ resolve(cwd instanceof URL ? fileURLToPath(cwd) : (cwd ?? process.cwd()), entry, "tmux"),
81
+ );
82
+ let executable: string | undefined;
83
+ for (const candidate of candidates) {
84
+ try {
85
+ if (!isAbsolute(candidate)) continue;
86
+ accessSync(candidate, constants.X_OK);
87
+ const canonical = realpathSync(candidate);
88
+ if (statSync(canonical).isFile()) {
89
+ executable = canonical;
90
+ break;
91
+ }
92
+ } catch {
93
+ /* Try the next ordinary PATH candidate. */
94
+ }
95
+ }
96
+ if (!executable && !configured)
97
+ executable =
98
+ resolveBundledTmux([
99
+ ...(environment.TMUX_IDE_CLI ? [environment.TMUX_IDE_CLI] : []),
100
+ fileURLToPath(import.meta.url),
101
+ ]) ?? undefined;
102
+ if (!executable) throw new Error("tmux_executable_unavailable");
103
+ return { executable, environment: withBundledTmuxResources(executable, environment) };
104
+ }