tmux-ide 2.9.0-beta.19 → 2.9.0-beta.21

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (90) hide show
  1. package/bin/cli.js +1979 -783
  2. package/package.json +4 -2
  3. package/packages/contracts/src/__tests__/daemon-wire.test.ts +32 -0
  4. package/packages/contracts/src/daemon-events.ts +12 -12
  5. package/packages/contracts/src/daemon-wire.ts +30 -0
  6. package/packages/daemon/dist/command-center/agent-status-watch.js +9 -2
  7. package/packages/daemon/dist/command-center/diagnostics.js +65 -0
  8. package/packages/daemon/dist/command-center/log-stream.js +9 -0
  9. package/packages/daemon/dist/command-center/resources/fleet-preview-route.js +22 -6
  10. package/packages/daemon/dist/command-center/server.js +7 -0
  11. package/packages/daemon/dist/doctor.js +62 -0
  12. package/packages/daemon/dist/lib/__tests__/installed-recovery-fixture.js +400 -0
  13. package/packages/daemon/dist/lib/app-config.js +4 -2
  14. package/packages/daemon/dist/lib/canonical-daemon.js +1 -0
  15. package/packages/daemon/dist/lib/daemon-embed.js +24 -0
  16. package/packages/daemon/dist/lib/daemon-provenance.js +575 -0
  17. package/packages/daemon/dist/lib/headless-daemon.js +1 -0
  18. package/packages/daemon/dist/lib/log-sanitize.js +199 -0
  19. package/packages/daemon/dist/lib/log.js +123 -13
  20. package/packages/daemon/dist/lib/soak-diagnostics.js +124 -0
  21. package/packages/daemon/dist/lib/soak-verdict.js +472 -0
  22. package/packages/daemon/dist/lib/terminal-host-color.js +33 -0
  23. package/packages/daemon/dist/lib/tmux-external-interaction-observer.js +187 -39
  24. package/packages/daemon/dist/lib/tmux-interaction-retention.js +21 -0
  25. package/packages/daemon/dist/lib/workspace-promotion.js +55 -37
  26. package/packages/daemon/dist/terminal/mirror/session-channel.js +2 -1
  27. package/packages/daemon/dist/tui/mirror/automatic-contrast.js +161 -0
  28. package/packages/daemon/dist/tui/mirror/open-tui-workspace-runtime-port.js +9 -3
  29. package/packages/daemon/dist/tui/mirror/pane-surface.jsx +7 -5
  30. package/packages/daemon/dist/tui/mirror/resize-transaction.js +48 -20
  31. package/packages/daemon/dist/tui/mirror/runtime/application-appearance-owner.js +40 -6
  32. package/packages/daemon/dist/tui/mirror/runtime/application-fleet-preview.js +4 -1
  33. package/packages/daemon/dist/tui/mirror/runtime/application-machine-sidebar.jsx +63 -42
  34. package/packages/daemon/dist/tui/mirror/runtime/application-terminal-interaction-controller.js +138 -67
  35. package/packages/daemon/dist/tui/mirror/runtime/application-terminal-palette-owner.js +44 -28
  36. package/packages/daemon/dist/tui/mirror/runtime/application-terminal-workspace.jsx +49 -11
  37. package/packages/daemon/dist/tui/mirror/runtime/semantic-shell-viewport-resize.js +140 -2
  38. package/packages/daemon/dist/tui/mirror/runtime/workspace-terminal-fast-lane.js +3 -1
  39. package/packages/daemon/dist/tui/mirror/semantic-pane-render-source.js +2 -1
  40. package/packages/daemon/dist/tui/mirror/theme.js +4 -31
  41. package/packages/daemon/dist/tui/mirror/workspace/terminal-pane-header.jsx +15 -8
  42. package/packages/daemon/dist/tui/team/wait-receipts.js +112 -33
  43. package/packages/daemon/src/command-center/agent-status-watch.ts +8 -2
  44. package/packages/daemon/src/command-center/diagnostics.ts +75 -0
  45. package/packages/daemon/src/command-center/log-stream.ts +8 -0
  46. package/packages/daemon/src/command-center/resources/fleet-preview-route.ts +27 -5
  47. package/packages/daemon/src/command-center/server.ts +8 -0
  48. package/packages/daemon/src/doctor.ts +70 -0
  49. package/packages/daemon/src/lib/app-config.ts +11 -3
  50. package/packages/daemon/src/lib/canonical-daemon.ts +1 -0
  51. package/packages/daemon/src/lib/daemon-embed.ts +30 -0
  52. package/packages/daemon/src/lib/daemon-provenance.ts +769 -0
  53. package/packages/daemon/src/lib/fleet-preview-model.ts +1 -0
  54. package/packages/daemon/src/lib/headless-daemon.ts +1 -0
  55. package/packages/daemon/src/lib/log-sanitize.ts +248 -0
  56. package/packages/daemon/src/lib/log.ts +165 -14
  57. package/packages/daemon/src/lib/soak-diagnostics.ts +183 -0
  58. package/packages/daemon/src/lib/soak-verdict.ts +785 -0
  59. package/packages/daemon/src/lib/terminal-host-color.ts +43 -0
  60. package/packages/daemon/src/lib/tmux-external-interaction-observer.ts +233 -37
  61. package/packages/daemon/src/lib/tmux-interaction-retention.ts +24 -0
  62. package/packages/daemon/src/lib/workspace-promotion.ts +74 -45
  63. package/packages/daemon/src/terminal/mirror/session-channel.ts +2 -1
  64. package/packages/daemon/src/tui/mirror/automatic-contrast.ts +180 -0
  65. package/packages/daemon/src/tui/mirror/open-tui-workspace-runtime-port.ts +26 -5
  66. package/packages/daemon/src/tui/mirror/pane-surface.tsx +9 -8
  67. package/packages/daemon/src/tui/mirror/resize-transaction.ts +46 -22
  68. package/packages/daemon/src/tui/mirror/runtime/application-appearance-owner.ts +45 -6
  69. package/packages/daemon/src/tui/mirror/runtime/application-fleet-preview.ts +4 -0
  70. package/packages/daemon/src/tui/mirror/runtime/application-fleet-switcher.tsx +2 -1
  71. package/packages/daemon/src/tui/mirror/runtime/application-machine-sidebar.tsx +99 -75
  72. package/packages/daemon/src/tui/mirror/runtime/application-palette-preview.tsx +13 -3
  73. package/packages/daemon/src/tui/mirror/runtime/application-reference-sheet.tsx +125 -0
  74. package/packages/daemon/src/tui/mirror/runtime/application-root-v2.tsx +2 -1
  75. package/packages/daemon/src/tui/mirror/runtime/application-shell-overlays.tsx +199 -157
  76. package/packages/daemon/src/tui/mirror/runtime/application-shell-view.tsx +2 -0
  77. package/packages/daemon/src/tui/mirror/runtime/application-terminal-interaction-controller.ts +148 -69
  78. package/packages/daemon/src/tui/mirror/runtime/application-terminal-palette-owner.ts +55 -28
  79. package/packages/daemon/src/tui/mirror/runtime/application-terminal-workspace.tsx +59 -17
  80. package/packages/daemon/src/tui/mirror/runtime/semantic-shell-viewport-resize.ts +151 -3
  81. package/packages/daemon/src/tui/mirror/runtime/workspace-terminal-fast-lane.ts +3 -1
  82. package/packages/daemon/src/tui/mirror/semantic-pane-render-source.ts +2 -1
  83. package/packages/daemon/src/tui/mirror/theme.ts +4 -39
  84. package/packages/daemon/src/tui/mirror/workspace/terminal-pane-header.tsx +21 -8
  85. package/packages/daemon/src/tui/team/wait-receipts.ts +115 -40
  86. package/packages/daemon-client/src/terminal-fast-lane.test.ts +18 -0
  87. package/packages/daemon-client/src/terminal-fast-lane.ts +7 -2
  88. package/packages/tmux-bridge/src/index.ts +2 -0
  89. package/packages/tmux-bridge/src/runner.test.ts +41 -0
  90. package/packages/tmux-bridge/src/runner.ts +41 -2
@@ -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())}
@@ -2,12 +2,11 @@
2
2
  * Receipt-driven `wait agent-status` — the push twin of the polling loop in
3
3
  * `wait.ts`.
4
4
  *
5
- * When a canonical daemon is running, its agent-status watcher already
6
- * observes every `@agent_state` transition and emits a typed
7
- * `agent.turn-completed` receipt on the `/ws/events` bus. Waiting on that
8
- * receipt replaces the CLI's local poll (which re-scrapes the whole fleet
9
- * every 750 ms) with a single WebSocket that sits idle until the daemon
10
- * pushes the completion.
5
+ * Daemon completion receipts and session invalidations are wake-up hints, not
6
+ * proof that the whole session reached a status. Re-read the same aggregate
7
+ * session status as the polling path: another agent may still be working or
8
+ * blocked. Neither transport proves success of a submitted task. Bursts of
9
+ * hints share one read, with a persistent classification tracker.
11
10
  *
12
11
  * Honest degrade: this path only answers for the receipt-covered targets
13
12
  * (`done` / `idle` — the "turn finished" statuses). Every other case — no
@@ -16,7 +15,7 @@
16
15
  * existing polling implementation. A timeout is a real answer, not a fallback.
17
16
  *
18
17
  * Deps are injected (daemon record reader, liveness probe, socket factory,
19
- * one-shot status read, clock) so the waiting logic unit-tests without a
18
+ * aggregate status read, clock) so the waiting logic unit-tests without a
20
19
  * daemon; the exported defaults wire the real io.
21
20
  */
22
21
  import { WebSocket as WsWebSocket } from "ws";
@@ -26,7 +25,7 @@ import { createStatusTracker } from "../detect/classify.js";
26
25
  import { findSessionStatus } from "./report.js";
27
26
  import { listTeamSessions } from "./sessions.js";
28
27
  import { WAIT_DEFAULT_TIMEOUT_MS } from "./wait.js";
29
- /** How long to give the daemon socket to open before falling back to polling. */
28
+ /** How long to allow socket open, hello and observer-install acknowledgement before falling back to polling. */
30
29
  export const RECEIPT_CONNECT_TIMEOUT_MS = 1_500;
31
30
  /** The statuses a turn-completed receipt can settle; all others need the poll. */
32
31
  export function isReceiptCoveredStatus(want) {
@@ -52,6 +51,17 @@ function messageText(data) {
52
51
  export async function waitForAgentStatusViaReceipts(session, want, opts = {}) {
53
52
  if (!isReceiptCoveredStatus(want))
54
53
  return null;
54
+ const timeoutMs = opts.timeoutMs ?? WAIT_DEFAULT_TIMEOUT_MS;
55
+ const now = opts.now ?? Date.now;
56
+ const started = now();
57
+ const timedOut = () => ({
58
+ ok: false,
59
+ session,
60
+ want,
61
+ status: null,
62
+ timedOutAfterMs: timeoutMs,
63
+ });
64
+ const remaining = () => Math.max(0, timeoutMs - (now() - started));
55
65
  const readInfo = opts.readDaemonInfo ?? readCanonicalDaemonInfo;
56
66
  let info;
57
67
  try {
@@ -63,9 +73,26 @@ export async function waitForAgentStatusViaReceipts(session, want, opts = {}) {
63
73
  if (!info)
64
74
  return null;
65
75
  const probeAlive = opts.probeAlive ?? isCanonicalDaemonAlive;
66
- if (!(await probeAlive(info)))
76
+ let probeTimer;
77
+ try {
78
+ const alive = await Promise.race([
79
+ probeAlive(info),
80
+ new Promise((resolve) => {
81
+ probeTimer = setTimeout(() => resolve(null), remaining());
82
+ probeTimer.unref?.();
83
+ }),
84
+ ]);
85
+ if (alive === null || remaining() === 0)
86
+ return timedOut();
87
+ if (!alive)
88
+ return null;
89
+ }
90
+ catch {
67
91
  return null;
68
- const timeoutMs = opts.timeoutMs ?? WAIT_DEFAULT_TIMEOUT_MS;
92
+ }
93
+ finally {
94
+ clearTimeout(probeTimer);
95
+ }
69
96
  const connectTimeoutMs = opts.connectTimeoutMs ?? RECEIPT_CONNECT_TIMEOUT_MS;
70
97
  // A loopback-bound daemon admits same-machine upgrades without a token; a
71
98
  // remote-bound daemon enforces its token even locally, so present the one
@@ -73,8 +100,8 @@ export async function waitForAgentStatusViaReceipts(session, want, opts = {}) {
73
100
  const authToken = info.authToken;
74
101
  const openSocket = opts.openSocket ??
75
102
  ((url) => new WsWebSocket(url, authToken ? { headers: { Authorization: `Bearer ${authToken}` } } : undefined));
76
- const currentStatus = opts.currentStatus ??
77
- (() => findSessionStatus(listTeamSessions(createStatusTracker()), session));
103
+ const tracker = createStatusTracker();
104
+ const currentStatus = opts.currentStatus ?? (() => findSessionStatus(listTeamSessions(tracker), session));
78
105
  const url = canonicalDaemonUrl("ws", info.bindHostname, info.port, "/ws/events");
79
106
  let socket;
80
107
  try {
@@ -85,9 +112,12 @@ export async function waitForAgentStatusViaReceipts(session, want, opts = {}) {
85
112
  }
86
113
  return new Promise((resolve) => {
87
114
  let settled = false;
115
+ let subscribed = false;
116
+ let ready = false;
88
117
  let lastStatus = null;
89
118
  let deadlineTimer = null;
90
119
  let connectTimer = null;
120
+ let refreshTimer = null;
91
121
  const settle = (result) => {
92
122
  if (settled)
93
123
  return;
@@ -96,6 +126,8 @@ export async function waitForAgentStatusViaReceipts(session, want, opts = {}) {
96
126
  clearTimeout(connectTimer);
97
127
  if (deadlineTimer !== null)
98
128
  clearTimeout(deadlineTimer);
129
+ if (refreshTimer !== null)
130
+ clearTimeout(refreshTimer);
99
131
  try {
100
132
  socket.close();
101
133
  }
@@ -104,30 +136,26 @@ export async function waitForAgentStatusViaReceipts(session, want, opts = {}) {
104
136
  }
105
137
  resolve(result);
106
138
  };
107
- connectTimer = setTimeout(() => settle(null), connectTimeoutMs);
108
- connectTimer.unref?.();
109
- socket.on("error", () => settle(null));
110
- socket.on("close", () => settle(null));
111
- socket.on("open", () => {
139
+ const refreshStatus = () => {
112
140
  if (settled)
113
141
  return;
114
- if (connectTimer !== null)
115
- clearTimeout(connectTimer);
116
- deadlineTimer = setTimeout(() => settle({ ok: false, session, want, status: lastStatus, timedOutAfterMs: timeoutMs }), timeoutMs);
117
- deadlineTimer.unref?.();
118
- // The watcher baselines when the first client connects, so a receipt can
119
- // only describe a transition AFTER this socket exists. Answer the
120
- // already-finished case with one direct read.
121
142
  try {
122
143
  lastStatus = currentStatus();
123
144
  }
124
145
  catch {
125
- lastStatus = null;
146
+ // This transport cannot establish aggregate status; use the fallback.
147
+ settle(null);
148
+ return;
126
149
  }
127
- if (lastStatus === want) {
150
+ if (lastStatus === want)
128
151
  settle({ ok: true, session, want, status: want });
129
- }
130
- });
152
+ };
153
+ deadlineTimer = setTimeout(() => settle({ ...timedOut(), status: lastStatus }), remaining());
154
+ deadlineTimer.unref?.();
155
+ connectTimer = setTimeout(() => settle(null), connectTimeoutMs);
156
+ connectTimer.unref?.();
157
+ socket.on("error", () => settle(null));
158
+ socket.on("close", () => settle(null));
131
159
  socket.on("message", (data) => {
132
160
  if (settled)
133
161
  return;
@@ -139,15 +167,66 @@ export async function waitForAgentStatusViaReceipts(session, want, opts = {}) {
139
167
  return; // not a protocol frame — ignore
140
168
  }
141
169
  const parsed = DaemonEventServerFrameSchemaZ.safeParse(raw);
142
- if (!parsed.success)
170
+ if (!parsed.success) {
171
+ if (!ready)
172
+ settle(null); // incompatible handshake/protocol
143
173
  return;
174
+ }
144
175
  const frame = parsed.data;
145
- if (frame.type !== "agent.turn-completed" || frame.sessionName !== session)
176
+ if (frame.type === "protocol.error") {
177
+ settle(null);
146
178
  return;
147
- lastStatus = frame.toStatus;
148
- if (frame.toStatus === want) {
149
- settle({ ok: true, session, want, status: want });
150
179
  }
180
+ if (frame.type === "hello") {
181
+ if (subscribed)
182
+ return;
183
+ if (frame.daemon.protocolVersion !== info.protocolVersion ||
184
+ frame.daemon.instanceId !== info.instanceId) {
185
+ settle(null);
186
+ return;
187
+ }
188
+ subscribed = true;
189
+ try {
190
+ socket.send(JSON.stringify({
191
+ type: "subscribe",
192
+ sessions: [],
193
+ legacyEvents: true,
194
+ interests: [{ resource: "fleet-catalog", workspaceName: null }],
195
+ interestRevision: 1,
196
+ }));
197
+ }
198
+ catch {
199
+ settle(null);
200
+ }
201
+ return;
202
+ }
203
+ if (frame.type === "resource.interests-ack") {
204
+ if (!subscribed || ready || frame.interestRevision !== 1)
205
+ return;
206
+ if (frame.unavailableInterests.length > 0) {
207
+ settle(null);
208
+ return;
209
+ }
210
+ ready = true;
211
+ if (connectTimer !== null)
212
+ clearTimeout(connectTimer);
213
+ // Observation is installed and baselined before this read. Hints that
214
+ // arrived before the barrier are covered by this current snapshot.
215
+ refreshStatus();
216
+ return;
217
+ }
218
+ if (!ready)
219
+ return;
220
+ if ((frame.type !== "agent.turn-completed" && frame.type !== "agent-status.changed") ||
221
+ frame.sessionName !== session)
222
+ return;
223
+ if (refreshTimer !== null)
224
+ return;
225
+ refreshTimer = setTimeout(() => {
226
+ refreshTimer = null;
227
+ refreshStatus();
228
+ }, 0);
229
+ refreshTimer.unref?.();
151
230
  });
152
231
  });
153
232
  }
@@ -105,6 +105,10 @@ export interface AgentTurnCompletion {
105
105
  * Completed turns between two readings: panes present in BOTH whose state word
106
106
  * left `working` for `done` or `idle`. A pane that vanished mid-turn completed
107
107
  * nothing; a pane that appeared already-done transitioned nothing we observed.
108
+ * The durable stamp must remain equal: gaining, losing or replacing it is not
109
+ * evidence that the replacement completed the prior turn. Two unstamped reads
110
+ * retain anonymous receipts for compatibility, without process/task correlation.
111
+ * These sampled status transitions do not prove submitted-task success.
108
112
  * Sorted (session, then pane key) for deterministic emission order.
109
113
  */
110
114
  export function diffTurnCompletions(
@@ -119,11 +123,13 @@ export function diffTurnCompletions(
119
123
  for (const paneId of [...panes.keys()].sort()) {
120
124
  const prior = before.get(paneId);
121
125
  if (prior === undefined || agentStateWord(prior.state) !== "working") continue;
122
- const word = agentStateWord(panes.get(paneId)!.state);
126
+ const current = panes.get(paneId)!;
127
+ if (prior.paneStamp !== current.paneStamp) continue;
128
+ const word = agentStateWord(current.state);
123
129
  if (word !== "done" && word !== "idle") continue;
124
130
  completions.push({
125
131
  sessionName,
126
- paneStamp: panes.get(paneId)!.paneStamp,
132
+ paneStamp: current.paneStamp,
127
133
  fromStatus: "working",
128
134
  toStatus: word,
129
135
  });
@@ -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
+ }