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.
- package/bin/cli.js +1979 -783
- package/package.json +4 -2
- package/packages/contracts/src/__tests__/daemon-wire.test.ts +32 -0
- package/packages/contracts/src/daemon-events.ts +12 -12
- package/packages/contracts/src/daemon-wire.ts +30 -0
- package/packages/daemon/dist/command-center/agent-status-watch.js +9 -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/resources/fleet-preview-route.js +22 -6
- package/packages/daemon/dist/command-center/server.js +7 -0
- package/packages/daemon/dist/doctor.js +62 -0
- package/packages/daemon/dist/lib/__tests__/installed-recovery-fixture.js +400 -0
- package/packages/daemon/dist/lib/app-config.js +4 -2
- package/packages/daemon/dist/lib/canonical-daemon.js +1 -0
- package/packages/daemon/dist/lib/daemon-embed.js +24 -0
- package/packages/daemon/dist/lib/daemon-provenance.js +575 -0
- package/packages/daemon/dist/lib/headless-daemon.js +1 -0
- package/packages/daemon/dist/lib/log-sanitize.js +199 -0
- package/packages/daemon/dist/lib/log.js +123 -13
- package/packages/daemon/dist/lib/soak-diagnostics.js +124 -0
- package/packages/daemon/dist/lib/soak-verdict.js +472 -0
- package/packages/daemon/dist/lib/terminal-host-color.js +33 -0
- package/packages/daemon/dist/lib/tmux-external-interaction-observer.js +187 -39
- package/packages/daemon/dist/lib/tmux-interaction-retention.js +21 -0
- package/packages/daemon/dist/lib/workspace-promotion.js +55 -37
- package/packages/daemon/dist/terminal/mirror/session-channel.js +2 -1
- 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-fleet-preview.js +4 -1
- 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/dist/tui/team/wait-receipts.js +112 -33
- package/packages/daemon/src/command-center/agent-status-watch.ts +8 -2
- 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/resources/fleet-preview-route.ts +27 -5
- package/packages/daemon/src/command-center/server.ts +8 -0
- package/packages/daemon/src/doctor.ts +70 -0
- package/packages/daemon/src/lib/app-config.ts +11 -3
- package/packages/daemon/src/lib/canonical-daemon.ts +1 -0
- package/packages/daemon/src/lib/daemon-embed.ts +30 -0
- package/packages/daemon/src/lib/daemon-provenance.ts +769 -0
- package/packages/daemon/src/lib/fleet-preview-model.ts +1 -0
- package/packages/daemon/src/lib/headless-daemon.ts +1 -0
- package/packages/daemon/src/lib/log-sanitize.ts +248 -0
- package/packages/daemon/src/lib/log.ts +165 -14
- package/packages/daemon/src/lib/soak-diagnostics.ts +183 -0
- package/packages/daemon/src/lib/soak-verdict.ts +785 -0
- package/packages/daemon/src/lib/terminal-host-color.ts +43 -0
- package/packages/daemon/src/lib/tmux-external-interaction-observer.ts +233 -37
- package/packages/daemon/src/lib/tmux-interaction-retention.ts +24 -0
- package/packages/daemon/src/lib/workspace-promotion.ts +74 -45
- package/packages/daemon/src/terminal/mirror/session-channel.ts +2 -1
- 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-fleet-preview.ts +4 -0
- package/packages/daemon/src/tui/mirror/runtime/application-fleet-switcher.tsx +2 -1
- package/packages/daemon/src/tui/mirror/runtime/application-machine-sidebar.tsx +99 -75
- package/packages/daemon/src/tui/mirror/runtime/application-palette-preview.tsx +13 -3
- package/packages/daemon/src/tui/mirror/runtime/application-reference-sheet.tsx +125 -0
- package/packages/daemon/src/tui/mirror/runtime/application-root-v2.tsx +2 -1
- package/packages/daemon/src/tui/mirror/runtime/application-shell-overlays.tsx +199 -157
- 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/src/tui/team/wait-receipts.ts +115 -40
- package/packages/daemon-client/src/terminal-fast-lane.test.ts +18 -0
- package/packages/daemon-client/src/terminal-fast-lane.ts +7 -2
- package/packages/tmux-bridge/src/index.ts +2 -0
- package/packages/tmux-bridge/src/runner.test.ts +41 -0
- 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
|
-
|
|
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())}
|
|
@@ -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
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
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
|
-
*
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
77
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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:
|
|
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
|
+
}
|