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.
- package/LICENSE +1 -1
- package/README.md +120 -139
- package/bin/cli.js +4847 -3585
- package/package.json +16 -9
- package/packages/contracts/src/session-runtime.ts +2 -0
- package/packages/contracts/src/terminal-delivery.ts +4 -0
- package/packages/core/src/terminal-compact-capability.ts +31 -1
- package/packages/core/src/terminal-delivery.ts +234 -32
- package/packages/core/src/terminal-replica-hash-cache.ts +162 -21
- package/packages/core/src/terminal-replica-owned-row.ts +165 -10
- package/packages/core/src/terminal-replica.ts +43 -21
- package/packages/daemon/dist/lib/daemon-embed.js +19 -34
- package/packages/daemon/dist/lib/runtime-trace-writer.js +133 -0
- package/packages/daemon/dist/lib/tmux-client-execution.js +37 -2
- package/packages/daemon/dist/lib/tmux-server-owner.js +20 -4
- 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/Assets.car +0 -0
- package/packages/daemon/dist/native/TmuxIdeNotifier.app/Contents/_CodeSignature/CodeResources +2 -2
- package/packages/daemon/dist/native/tmux/darwin-arm64/manifest.json +5 -1
- package/packages/daemon/dist/native/tmux/darwin-arm64/tmux +0 -0
- package/packages/daemon/dist/native/tmux/darwin-x64/manifest.json +5 -1
- package/packages/daemon/dist/native/tmux/darwin-x64/tmux +0 -0
- package/packages/daemon/dist/native/tmux/linux-arm64/manifest.json +5 -1
- package/packages/daemon/dist/native/tmux/linux-arm64/tmux +0 -0
- package/packages/daemon/dist/native/tmux/linux-x64/licenses/libevent_core-2.1.so.7.txt +0 -0
- package/packages/daemon/dist/native/tmux/linux-x64/manifest.json +5 -1
- package/packages/daemon/dist/native/tmux/linux-x64/tmux +0 -0
- package/packages/daemon/dist/terminal/attachments/native-runtime.js +6 -6
- package/packages/daemon/dist/terminal/mirror/control-channel.js +379 -75
- package/packages/daemon/dist/terminal/mirror/mirror-service.js +43 -3
- package/packages/daemon/dist/terminal/mirror/native-grid-projection.js +20 -11
- package/packages/daemon/dist/terminal/mirror/session-channel.js +639 -811
- package/packages/daemon/dist/terminal/mirror/stock-pane-snapshot.js +177 -0
- package/packages/daemon/dist/terminal/session-runtime/causal-cell-ledger.js +1 -1
- package/packages/daemon/dist/terminal/session-runtime/registry.js +145 -15
- package/packages/daemon/dist/terminal/session-runtime/stock-capture-fidelity.js +12 -0
- package/packages/daemon/dist/terminal/session-runtime/terminal-delivery-hub.js +128 -10
- package/packages/daemon/dist/terminal/session-runtime/terminal-replica-interpreter.js +21 -4
- package/packages/daemon/dist/terminal/session-runtime/terminal-replica-owner.js +19 -2
- package/packages/daemon/dist/terminal/session-runtime/xterm-terminal-interpreter-backend.js +17 -19
- package/packages/daemon/dist/tui/integrations/claude-agent-teams.js +264 -0
- package/packages/daemon/dist/tui/mirror/open-tui-workspace-runtime-port.js +64 -4
- package/packages/daemon/dist/tui/mirror/pane-surface.jsx +10 -1
- package/packages/daemon/dist/tui/mirror/reference-performance-trace.js +117 -71
- package/packages/daemon/dist/tui/mirror/runtime/application-terminal-input-ingress.js +27 -3
- package/packages/daemon/dist/tui/mirror/runtime/pane-scoped-terminal-surface.jsx +17 -4
- package/packages/daemon/dist/tui/mirror/runtime/terminal-fast-lane-renderer-adapter.js +19 -0
- package/packages/daemon/dist/tui/mirror/runtime/terminal-input-adapter.js +10 -2
- package/packages/daemon/dist/tui/mirror/testing/styled-frame-oracle.js +156 -0
- package/packages/daemon/src/lib/daemon-embed.ts +22 -34
- package/packages/daemon/src/lib/runtime-trace-writer.ts +132 -0
- package/packages/daemon/src/lib/tmux-client-execution.ts +47 -2
- package/packages/daemon/src/lib/tmux-server-owner.ts +19 -3
- package/packages/daemon/src/terminal/attachments/native-runtime.ts +6 -0
- package/packages/daemon/src/terminal/mirror/control-channel.ts +428 -84
- package/packages/daemon/src/terminal/mirror/events.ts +7 -7
- package/packages/daemon/src/terminal/mirror/mirror-service.ts +49 -3
- package/packages/daemon/src/terminal/mirror/native-grid-projection.ts +21 -11
- package/packages/daemon/src/terminal/mirror/pane-feed.ts +9 -4
- package/packages/daemon/src/terminal/mirror/session-channel.ts +705 -859
- package/packages/daemon/src/terminal/mirror/stock-pane-snapshot.ts +228 -0
- package/packages/daemon/src/terminal/session-runtime/causal-cell-ledger.ts +1 -1
- package/packages/daemon/src/terminal/session-runtime/registry.ts +203 -32
- package/packages/daemon/src/terminal/session-runtime/runtime-observability.ts +7 -0
- package/packages/daemon/src/terminal/session-runtime/stock-capture-fidelity.ts +13 -0
- package/packages/daemon/src/terminal/session-runtime/terminal-delivery-hub.ts +142 -8
- package/packages/daemon/src/terminal/session-runtime/terminal-replica-interpreter.ts +26 -3
- package/packages/daemon/src/terminal/session-runtime/terminal-replica-owner.ts +25 -1
- package/packages/daemon/src/terminal/session-runtime/xterm-terminal-interpreter-backend.ts +26 -20
- package/packages/daemon/src/tui/integrations/claude-agent-teams.ts +334 -0
- package/packages/daemon/src/tui/mirror/open-tui-workspace-runtime-port.ts +63 -4
- package/packages/daemon/src/tui/mirror/pane-surface.tsx +9 -1
- package/packages/daemon/src/tui/mirror/reference-performance-trace.ts +141 -90
- package/packages/daemon/src/tui/mirror/runtime/application-terminal-input-ingress.ts +27 -4
- package/packages/daemon/src/tui/mirror/runtime/pane-scoped-terminal-surface.tsx +61 -31
- package/packages/daemon/src/tui/mirror/runtime/terminal-fast-lane-renderer-adapter.ts +31 -3
- package/packages/daemon/src/tui/mirror/runtime/terminal-input-adapter.ts +9 -6
- package/packages/daemon/src/tui/mirror/testing/styled-frame-oracle.ts +209 -0
- package/packages/daemon-client/src/terminal-fast-lane.ts +53 -4
- package/packages/daemon-client/src/workspace-client-types.ts +1 -0
- package/packages/daemon-client/src/workspace-client.ts +78 -5
- package/packages/tmux-bridge/src/index.ts +1 -0
- package/packages/tmux-bridge/src/runner.ts +25 -1
- 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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 (<
|
|
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 =
|
|
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;
|
|
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,
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
1459
|
-
?
|
|
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
|
-
|
|
1477
|
-
|
|
1478
|
-
|
|
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
|
-
|
|
1486
|
-
|
|
1487
|
-
|
|
1488
|
-
|
|
1489
|
-
|
|
1490
|
-
|
|
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 {
|
|
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
|
+
}
|