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.
Files changed (49) hide show
  1. package/bin/cli.js +100 -8
  2. package/package.json +3 -2
  3. package/packages/daemon/dist/command-center/diagnostics.js +65 -0
  4. package/packages/daemon/dist/command-center/log-stream.js +9 -0
  5. package/packages/daemon/dist/command-center/server.js +7 -0
  6. package/packages/daemon/dist/lib/app-config.js +4 -2
  7. package/packages/daemon/dist/lib/soak-diagnostics.js +124 -0
  8. package/packages/daemon/dist/lib/soak-verdict.js +185 -35
  9. package/packages/daemon/dist/lib/terminal-host-color.js +33 -0
  10. package/packages/daemon/dist/tui/mirror/automatic-contrast.js +161 -0
  11. package/packages/daemon/dist/tui/mirror/open-tui-workspace-runtime-port.js +9 -3
  12. package/packages/daemon/dist/tui/mirror/pane-surface.jsx +7 -5
  13. package/packages/daemon/dist/tui/mirror/resize-transaction.js +48 -20
  14. package/packages/daemon/dist/tui/mirror/runtime/application-appearance-owner.js +40 -6
  15. package/packages/daemon/dist/tui/mirror/runtime/application-machine-sidebar.jsx +63 -42
  16. package/packages/daemon/dist/tui/mirror/runtime/application-terminal-interaction-controller.js +138 -67
  17. package/packages/daemon/dist/tui/mirror/runtime/application-terminal-palette-owner.js +44 -28
  18. package/packages/daemon/dist/tui/mirror/runtime/application-terminal-workspace.jsx +49 -11
  19. package/packages/daemon/dist/tui/mirror/runtime/semantic-shell-viewport-resize.js +140 -2
  20. package/packages/daemon/dist/tui/mirror/runtime/workspace-terminal-fast-lane.js +3 -1
  21. package/packages/daemon/dist/tui/mirror/semantic-pane-render-source.js +2 -1
  22. package/packages/daemon/dist/tui/mirror/theme.js +4 -31
  23. package/packages/daemon/dist/tui/mirror/workspace/terminal-pane-header.jsx +15 -8
  24. package/packages/daemon/src/command-center/diagnostics.ts +75 -0
  25. package/packages/daemon/src/command-center/log-stream.ts +8 -0
  26. package/packages/daemon/src/command-center/server.ts +8 -0
  27. package/packages/daemon/src/lib/app-config.ts +11 -3
  28. package/packages/daemon/src/lib/soak-diagnostics.ts +183 -0
  29. package/packages/daemon/src/lib/soak-verdict.ts +314 -23
  30. package/packages/daemon/src/lib/terminal-host-color.ts +43 -0
  31. package/packages/daemon/src/tui/mirror/automatic-contrast.ts +180 -0
  32. package/packages/daemon/src/tui/mirror/open-tui-workspace-runtime-port.ts +26 -5
  33. package/packages/daemon/src/tui/mirror/pane-surface.tsx +9 -8
  34. package/packages/daemon/src/tui/mirror/resize-transaction.ts +46 -22
  35. package/packages/daemon/src/tui/mirror/runtime/application-appearance-owner.ts +45 -6
  36. package/packages/daemon/src/tui/mirror/runtime/application-machine-sidebar.tsx +99 -75
  37. package/packages/daemon/src/tui/mirror/runtime/application-root-v2.tsx +1 -0
  38. package/packages/daemon/src/tui/mirror/runtime/application-shell-overlays.tsx +9 -2
  39. package/packages/daemon/src/tui/mirror/runtime/application-shell-view.tsx +2 -0
  40. package/packages/daemon/src/tui/mirror/runtime/application-terminal-interaction-controller.ts +148 -69
  41. package/packages/daemon/src/tui/mirror/runtime/application-terminal-palette-owner.ts +55 -28
  42. package/packages/daemon/src/tui/mirror/runtime/application-terminal-workspace.tsx +59 -17
  43. package/packages/daemon/src/tui/mirror/runtime/semantic-shell-viewport-resize.ts +151 -3
  44. package/packages/daemon/src/tui/mirror/runtime/workspace-terminal-fast-lane.ts +3 -1
  45. package/packages/daemon/src/tui/mirror/semantic-pane-render-source.ts +2 -1
  46. package/packages/daemon/src/tui/mirror/theme.ts +4 -39
  47. package/packages/daemon/src/tui/mirror/workspace/terminal-pane-header.tsx +21 -8
  48. package/packages/daemon-client/src/terminal-fast-lane.test.ts +18 -0
  49. 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
- if (same(applied, target) || same(pending, target))
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 client.fitViewport(viewport.cols, viewport.rows);
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
- [foreground, background] = [background, foreground];
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) ?? reportedPalette[0] ?? null;
218
- const measuredForeground = parseTerminalHostColor(input.defaultForeground) ?? reportedPalette[7] ?? null;
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 = measuredBackground === null
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
- /** Explicit palette mode or terminal-following mode. Default keeps legacy dark visuals. */
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
- mode: "dark",
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
- mode: pickChoice(theme.mode, ["dark", "light", "system"], D.theme.mode),
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
+ }