tmux-ide 2.9.0-beta.20 → 2.9.0-beta.21
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bin/cli.js +100 -8
- package/package.json +3 -2
- package/packages/daemon/dist/command-center/diagnostics.js +65 -0
- package/packages/daemon/dist/command-center/log-stream.js +9 -0
- package/packages/daemon/dist/command-center/server.js +7 -0
- package/packages/daemon/dist/lib/app-config.js +4 -2
- package/packages/daemon/dist/lib/soak-diagnostics.js +124 -0
- package/packages/daemon/dist/lib/soak-verdict.js +185 -35
- package/packages/daemon/dist/lib/terminal-host-color.js +33 -0
- package/packages/daemon/dist/tui/mirror/automatic-contrast.js +161 -0
- package/packages/daemon/dist/tui/mirror/open-tui-workspace-runtime-port.js +9 -3
- package/packages/daemon/dist/tui/mirror/pane-surface.jsx +7 -5
- package/packages/daemon/dist/tui/mirror/resize-transaction.js +48 -20
- package/packages/daemon/dist/tui/mirror/runtime/application-appearance-owner.js +40 -6
- package/packages/daemon/dist/tui/mirror/runtime/application-machine-sidebar.jsx +63 -42
- package/packages/daemon/dist/tui/mirror/runtime/application-terminal-interaction-controller.js +138 -67
- package/packages/daemon/dist/tui/mirror/runtime/application-terminal-palette-owner.js +44 -28
- package/packages/daemon/dist/tui/mirror/runtime/application-terminal-workspace.jsx +49 -11
- package/packages/daemon/dist/tui/mirror/runtime/semantic-shell-viewport-resize.js +140 -2
- package/packages/daemon/dist/tui/mirror/runtime/workspace-terminal-fast-lane.js +3 -1
- package/packages/daemon/dist/tui/mirror/semantic-pane-render-source.js +2 -1
- package/packages/daemon/dist/tui/mirror/theme.js +4 -31
- package/packages/daemon/dist/tui/mirror/workspace/terminal-pane-header.jsx +15 -8
- package/packages/daemon/src/command-center/diagnostics.ts +75 -0
- package/packages/daemon/src/command-center/log-stream.ts +8 -0
- package/packages/daemon/src/command-center/server.ts +8 -0
- package/packages/daemon/src/lib/app-config.ts +11 -3
- package/packages/daemon/src/lib/soak-diagnostics.ts +183 -0
- package/packages/daemon/src/lib/soak-verdict.ts +314 -23
- package/packages/daemon/src/lib/terminal-host-color.ts +43 -0
- package/packages/daemon/src/tui/mirror/automatic-contrast.ts +180 -0
- package/packages/daemon/src/tui/mirror/open-tui-workspace-runtime-port.ts +26 -5
- package/packages/daemon/src/tui/mirror/pane-surface.tsx +9 -8
- package/packages/daemon/src/tui/mirror/resize-transaction.ts +46 -22
- package/packages/daemon/src/tui/mirror/runtime/application-appearance-owner.ts +45 -6
- package/packages/daemon/src/tui/mirror/runtime/application-machine-sidebar.tsx +99 -75
- package/packages/daemon/src/tui/mirror/runtime/application-root-v2.tsx +1 -0
- package/packages/daemon/src/tui/mirror/runtime/application-shell-overlays.tsx +9 -2
- package/packages/daemon/src/tui/mirror/runtime/application-shell-view.tsx +2 -0
- package/packages/daemon/src/tui/mirror/runtime/application-terminal-interaction-controller.ts +148 -69
- package/packages/daemon/src/tui/mirror/runtime/application-terminal-palette-owner.ts +55 -28
- package/packages/daemon/src/tui/mirror/runtime/application-terminal-workspace.tsx +59 -17
- package/packages/daemon/src/tui/mirror/runtime/semantic-shell-viewport-resize.ts +151 -3
- package/packages/daemon/src/tui/mirror/runtime/workspace-terminal-fast-lane.ts +3 -1
- package/packages/daemon/src/tui/mirror/semantic-pane-render-source.ts +2 -1
- package/packages/daemon/src/tui/mirror/theme.ts +4 -39
- package/packages/daemon/src/tui/mirror/workspace/terminal-pane-header.tsx +21 -8
- package/packages/daemon-client/src/terminal-fast-lane.test.ts +18 -0
- package/packages/daemon-client/src/terminal-fast-lane.ts +7 -2
|
@@ -7,23 +7,113 @@ export function createSemanticShellViewportResizeOwner(getLayout = () => ({ curr
|
|
|
7
7
|
let disposed = false;
|
|
8
8
|
let applied = null;
|
|
9
9
|
let pending = null;
|
|
10
|
+
let desired = null;
|
|
11
|
+
let scopedFlight = false;
|
|
12
|
+
let scopedEpoch = 0;
|
|
13
|
+
let authorityClient = null;
|
|
14
|
+
let stopAuthority = null;
|
|
15
|
+
let geometryOwner = null;
|
|
16
|
+
let authoritySuspended = false;
|
|
17
|
+
let retry = null;
|
|
18
|
+
const scopedApplied = new Map();
|
|
19
|
+
const targetKey = (target) => target.semanticWindowId ?? "";
|
|
20
|
+
const targetSize = (target) => `${target.cols}x${target.rows}`;
|
|
21
|
+
const drain = async () => {
|
|
22
|
+
if (scopedFlight || disposed || authoritySuspended)
|
|
23
|
+
return;
|
|
24
|
+
scopedFlight = true;
|
|
25
|
+
try {
|
|
26
|
+
while (desired && !disposed && !authoritySuspended) {
|
|
27
|
+
const owner = desired;
|
|
28
|
+
const epoch = scopedEpoch;
|
|
29
|
+
const target = owner.targets.find((entry) => scopedApplied.get(targetKey(entry)) !== targetSize(entry));
|
|
30
|
+
if (!target)
|
|
31
|
+
break;
|
|
32
|
+
// A global fit clears this control client's window overrides. Forget
|
|
33
|
+
// them before dispatch, including a reversal while the receipt waits.
|
|
34
|
+
if (target.semanticWindowId === undefined)
|
|
35
|
+
scopedApplied.clear();
|
|
36
|
+
else
|
|
37
|
+
scopedApplied.delete(targetKey(target));
|
|
38
|
+
const outcome = await owner.identity.lane.lane
|
|
39
|
+
.resize(target)
|
|
40
|
+
.catch(() => ({ status: "failed" }));
|
|
41
|
+
if (disposed || !desired)
|
|
42
|
+
break;
|
|
43
|
+
if (epoch !== scopedEpoch)
|
|
44
|
+
continue;
|
|
45
|
+
const stillDesired = desired.targets.some((entry) => targetKey(entry) === targetKey(target) && targetSize(entry) === targetSize(target));
|
|
46
|
+
if (outcome.status !== "applied") {
|
|
47
|
+
if (!stillDesired)
|
|
48
|
+
continue;
|
|
49
|
+
break;
|
|
50
|
+
}
|
|
51
|
+
// Preserve the actual accepted size even after a reversal so the next
|
|
52
|
+
// pass compares current truth with the latest complete desired set.
|
|
53
|
+
if (desired.targets.some((entry) => targetKey(entry) === targetKey(target)))
|
|
54
|
+
scopedApplied.set(targetKey(target), targetSize(target));
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
finally {
|
|
58
|
+
scopedFlight = false;
|
|
59
|
+
}
|
|
60
|
+
};
|
|
61
|
+
const sameAuthority = (left, right) => left.lane === right.lane &&
|
|
62
|
+
left.daemonGeneration === right.daemonGeneration &&
|
|
63
|
+
left.rendererEpoch === right.rendererEpoch;
|
|
10
64
|
const same = (left, right) => left?.lane === right.lane &&
|
|
11
65
|
left.daemonGeneration === right.daemonGeneration &&
|
|
12
66
|
left.rendererEpoch === right.rendererEpoch &&
|
|
13
67
|
left.cols === right.cols &&
|
|
14
68
|
left.rows === right.rows;
|
|
15
69
|
return Object.freeze({
|
|
16
|
-
adopt(dimensions, semantic, generation, paneBorderStatus = getLayout().current?.paneBorderStatus ?? "off") {
|
|
70
|
+
adopt: function adopt(dimensions, semantic, generation, paneBorderStatus = getLayout().current?.paneBorderStatus ?? "off") {
|
|
17
71
|
if (disposed)
|
|
18
72
|
return;
|
|
73
|
+
retry = () => adopt(dimensions, semantic, generation, paneBorderStatus);
|
|
19
74
|
if (semantic === null ||
|
|
20
75
|
generation?.status !== "live" ||
|
|
21
76
|
generation.daemonGeneration === null ||
|
|
22
77
|
generation.fastLane === null) {
|
|
78
|
+
stopAuthority?.();
|
|
79
|
+
stopAuthority = null;
|
|
80
|
+
authorityClient = null;
|
|
81
|
+
authoritySuspended = false;
|
|
23
82
|
applied = null;
|
|
24
83
|
pending = null;
|
|
84
|
+
desired = null;
|
|
85
|
+
scopedEpoch += 1;
|
|
86
|
+
scopedApplied.clear();
|
|
25
87
|
return;
|
|
26
88
|
}
|
|
89
|
+
if (authorityClient !== generation.authorityClient) {
|
|
90
|
+
stopAuthority?.();
|
|
91
|
+
authorityClient = generation.authorityClient;
|
|
92
|
+
const observed = authorityClient;
|
|
93
|
+
geometryOwner = observed?.getAuthoritySnapshot()?.owners.geometry ?? null;
|
|
94
|
+
authoritySuspended =
|
|
95
|
+
geometryOwner !== null && geometryOwner !== observed?.authorityIdentity.clientId;
|
|
96
|
+
stopAuthority =
|
|
97
|
+
observed?.onAuthority((snapshot) => {
|
|
98
|
+
if (disposed ||
|
|
99
|
+
authorityClient !== observed ||
|
|
100
|
+
snapshot.generation !== generation.daemonGeneration)
|
|
101
|
+
return;
|
|
102
|
+
const next = snapshot.owners.geometry;
|
|
103
|
+
if (next === geometryOwner)
|
|
104
|
+
return;
|
|
105
|
+
geometryOwner = next;
|
|
106
|
+
authoritySuspended = next !== null && next !== observed.authorityIdentity.clientId;
|
|
107
|
+
// The daemon clears scoped fits on handoff even when the host/runtime
|
|
108
|
+
// identity survives. Reacquisition must rebuild them from current truth.
|
|
109
|
+
applied = null;
|
|
110
|
+
pending = null;
|
|
111
|
+
scopedApplied.clear();
|
|
112
|
+
scopedEpoch += 1;
|
|
113
|
+
if (!authoritySuspended)
|
|
114
|
+
retry?.();
|
|
115
|
+
}) ?? null;
|
|
116
|
+
}
|
|
27
117
|
const viewport = applicationShellViewport(dimensions, true);
|
|
28
118
|
const lane = generation.fastLane;
|
|
29
119
|
const target = Object.freeze({
|
|
@@ -33,8 +123,50 @@ export function createSemanticShellViewportResizeOwner(getLayout = () => ({ curr
|
|
|
33
123
|
cols: viewport.width,
|
|
34
124
|
rows: Math.max(2, viewport.height - (paneBorderStatus === "off" ? 1 : 0)),
|
|
35
125
|
});
|
|
36
|
-
|
|
126
|
+
const windows = getLayout().windows;
|
|
127
|
+
if (windows &&
|
|
128
|
+
windows.every((window) => typeof window.semanticWindowId === "string" && window.semanticWindowId.length > 0)) {
|
|
129
|
+
applied = null;
|
|
130
|
+
pending = null;
|
|
131
|
+
if (!desired || !sameAuthority(desired.identity, target)) {
|
|
132
|
+
scopedEpoch += 1;
|
|
133
|
+
scopedApplied.clear();
|
|
134
|
+
}
|
|
135
|
+
const retainedWindows = new Set(["", ...windows.map((window) => window.semanticWindowId)]);
|
|
136
|
+
for (const key of scopedApplied.keys())
|
|
137
|
+
if (!retainedWindows.has(key))
|
|
138
|
+
scopedApplied.delete(key);
|
|
139
|
+
desired = {
|
|
140
|
+
identity: target,
|
|
141
|
+
targets: [
|
|
142
|
+
// Unscoped/new windows inherit a stable size, independent of the
|
|
143
|
+
// selected window's border policy. Known windows then fit their own
|
|
144
|
+
// header reservation without resizing their neighbours on switch.
|
|
145
|
+
{ cols: viewport.width, rows: Math.max(2, viewport.height - 1) },
|
|
146
|
+
...windows.map((window) => ({
|
|
147
|
+
semanticWindowId: window.semanticWindowId,
|
|
148
|
+
cols: viewport.width,
|
|
149
|
+
rows: Math.max(2, viewport.height - (window.paneBorderStatus === "off" ? 1 : 0)),
|
|
150
|
+
})),
|
|
151
|
+
],
|
|
152
|
+
};
|
|
153
|
+
void drain();
|
|
37
154
|
return;
|
|
155
|
+
}
|
|
156
|
+
if (desired) {
|
|
157
|
+
desired = null;
|
|
158
|
+
scopedEpoch += 1;
|
|
159
|
+
scopedApplied.clear();
|
|
160
|
+
applied = null;
|
|
161
|
+
pending = null;
|
|
162
|
+
}
|
|
163
|
+
if (authoritySuspended || same(applied, target) || same(pending, target))
|
|
164
|
+
return;
|
|
165
|
+
// The accepted size stops being a dedupe target as soon as another
|
|
166
|
+
// resize can mutate tmux. Otherwise A -> B -> A drops the final A while
|
|
167
|
+
// B is still awaiting its receipt. The fast lane owns first/latest
|
|
168
|
+
// coalescing; it must see the reversal to retain the actual final size.
|
|
169
|
+
applied = null;
|
|
38
170
|
pending = target;
|
|
39
171
|
void lane.lane.resize({ cols: target.cols, rows: target.rows }).then((outcome) => {
|
|
40
172
|
if (disposed || pending !== target)
|
|
@@ -49,6 +181,12 @@ export function createSemanticShellViewportResizeOwner(getLayout = () => ({ curr
|
|
|
49
181
|
},
|
|
50
182
|
dispose() {
|
|
51
183
|
disposed = true;
|
|
184
|
+
retry = null;
|
|
185
|
+
stopAuthority?.();
|
|
186
|
+
stopAuthority = null;
|
|
187
|
+
authorityClient = null;
|
|
188
|
+
desired = null;
|
|
189
|
+
scopedApplied.clear();
|
|
52
190
|
applied = null;
|
|
53
191
|
pending = null;
|
|
54
192
|
},
|
|
@@ -56,7 +56,9 @@ export function createOpenTuiWorkspaceTerminalFastLane(client, hostClientId, cau
|
|
|
56
56
|
if (client.getSnapshot().target?.daemon.instanceId !== address.generation) {
|
|
57
57
|
return Promise.resolve("authority-lost");
|
|
58
58
|
}
|
|
59
|
-
return
|
|
59
|
+
return viewport.semanticWindowId === undefined
|
|
60
|
+
? client.fitViewport(viewport.cols, viewport.rows)
|
|
61
|
+
: client.fitViewport(viewport.cols, viewport.rows, viewport.semanticWindowId);
|
|
60
62
|
},
|
|
61
63
|
},
|
|
62
64
|
...(performanceSink?.terminalTraceStage
|
|
@@ -566,7 +566,8 @@ export function blitSemanticRow(row, buffers, y, width, defaultFg, defaultBg, gr
|
|
|
566
566
|
let background = resolveColor(cell.background, false, palette);
|
|
567
567
|
let attributes = cell.attributes;
|
|
568
568
|
if ((attributes & 32) !== 0) {
|
|
569
|
-
|
|
569
|
+
// Resolve each side before reversing: a default retains its original role.
|
|
570
|
+
[foreground, background] = [background ?? defaultBg, foreground ?? defaultFg];
|
|
570
571
|
attributes &= ~32;
|
|
571
572
|
}
|
|
572
573
|
// Native tmux clears a partial wide glyph, retaining its background.
|
|
@@ -12,6 +12,7 @@
|
|
|
12
12
|
import { RGBA } from "@opentui/core";
|
|
13
13
|
import { BUILTIN_VISUAL_THEMES, findVisualThemePreset, contrastRatio, deriveAttentionBlend, deriveFocusedHeader, mixSrgbColors, readableForeground, resolveVisualTheme, } from "@tmux-ide/contracts";
|
|
14
14
|
import { LEGACY_THEME_OVERRIDE_PROVENANCE, } from "../../lib/legacy-theme-compat.js";
|
|
15
|
+
import { parseTerminalHostColor, terminalHostMode } from "../../lib/terminal-host-color.js";
|
|
15
16
|
import { XTERM_PALETTE } from "./ansi-palette.js";
|
|
16
17
|
/** Stable categorical host accents; palette authority stays with the theme. */
|
|
17
18
|
export const FLEET_HOST_ACCENT_COLORS = Object.freeze([
|
|
@@ -162,30 +163,6 @@ function rendererNeutral(red, green, blue) {
|
|
|
162
163
|
function rendererNeutralFromPacked(color) {
|
|
163
164
|
return rendererNeutral((color >> 16) & 0xff, (color >> 8) & 0xff, color & 0xff);
|
|
164
165
|
}
|
|
165
|
-
function perceivedLuminance(color) {
|
|
166
|
-
return 0.299 * color.red + 0.587 * color.green + 0.114 * color.blue;
|
|
167
|
-
}
|
|
168
|
-
function parseTerminalHostColor(value) {
|
|
169
|
-
if (!value)
|
|
170
|
-
return null;
|
|
171
|
-
const normalized = value.trim().toLowerCase();
|
|
172
|
-
const hex = /^#([\da-f]{3}|[\da-f]{6})$/u.exec(normalized)?.[1];
|
|
173
|
-
if (hex) {
|
|
174
|
-
const expanded = hex.length === 3 ? `${hex[0]}${hex[0]}${hex[1]}${hex[1]}${hex[2]}${hex[2]}` : hex;
|
|
175
|
-
return rendererNeutral(Number.parseInt(expanded.slice(0, 2), 16), Number.parseInt(expanded.slice(2, 4), 16), Number.parseInt(expanded.slice(4, 6), 16));
|
|
176
|
-
}
|
|
177
|
-
// OSC palette replies may use X11's rgb:RR/GG/BB form with one to four
|
|
178
|
-
// hexadecimal digits per channel. Scale each channel to a byte rather than
|
|
179
|
-
// truncating high-fidelity replies.
|
|
180
|
-
const x11 = /^rgb:([\da-f]{1,4})\/([\da-f]{1,4})\/([\da-f]{1,4})$/u.exec(normalized);
|
|
181
|
-
if (!x11)
|
|
182
|
-
return null;
|
|
183
|
-
const channel = (part) => {
|
|
184
|
-
const maximum = 16 ** part.length - 1;
|
|
185
|
-
return Math.round((Number.parseInt(part, 16) / maximum) * 255);
|
|
186
|
-
};
|
|
187
|
-
return rendererNeutral(channel(x11[1]), channel(x11[2]), channel(x11[3]));
|
|
188
|
-
}
|
|
189
166
|
function mostReadable(background, candidates, minimum = 4.5) {
|
|
190
167
|
const unique = candidates.filter((candidate, index) => candidates.findIndex((other) => other.red === candidate.red &&
|
|
191
168
|
other.green === candidate.green &&
|
|
@@ -214,19 +191,15 @@ function mutedHostText(panel, foreground) {
|
|
|
214
191
|
*/
|
|
215
192
|
export function deriveSystemVisualHostDefaults(input) {
|
|
216
193
|
const reportedPalette = Array.from({ length: 16 }, (_, index) => parseTerminalHostColor(input.palette[index]));
|
|
217
|
-
const measuredBackground = parseTerminalHostColor(input.defaultBackground)
|
|
218
|
-
const measuredForeground = parseTerminalHostColor(input.defaultForeground)
|
|
194
|
+
const measuredBackground = parseTerminalHostColor(input.defaultBackground);
|
|
195
|
+
const measuredForeground = parseTerminalHostColor(input.defaultForeground);
|
|
219
196
|
if (!measuredBackground && !measuredForeground && reportedPalette.every((value) => !value))
|
|
220
197
|
return null;
|
|
221
198
|
const fallbackMode = input.detectedMode ?? "dark";
|
|
222
199
|
const background = measuredBackground ?? BUILTIN_VISUAL_THEMES[fallbackMode].surfaces.canvas;
|
|
223
200
|
const black = rendererNeutral(0, 0, 0);
|
|
224
201
|
const white = rendererNeutral(255, 255, 255);
|
|
225
|
-
const appearance =
|
|
226
|
-
? fallbackMode
|
|
227
|
-
: perceivedLuminance(background) > 127.5
|
|
228
|
-
? "light"
|
|
229
|
-
: "dark";
|
|
202
|
+
const appearance = terminalHostMode(input.defaultBackground) ?? fallbackMode;
|
|
230
203
|
const builtIn = BUILTIN_VISUAL_THEMES[appearance];
|
|
231
204
|
const structuralTarget = appearance === "dark" ? white : black;
|
|
232
205
|
const panel = mixSrgbColors(background, structuralTarget, appearance === "dark" ? 0.055 : 0.04);
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
/* @jsxImportSource @opentui/solid */
|
|
2
2
|
import { paneInteractionPresence } from "@tmux-ide/core";
|
|
3
3
|
import { Badge } from "../ui/badge.jsx";
|
|
4
|
-
import { createSignal, Show } from "solid-js";
|
|
4
|
+
import { createMemo, createSignal, Show } from "solid-js";
|
|
5
5
|
import { clipTerminal, terminalDisplayWidth } from "../terminal-text.js";
|
|
6
6
|
import { AgentBadge, IconButton, componentPalette } from "../ui/index.js";
|
|
7
7
|
function agentStatus(activity) {
|
|
@@ -49,13 +49,6 @@ export function PaneTitleBar(props) {
|
|
|
49
49
|
return 0;
|
|
50
50
|
return Math.min(available, terminalDisplayWidth(activityLabel()) + 2);
|
|
51
51
|
};
|
|
52
|
-
// Capture one non-null presence for each badge lifetime. A retiring child's
|
|
53
|
-
// queued style effect must not dereference the parent's now-expired receipt.
|
|
54
|
-
const activityBadge = () => {
|
|
55
|
-
const current = presence();
|
|
56
|
-
const width = activityWidth();
|
|
57
|
-
return current && width > 0 ? { presence: current, width, label: activityLabel() } : null;
|
|
58
|
-
};
|
|
59
52
|
// Keep the state glyph out of the first two inline cells. OpenTUI can repaint
|
|
60
53
|
// those cells from the clipped parent during nested workspace composition.
|
|
61
54
|
const markerGutterWidth = () => Math.min(2, safeWidth());
|
|
@@ -120,6 +113,20 @@ export function PaneTitleBar(props) {
|
|
|
120
113
|
else
|
|
121
114
|
props.onSelectIntent();
|
|
122
115
|
};
|
|
116
|
+
// Capture one non-null presence for each badge lifetime. A retiring child's
|
|
117
|
+
// queued style effect must not dereference the parent's now-expired receipt.
|
|
118
|
+
const activityBadge = createMemo(() => {
|
|
119
|
+
const current = presence();
|
|
120
|
+
const width = activityWidth();
|
|
121
|
+
return current && width > 0 ? { presence: current, width, label: activityLabel() } : null;
|
|
122
|
+
}, undefined, {
|
|
123
|
+
equals: (previous, next) => previous === next ||
|
|
124
|
+
(previous !== null &&
|
|
125
|
+
next !== null &&
|
|
126
|
+
previous.width === next.width &&
|
|
127
|
+
previous.label === next.label &&
|
|
128
|
+
previous.presence.tone === next.presence.tone),
|
|
129
|
+
});
|
|
123
130
|
return (<box id={`pane-title-bar:${props.paneId}`} position="absolute" left={0} top={0} width={safeWidth()} height={1} zIndex={2} flexDirection="row" overflow="hidden" backgroundColor={palette().background} onMouseOver={() => setPointerInside(true)} onMouseOut={() => setPointerInside(false)} onMouseDown={selectOrOpenMenu}>
|
|
124
131
|
<text width={markerGutterWidth()} height={1} flexShrink={0} bg={palette().background} onMouseDown={selectOrOpenMenu}>
|
|
125
132
|
{" ".repeat(markerGutterWidth())}
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import { performance } from "node:perf_hooks";
|
|
2
|
+
import type { DaemonInstanceIdentity } from "@tmux-ide/contracts";
|
|
3
|
+
import type { Hono } from "hono";
|
|
4
|
+
import { requireOwnerAuthority } from "./owner-authority.ts";
|
|
5
|
+
|
|
6
|
+
/** One synchronous, demand-only sample. No observers, discovery, or retained baseline. */
|
|
7
|
+
export function sampleDaemonDiagnostics(daemon: DaemonInstanceIdentity) {
|
|
8
|
+
const sampledAtMs = Date.now();
|
|
9
|
+
const memory = process.memoryUsage();
|
|
10
|
+
const cpu = process.cpuUsage();
|
|
11
|
+
const eventLoop = performance.eventLoopUtilization();
|
|
12
|
+
// Fixed output cardinality. Unknown runtime resource labels are never echoed.
|
|
13
|
+
const activeResources = {
|
|
14
|
+
Timeout: 0,
|
|
15
|
+
Immediate: 0,
|
|
16
|
+
TCPServerWrap: 0,
|
|
17
|
+
TCPSocketWrap: 0,
|
|
18
|
+
PipeWrap: 0,
|
|
19
|
+
ProcessWrap: 0,
|
|
20
|
+
FSEventWrap: 0,
|
|
21
|
+
other: 0,
|
|
22
|
+
};
|
|
23
|
+
const resourceNames = process.getActiveResourcesInfo?.();
|
|
24
|
+
for (const resource of resourceNames ?? []) {
|
|
25
|
+
if (Object.hasOwn(activeResources, resource) && resource !== "other") {
|
|
26
|
+
activeResources[resource as keyof typeof activeResources]++;
|
|
27
|
+
} else {
|
|
28
|
+
activeResources.other++;
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
return {
|
|
32
|
+
daemon: {
|
|
33
|
+
protocolVersion: daemon.protocolVersion,
|
|
34
|
+
productVersion: daemon.productVersion,
|
|
35
|
+
instanceId: daemon.instanceId,
|
|
36
|
+
startedAt: daemon.startedAt,
|
|
37
|
+
...(daemon.environmentId !== undefined ? { environmentId: daemon.environmentId } : {}),
|
|
38
|
+
},
|
|
39
|
+
pid: process.pid,
|
|
40
|
+
uptimeMs: process.uptime() * 1000,
|
|
41
|
+
sampledAtMs,
|
|
42
|
+
memory: {
|
|
43
|
+
rss: memory.rss,
|
|
44
|
+
heapTotal: memory.heapTotal,
|
|
45
|
+
heapUsed: memory.heapUsed,
|
|
46
|
+
external: memory.external,
|
|
47
|
+
arrayBuffers: memory.arrayBuffers,
|
|
48
|
+
},
|
|
49
|
+
cpu: { user: cpu.user, system: cpu.system },
|
|
50
|
+
eventLoop: {
|
|
51
|
+
idle: eventLoop.idle,
|
|
52
|
+
active: eventLoop.active,
|
|
53
|
+
utilization: eventLoop.utilization,
|
|
54
|
+
},
|
|
55
|
+
activeResources: resourceNames === undefined ? null : activeResources,
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export function mountDiagnosticsRoute(
|
|
60
|
+
app: Hono,
|
|
61
|
+
options: { daemon: DaemonInstanceIdentity; ownerToken: string | null },
|
|
62
|
+
) {
|
|
63
|
+
app.get(
|
|
64
|
+
"/api/diagnostics",
|
|
65
|
+
requireOwnerAuthority(options.ownerToken, {
|
|
66
|
+
whenOwnerless: "unavailable",
|
|
67
|
+
unavailableMessage: "Diagnostics unavailable",
|
|
68
|
+
mismatchMessage: "Diagnostics require owner authority",
|
|
69
|
+
}),
|
|
70
|
+
(c) => {
|
|
71
|
+
c.header("Cache-Control", "no-store");
|
|
72
|
+
return c.json(sampleDaemonDiagnostics(options.daemon));
|
|
73
|
+
},
|
|
74
|
+
);
|
|
75
|
+
}
|
|
@@ -19,6 +19,7 @@ export async function streamBoundedLogs(
|
|
|
19
19
|
entries?: number;
|
|
20
20
|
bytes?: number;
|
|
21
21
|
writeTimeoutMs?: number;
|
|
22
|
+
heartbeatIntervalMs?: number;
|
|
22
23
|
},
|
|
23
24
|
): Promise<void> {
|
|
24
25
|
const maxEntries = options.entries ?? 256;
|
|
@@ -29,6 +30,7 @@ export async function streamBoundedLogs(
|
|
|
29
30
|
let wake: (() => void) | null = null;
|
|
30
31
|
let unsubscribe = () => {};
|
|
31
32
|
let timer: ReturnType<typeof setTimeout> | null = null;
|
|
33
|
+
let heartbeatTimer: ReturnType<typeof setInterval> | null = null;
|
|
32
34
|
let cancelWrite: (() => void) | null = null;
|
|
33
35
|
const cleanup = () => {
|
|
34
36
|
if (closed) return;
|
|
@@ -38,6 +40,8 @@ export async function streamBoundedLogs(
|
|
|
38
40
|
bytes = 0;
|
|
39
41
|
if (timer) clearTimeout(timer);
|
|
40
42
|
timer = null;
|
|
43
|
+
if (heartbeatTimer) clearInterval(heartbeatTimer);
|
|
44
|
+
heartbeatTimer = null;
|
|
41
45
|
wake?.();
|
|
42
46
|
wake = null;
|
|
43
47
|
cancelWrite?.();
|
|
@@ -67,6 +71,10 @@ export async function streamBoundedLogs(
|
|
|
67
71
|
unsubscribe();
|
|
68
72
|
return;
|
|
69
73
|
}
|
|
74
|
+
heartbeatTimer = setInterval(() => {
|
|
75
|
+
if (!closed && queue.length === 0 && !push({ event: "heartbeat", data: "keep-alive" })) abort();
|
|
76
|
+
}, options.heartbeatIntervalMs ?? 15_000);
|
|
77
|
+
heartbeatTimer.unref?.();
|
|
70
78
|
try {
|
|
71
79
|
// Keep newest bounded history, reserving room for gap/bookmark metadata.
|
|
72
80
|
const retained: Frame[] = [];
|
|
@@ -155,6 +155,7 @@ import { mountWorkspaceResourceRoutes } from "./resources/workspace-resource-rou
|
|
|
155
155
|
import { mountFleetResourceRoute } from "./resources/fleet-resource-route.ts";
|
|
156
156
|
import { mountWorkspaceMissionsRoute } from "./resources/workspace-missions-route.ts";
|
|
157
157
|
import { ownerAuthorityGate, requireOwnerAuthority } from "./owner-authority.ts";
|
|
158
|
+
import { mountDiagnosticsRoute } from "./diagnostics.ts";
|
|
158
159
|
import {
|
|
159
160
|
mountStartupReadinessRoute,
|
|
160
161
|
type StartupReadinessAttachmentAuthority,
|
|
@@ -532,6 +533,13 @@ export function createApp(options: CreateAppOptions = {}): Hono {
|
|
|
532
533
|
// Allow cross-origin (Next.js dashboard, Tailscale, etc.)
|
|
533
534
|
app.use("/*", cors());
|
|
534
535
|
|
|
536
|
+
// Owner-only reads use the same early routing boundary as issuance below:
|
|
537
|
+
// remote/project credentials neither authorize nor block the owner bearer.
|
|
538
|
+
mountDiagnosticsRoute(app, {
|
|
539
|
+
daemon: daemonInstanceIdentity,
|
|
540
|
+
ownerToken: options.remoteAccess?.ownerToken ?? null,
|
|
541
|
+
});
|
|
542
|
+
|
|
535
543
|
// This exact route carries its own owner-only bearer and correlation gate.
|
|
536
544
|
// Mount it before remote and project auth so a valid host capability is
|
|
537
545
|
// neither rejected nor confused with any remotely shared user credential.
|
|
@@ -84,7 +84,9 @@ export interface AppThemeGlyphs {
|
|
|
84
84
|
* reads as one system.
|
|
85
85
|
*/
|
|
86
86
|
export interface AppTheme {
|
|
87
|
-
/**
|
|
87
|
+
/** Improve composed app text contrast. Default true. */
|
|
88
|
+
automaticContrast: boolean;
|
|
89
|
+
/** Explicit palette mode or terminal-following mode. Defaults to following the terminal. */
|
|
88
90
|
mode: ThemeModeSetting;
|
|
89
91
|
preset?: string;
|
|
90
92
|
/** Primary/brand accent (default `colour75`). */
|
|
@@ -263,7 +265,8 @@ export const DEFAULT_APP_CONFIG: AppConfig = {
|
|
|
263
265
|
panels: { explorer: "M-e", changes: "M-g", config: "M-," },
|
|
264
266
|
},
|
|
265
267
|
theme: {
|
|
266
|
-
|
|
268
|
+
automaticContrast: true,
|
|
269
|
+
mode: "system",
|
|
267
270
|
accent: "colour75",
|
|
268
271
|
muted: "colour240",
|
|
269
272
|
fg: "colour250",
|
|
@@ -395,7 +398,12 @@ export function parseAppConfig(input: unknown): AppConfig {
|
|
|
395
398
|
},
|
|
396
399
|
},
|
|
397
400
|
theme: {
|
|
398
|
-
|
|
401
|
+
automaticContrast: pickBool(theme.automaticContrast, D.theme.automaticContrast),
|
|
402
|
+
mode: pickChoice(
|
|
403
|
+
theme.mode,
|
|
404
|
+
["dark", "light", "system"],
|
|
405
|
+
findVisualThemePreset(theme.preset)?.appearance ?? D.theme.mode,
|
|
406
|
+
),
|
|
399
407
|
...(findVisualThemePreset(theme.preset) ? { preset: String(theme.preset) } : {}),
|
|
400
408
|
accent: pickString(theme.accent, D.theme.accent),
|
|
401
409
|
muted: pickString(theme.muted, D.theme.muted),
|
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
/** Pure, allowlisted evidence projection. Never retain response bodies or errors. */
|
|
2
|
+
export const DIAGNOSTICS_MAX_BYTES = 16_384;
|
|
3
|
+
export const RESOURCE_KEYS = [
|
|
4
|
+
"Timeout",
|
|
5
|
+
"Immediate",
|
|
6
|
+
"TCPServerWrap",
|
|
7
|
+
"TCPSocketWrap",
|
|
8
|
+
"PipeWrap",
|
|
9
|
+
"ProcessWrap",
|
|
10
|
+
"FSEventWrap",
|
|
11
|
+
"other",
|
|
12
|
+
] as const;
|
|
13
|
+
export const MEMORY_KEYS = ["rss", "heapTotal", "heapUsed", "external", "arrayBuffers"] as const;
|
|
14
|
+
export interface DiagnosticsIdentity {
|
|
15
|
+
protocolVersion: number;
|
|
16
|
+
productVersion: string;
|
|
17
|
+
instanceId: string;
|
|
18
|
+
startedAt: string;
|
|
19
|
+
environmentId?: string;
|
|
20
|
+
}
|
|
21
|
+
export interface DiagnosticsSample {
|
|
22
|
+
daemon: DiagnosticsIdentity;
|
|
23
|
+
pid: number;
|
|
24
|
+
uptimeMs: number;
|
|
25
|
+
sampledAtMs: number;
|
|
26
|
+
memory: Record<(typeof MEMORY_KEYS)[number], number>;
|
|
27
|
+
cpu: { user: number; system: number };
|
|
28
|
+
eventLoop: { idle: number; active: number; utilization: number };
|
|
29
|
+
activeResources: Record<(typeof RESOURCE_KEYS)[number], number> | null;
|
|
30
|
+
}
|
|
31
|
+
export type DiagnosticsResult =
|
|
32
|
+
| { status: "ok"; sample: DiagnosticsSample }
|
|
33
|
+
| {
|
|
34
|
+
status:
|
|
35
|
+
| "missing"
|
|
36
|
+
| "unsupported-endpoint"
|
|
37
|
+
| "http-error"
|
|
38
|
+
| "transport-error"
|
|
39
|
+
| "malformed"
|
|
40
|
+
| "identity-mismatch";
|
|
41
|
+
sample: null;
|
|
42
|
+
};
|
|
43
|
+
const object = (v: unknown): v is Record<string, unknown> =>
|
|
44
|
+
!!v && typeof v === "object" && !Array.isArray(v);
|
|
45
|
+
const number = (v: unknown): v is number => typeof v === "number" && Number.isFinite(v) && v >= 0;
|
|
46
|
+
const text = (v: unknown): v is string => typeof v === "string" && v.length > 0 && v.length <= 256;
|
|
47
|
+
function numeric<K extends string>(
|
|
48
|
+
v: unknown,
|
|
49
|
+
keys: readonly K[],
|
|
50
|
+
integer = false,
|
|
51
|
+
): Record<K, number> | null {
|
|
52
|
+
if (!object(v) || !keys.every((k) => number(v[k]) && (!integer || Number.isSafeInteger(v[k]))))
|
|
53
|
+
return null;
|
|
54
|
+
return Object.fromEntries(keys.map((k) => [k, v[k]])) as Record<K, number>;
|
|
55
|
+
}
|
|
56
|
+
export function parseSoakDiagnostics(
|
|
57
|
+
body: string | null,
|
|
58
|
+
status: number,
|
|
59
|
+
expected: DiagnosticsIdentity & { pid: number },
|
|
60
|
+
): DiagnosticsResult {
|
|
61
|
+
const fail = (status: Exclude<DiagnosticsResult["status"], "ok">): DiagnosticsResult => ({
|
|
62
|
+
status,
|
|
63
|
+
sample: null,
|
|
64
|
+
});
|
|
65
|
+
if (status === 404) return fail("unsupported-endpoint");
|
|
66
|
+
if (status !== 200) return fail("http-error");
|
|
67
|
+
if (body === null || body === "") return fail("missing");
|
|
68
|
+
if (new TextEncoder().encode(body).length > DIAGNOSTICS_MAX_BYTES) return fail("malformed");
|
|
69
|
+
let raw: unknown;
|
|
70
|
+
try {
|
|
71
|
+
raw = JSON.parse(body);
|
|
72
|
+
} catch {
|
|
73
|
+
return fail("malformed");
|
|
74
|
+
}
|
|
75
|
+
if (!object(raw) || !object(raw.daemon)) return fail("malformed");
|
|
76
|
+
const d = raw.daemon;
|
|
77
|
+
if (
|
|
78
|
+
!Number.isSafeInteger(d.protocolVersion) ||
|
|
79
|
+
!number(d.protocolVersion) ||
|
|
80
|
+
!text(d.productVersion) ||
|
|
81
|
+
!text(d.instanceId) ||
|
|
82
|
+
!text(d.startedAt) ||
|
|
83
|
+
!Number.isFinite(Date.parse(d.startedAt)) ||
|
|
84
|
+
(d.environmentId !== undefined && !text(d.environmentId)) ||
|
|
85
|
+
!Number.isSafeInteger(raw.pid) ||
|
|
86
|
+
!number(raw.pid) ||
|
|
87
|
+
raw.pid === 0
|
|
88
|
+
)
|
|
89
|
+
return fail("malformed");
|
|
90
|
+
if (
|
|
91
|
+
["protocolVersion", "productVersion", "instanceId", "startedAt", "environmentId"].some(
|
|
92
|
+
(k) => d[k] !== expected[k as keyof DiagnosticsIdentity],
|
|
93
|
+
) ||
|
|
94
|
+
raw.pid !== expected.pid
|
|
95
|
+
)
|
|
96
|
+
return fail("identity-mismatch");
|
|
97
|
+
const memory = numeric(raw.memory, MEMORY_KEYS, true);
|
|
98
|
+
const cpu = numeric(raw.cpu, ["user", "system"], true);
|
|
99
|
+
const eventLoop = numeric(raw.eventLoop, ["idle", "active", "utilization"]);
|
|
100
|
+
const activeResources =
|
|
101
|
+
raw.activeResources === null ? null : numeric(raw.activeResources, RESOURCE_KEYS, true);
|
|
102
|
+
if (
|
|
103
|
+
!memory ||
|
|
104
|
+
!cpu ||
|
|
105
|
+
!eventLoop ||
|
|
106
|
+
eventLoop.utilization > 1 ||
|
|
107
|
+
!number(raw.uptimeMs) ||
|
|
108
|
+
!number(raw.sampledAtMs) ||
|
|
109
|
+
(raw.activeResources !== null && !activeResources)
|
|
110
|
+
)
|
|
111
|
+
return fail("malformed");
|
|
112
|
+
return {
|
|
113
|
+
status: "ok",
|
|
114
|
+
sample: {
|
|
115
|
+
daemon: {
|
|
116
|
+
protocolVersion: d.protocolVersion,
|
|
117
|
+
productVersion: d.productVersion,
|
|
118
|
+
instanceId: d.instanceId,
|
|
119
|
+
startedAt: d.startedAt,
|
|
120
|
+
...(d.environmentId !== undefined ? { environmentId: d.environmentId } : {}),
|
|
121
|
+
},
|
|
122
|
+
pid: raw.pid,
|
|
123
|
+
uptimeMs: raw.uptimeMs,
|
|
124
|
+
sampledAtMs: raw.sampledAtMs,
|
|
125
|
+
memory,
|
|
126
|
+
cpu,
|
|
127
|
+
eventLoop,
|
|
128
|
+
activeResources,
|
|
129
|
+
},
|
|
130
|
+
};
|
|
131
|
+
}
|
|
132
|
+
export type DiagnosticsDelta =
|
|
133
|
+
| {
|
|
134
|
+
status: "ok";
|
|
135
|
+
intervalMs: number;
|
|
136
|
+
cpuUserMicros: number;
|
|
137
|
+
cpuSystemMicros: number;
|
|
138
|
+
cpuPercent: number;
|
|
139
|
+
eventLoopIdleMs: number;
|
|
140
|
+
eventLoopActiveMs: number;
|
|
141
|
+
eventLoopUtilization: number | null;
|
|
142
|
+
}
|
|
143
|
+
| {
|
|
144
|
+
status: "missing-baseline" | "identity-mismatch" | "counter-regression" | "invalid-interval";
|
|
145
|
+
};
|
|
146
|
+
/** Uptime is monotonic; wall-clock jumps do not change interval rates. */
|
|
147
|
+
export function diagnosticsDelta(
|
|
148
|
+
previous: DiagnosticsSample | null,
|
|
149
|
+
current: DiagnosticsSample,
|
|
150
|
+
): DiagnosticsDelta {
|
|
151
|
+
if (!previous) return { status: "missing-baseline" };
|
|
152
|
+
if (
|
|
153
|
+
previous.pid !== current.pid ||
|
|
154
|
+
["protocolVersion", "productVersion", "instanceId", "startedAt", "environmentId"].some(
|
|
155
|
+
(key) =>
|
|
156
|
+
previous.daemon[key as keyof DiagnosticsIdentity] !==
|
|
157
|
+
current.daemon[key as keyof DiagnosticsIdentity],
|
|
158
|
+
)
|
|
159
|
+
)
|
|
160
|
+
return { status: "identity-mismatch" };
|
|
161
|
+
const intervalMs = current.uptimeMs - previous.uptimeMs;
|
|
162
|
+
if (!Number.isFinite(intervalMs) || intervalMs <= 0) return { status: "invalid-interval" };
|
|
163
|
+
const cpuUserMicros = current.cpu.user - previous.cpu.user;
|
|
164
|
+
const cpuSystemMicros = current.cpu.system - previous.cpu.system;
|
|
165
|
+
const eventLoopIdleMs = current.eventLoop.idle - previous.eventLoop.idle;
|
|
166
|
+
const eventLoopActiveMs = current.eventLoop.active - previous.eventLoop.active;
|
|
167
|
+
if (![cpuUserMicros, cpuSystemMicros, eventLoopIdleMs, eventLoopActiveMs].every(number))
|
|
168
|
+
return { status: "counter-regression" };
|
|
169
|
+
const cpuPercent = (cpuUserMicros + cpuSystemMicros) / (intervalMs * 10);
|
|
170
|
+
if (!Number.isFinite(cpuPercent)) return { status: "invalid-interval" };
|
|
171
|
+
const eventTotal = eventLoopIdleMs + eventLoopActiveMs;
|
|
172
|
+
if (!Number.isFinite(eventTotal)) return { status: "invalid-interval" };
|
|
173
|
+
return {
|
|
174
|
+
status: "ok",
|
|
175
|
+
intervalMs,
|
|
176
|
+
cpuUserMicros,
|
|
177
|
+
cpuSystemMicros,
|
|
178
|
+
cpuPercent,
|
|
179
|
+
eventLoopIdleMs,
|
|
180
|
+
eventLoopActiveMs,
|
|
181
|
+
eventLoopUtilization: eventTotal === 0 ? null : eventLoopActiveMs / eventTotal,
|
|
182
|
+
};
|
|
183
|
+
}
|