lazyclaw 6.4.0 → 6.6.0

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.
@@ -0,0 +1,220 @@
1
+ // tui/slash_dashboard.mjs — the /dashboard slash-command cluster, extracted
2
+ // verbatim from slash_dispatcher.mjs. Self-contained leaf with its own
3
+ // encapsulated module state (_dashboardSpawning / _dashboardChildPid). Imports
4
+ // only the shared leaf helpers, never the dispatcher (no cycle).
5
+
6
+ import { splitWhitespace } from './slash_helpers.mjs';
7
+
8
+ // /dashboard — open the lazyclaw web UI.
9
+ //
10
+ // v5.4.4 ROOT-CAUSE FIX (was: rapid repeated /dashboard within one chat
11
+ // session spawned 20+ daemon children).
12
+ //
13
+ // Original implementation:
14
+ // probe /healthz → if !200, spawn detached `lazyclaw dashboard
15
+ // --no-open` and poll for up to 3s.
16
+ //
17
+ // Failure mode that produced the 20+ spawn pile-up:
18
+ // 1. User types /dashboard. probe fails (no daemon). Spawn child A.
19
+ // 2. Child A begins binding port 19600. Takes ~500ms-2s to be ready.
20
+ // 3. User types /dashboard again BEFORE A is ready. probe still fails.
21
+ // Spawn child B. Child B sees EADDRINUSE and calls _killPortOccupant
22
+ // (cli.mjs:3611) which SIGTERMs child A. B takes over.
23
+ // 4. Repeat. Each /dashboard kills the previous daemon and starts a
24
+ // new one. With autorepeat / many slash calls this stacks fast.
25
+ //
26
+ // Two-layer guard:
27
+ // - A module-level _dashboardSpawning latch refuses concurrent spawn
28
+ // attempts. While a spawn is in flight, /dashboard says so + returns
29
+ // without firing another child.
30
+ // - A _dashboardChildPid cache remembers the PID we already spawned;
31
+ // subsequent calls check kill(pid, 0) to confirm the child is alive
32
+ // and just open the browser without spawning.
33
+ //
34
+ // We probe both /healthz (HTTP) AND a raw net.connect port check so a
35
+ // slow-starting daemon (binding the listener but not yet answering HTTP)
36
+ // still counts as "running".
37
+ let _dashboardSpawning = false;
38
+ let _dashboardChildPid = null;
39
+
40
+ function _portIsListening(port, timeoutMs = 200) {
41
+ return new Promise((resolve) => {
42
+ import('node:net').then(({ createConnection }) => {
43
+ let settled = false;
44
+ const sock = createConnection({ host: '127.0.0.1', port });
45
+ const done = (ok) => {
46
+ if (settled) return;
47
+ settled = true;
48
+ try { sock.destroy(); } catch {}
49
+ resolve(ok);
50
+ };
51
+ sock.once('connect', () => done(true));
52
+ sock.once('error', () => done(false));
53
+ setTimeout(() => done(false), timeoutMs);
54
+ }).catch(() => resolve(false));
55
+ });
56
+ }
57
+
58
+ async function _dashboardProbe(port) {
59
+ // Fast path — port-level probe. Catches a daemon that has bound the
60
+ // socket but hasn't finished initializing its HTTP routes.
61
+ if (await _portIsListening(port, 200)) return true;
62
+ // Slow path — full /healthz fetch, for defense in depth.
63
+ if (typeof fetch !== 'function') return false;
64
+ try {
65
+ const ac = new AbortController();
66
+ const t = setTimeout(() => ac.abort(), 250);
67
+ const r = await fetch(`http://127.0.0.1:${port}/healthz`, { signal: ac.signal });
68
+ clearTimeout(t);
69
+ return !!(r && r.ok);
70
+ } catch { return false; }
71
+ }
72
+
73
+ function _openBrowser(url) {
74
+ return import('node:child_process').then(({ spawn }) => {
75
+ let cmd, args;
76
+ if (process.platform === 'darwin') { cmd = 'open'; args = [url]; }
77
+ else if (process.platform === 'win32') { cmd = 'cmd'; args = ['/c', 'start', '""', url]; }
78
+ else { cmd = 'xdg-open'; args = [url]; }
79
+ try { spawn(cmd, args, { stdio: 'ignore', detached: true }).unref(); } catch { /* swallow */ }
80
+ });
81
+ }
82
+
83
+ async function _dashboardStop(port) {
84
+ // Best-effort kill of every lazyclaw dashboard daemon on the box.
85
+ // Used to clean up after the v5.4.3 spawn pile-up bug.
86
+ if (process.platform === 'win32') {
87
+ return 'dashboard stop: not implemented on Windows yet — kill via Task Manager';
88
+ }
89
+ const { spawn } = await import('node:child_process');
90
+ // Step 1: lsof the port and SIGTERM each PID.
91
+ const portPids = await new Promise((resolve) => {
92
+ try {
93
+ const lsof = spawn('lsof', ['-ti', `tcp:${port}`], { stdio: ['ignore', 'pipe', 'ignore'] });
94
+ let buf = '';
95
+ lsof.stdout.on('data', (d) => { buf += d.toString('utf8'); });
96
+ lsof.on('error', () => resolve([]));
97
+ lsof.on('close', () => resolve(
98
+ buf.trim().split(/\s+/).map((s) => parseInt(s, 10)).filter(Number.isFinite)
99
+ ));
100
+ } catch { resolve([]); }
101
+ });
102
+ for (const pid of portPids) {
103
+ try { process.kill(pid, 'SIGTERM'); } catch { /* gone */ }
104
+ }
105
+ // Step 2: pkill any process whose command line includes "lazyclaw dashboard"
106
+ // — catches detached children that bound a different (random) port via
107
+ // cmdDashboard's EADDRINUSE fallback.
108
+ let pkilled = 0;
109
+ try {
110
+ const pkill = spawn('pkill', ['-f', 'lazyclaw dashboard'], { stdio: ['ignore', 'ignore', 'ignore'] });
111
+ pkilled = await new Promise((r) => pkill.on('close', (code) => r(code === 0 ? 1 : 0)));
112
+ } catch { /* fine */ }
113
+ _dashboardChildPid = null;
114
+ return `✓ stopped ${portPids.length} listener(s) on :${port}${pkilled ? ' + remaining `lazyclaw dashboard` processes via pkill' : ''}`;
115
+ }
116
+
117
+ // Parse the daemon's "listening at <url>" stdout line so /dashboard opens the
118
+ // actually-bound port (the child may fall back to a random port on EADDRINUSE).
119
+ export function parseDashboardUrl(text) {
120
+ const m = String(text || '').match(/listening at\s+(https?:\/\/\S+)/i);
121
+ return m ? m[1] : null;
122
+ }
123
+
124
+ // Resolve the daemon's real URL from its stdout within `timeoutMs`, or null.
125
+ function _waitForDashboardUrl(child, timeoutMs) {
126
+ return new Promise((resolve) => {
127
+ if (!child || !child.stdout) { resolve(null); return; }
128
+ let buf = '';
129
+ let done = false;
130
+ const finish = (v) => {
131
+ if (done) return;
132
+ done = true;
133
+ try { child.stdout.off('data', onData); } catch { /* ignore */ }
134
+ resolve(v);
135
+ };
136
+ const onData = (d) => {
137
+ buf += d.toString('utf8');
138
+ const u = parseDashboardUrl(buf);
139
+ if (u) finish(u);
140
+ };
141
+ child.stdout.on('data', onData);
142
+ setTimeout(() => finish(null), timeoutMs);
143
+ });
144
+ }
145
+
146
+ export async function _dashboard(args) {
147
+ const port = 19600;
148
+ const url = `http://127.0.0.1:${port}/dashboard`;
149
+ // Under the node:test runner, never launch a real daemon or open a browser
150
+ // (it leaked a background daemon + opened a tab on every test run).
151
+ if (process.env.NODE_TEST_CONTEXT) return `dashboard: ${url} (spawn skipped under test)`;
152
+ const sub = splitWhitespace(args)[0];
153
+ if (sub === 'stop' || sub === 'kill') return _dashboardStop(port);
154
+
155
+ // 1. Already running anywhere on the machine? → reuse.
156
+ if (await _dashboardProbe(port)) {
157
+ await _openBrowser(url);
158
+ return `✓ dashboard already running — opened ${url}`;
159
+ }
160
+
161
+ // 2. We spawned in this chat — is that child still alive?
162
+ if (_dashboardChildPid != null) {
163
+ try {
164
+ process.kill(_dashboardChildPid, 0); // signal 0 = liveness probe
165
+ // Child alive but not answering yet. Don't re-spawn; just nudge.
166
+ await _openBrowser(url);
167
+ return `✓ dashboard starting (pid ${_dashboardChildPid}) — opened ${url}`;
168
+ } catch {
169
+ _dashboardChildPid = null; // child died; fall through and respawn.
170
+ }
171
+ }
172
+
173
+ // 3. Spawn already in flight from a concurrent /dashboard? Don't pile on.
174
+ if (_dashboardSpawning) {
175
+ await _openBrowser(url);
176
+ return `dashboard is still booting — opened ${url}; try again in a moment if it didn't load`;
177
+ }
178
+
179
+ // 4. Cold start. Spawn ONE detached child, poll up to 3s, latch the
180
+ // spawn flag in a finally so it always clears.
181
+ _dashboardSpawning = true;
182
+ try {
183
+ const { spawn } = await import('node:child_process');
184
+ let child;
185
+ try {
186
+ // Pass --port so the child tries 19600 first; pipe stdout so we can read
187
+ // the real bound URL (it may fall back to a random port on EADDRINUSE).
188
+ child = spawn(process.execPath, [process.argv[1], 'dashboard', '--port', String(port), '--no-open'], {
189
+ detached: true, stdio: ['ignore', 'pipe', 'ignore'], cwd: process.cwd(), env: process.env,
190
+ });
191
+ _dashboardChildPid = child.pid;
192
+ } catch (e) {
193
+ return `dashboard error: failed to spawn — ${e?.message || e}`;
194
+ }
195
+ // Prefer the daemon's own "listening at <url>" line — it carries the
196
+ // actual port even after a random-port fallback.
197
+ const boundUrl = await _waitForDashboardUrl(child, 3000);
198
+ // Release the captured stdout pipe so the detached daemon doesn't keep
199
+ // OUR event loop alive (unref, not destroy — destroying would EPIPE the
200
+ // daemon on its next stdout write). Then unref the child itself.
201
+ try { if (child.stdout) { child.stdout.removeAllListeners('data'); child.stdout.unref(); } } catch { /* ignore */ }
202
+ child.unref();
203
+ if (boundUrl) {
204
+ await _openBrowser(boundUrl);
205
+ return `✓ started dashboard (pid ${child.pid}) — opened ${boundUrl}`;
206
+ }
207
+ // Fallback: the line never arrived — poll the default port best-effort.
208
+ const start = Date.now();
209
+ while (Date.now() - start < 1500) {
210
+ if (await _dashboardProbe(port)) {
211
+ await _openBrowser(url);
212
+ return `✓ started dashboard (pid ${child.pid}) — opened ${url}`;
213
+ }
214
+ await new Promise((r) => setTimeout(r, 150));
215
+ }
216
+ return `⚠ dashboard didn't come up within 3s (pid ${child.pid}). URL: ${url}`;
217
+ } finally {
218
+ _dashboardSpawning = false;
219
+ }
220
+ }