lazyclaw 5.4.3 → 5.4.4
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/package.json +1 -1
- package/tui/editor.mjs +44 -0
- package/tui/slash_commands.mjs +7 -7
- package/tui/slash_dispatcher.mjs +213 -67
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "lazyclaw",
|
|
3
|
-
"version": "5.4.
|
|
3
|
+
"version": "5.4.4",
|
|
4
4
|
"description": "Lazy, elegant terminal CLI for chatting with Claude / OpenAI / Gemini / Ollama, orchestrating multi-step LLM workflows, and running multi-agent Slack teams with cross-task memory. Banner-on-launch, slash-command ghost autocomplete, persistent sessions, local HTTP gateway.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"claude",
|
package/tui/editor.mjs
CHANGED
|
@@ -50,6 +50,48 @@ export function displayWidth(text) {
|
|
|
50
50
|
return stringWidth(String(text));
|
|
51
51
|
}
|
|
52
52
|
|
|
53
|
+
// ─── IME cursor anchor (v5.4.4) ─────────────────────────────────────
|
|
54
|
+
//
|
|
55
|
+
// v5.4.3 shipped an anchor that moved the cursor inside the editor
|
|
56
|
+
// after every render so IME pre-edit composition appeared in the
|
|
57
|
+
// editor box. It also caused visible flicker because Ink's log-update
|
|
58
|
+
// (node_modules/ink/build/log-update.js) emits an eraseLines sequence
|
|
59
|
+
// (`\x1b[2K\x1b[1A...`) on every redraw — and that sequence walks UP
|
|
60
|
+
// from the CURRENT cursor position. With our anchor up inside the
|
|
61
|
+
// editor, eraseLines erased rows ABOVE the frame, then wrote the new
|
|
62
|
+
// frame starting one editor-height higher than the previous one.
|
|
63
|
+
//
|
|
64
|
+
// v5.4.4 fix — monkey-patch process.stdout.write the first time the
|
|
65
|
+
// anchor fires. When the patched writer sees a chunk that BEGINS with
|
|
66
|
+
// `\x1b[2K` (the start of log-update's eraseLines) AND the anchor
|
|
67
|
+
// offset is non-zero, it prepends `\x1b[<offset>B\r` to move the
|
|
68
|
+
// cursor BACK DOWN to the row log-update expects (one below the
|
|
69
|
+
// previous frame's last line). The user sees no flicker; IME still
|
|
70
|
+
// reads the editor cursor position because the anchor lives across
|
|
71
|
+
// the gap between renders.
|
|
72
|
+
const _anchorState = { offset: 0, shimmed: false };
|
|
73
|
+
|
|
74
|
+
function _installAnchorShim() {
|
|
75
|
+
if (_anchorState.shimmed) return;
|
|
76
|
+
if (!(process.stdout && typeof process.stdout.write === 'function')) return;
|
|
77
|
+
const orig = process.stdout.write.bind(process.stdout);
|
|
78
|
+
process.stdout.write = function patchedWrite(chunk, ...rest) {
|
|
79
|
+
try {
|
|
80
|
+
if (
|
|
81
|
+
_anchorState.offset > 0 &&
|
|
82
|
+
typeof chunk === 'string' &&
|
|
83
|
+
chunk.startsWith('\x1b[2K')
|
|
84
|
+
) {
|
|
85
|
+
const off = _anchorState.offset;
|
|
86
|
+
_anchorState.offset = 0;
|
|
87
|
+
return orig.call(this, `\x1b[${off}B\r` + chunk, ...rest);
|
|
88
|
+
}
|
|
89
|
+
} catch { /* fall through to unmodified write */ }
|
|
90
|
+
return orig.call(this, chunk, ...rest);
|
|
91
|
+
};
|
|
92
|
+
_anchorState.shimmed = true;
|
|
93
|
+
}
|
|
94
|
+
|
|
53
95
|
// Cell-aware soft-wrap. Returns an array of visual rows whose width
|
|
54
96
|
// respects the budget (first row uses `firstBudget`, subsequent rows
|
|
55
97
|
// use `contBudget`). Hoisted to module level (was inner-fn) so the
|
|
@@ -359,6 +401,8 @@ export function Editor({
|
|
|
359
401
|
// starts at col 3. Cursor sits one cell past the typed content.
|
|
360
402
|
const prefixWidth = rowInEditor === 0 ? PROMPT_WIDTH : CONTINUATION_WIDTH;
|
|
361
403
|
const colTarget = 3 + prefixWidth + colInLine;
|
|
404
|
+
_installAnchorShim();
|
|
405
|
+
_anchorState.offset = rowsUp;
|
|
362
406
|
try {
|
|
363
407
|
process.stdout.write(`\x1b[${rowsUp}A\x1b[${colTarget}G\x1b[?25h`);
|
|
364
408
|
} catch { /* stdout closed — swallow */ }
|
package/tui/slash_commands.mjs
CHANGED
|
@@ -14,15 +14,17 @@ export const SLASH_COMMANDS = [
|
|
|
14
14
|
{ cmd: '/status', help: 'print current provider, model, masked key' },
|
|
15
15
|
{ cmd: '/version', help: 'print version + node + platform' },
|
|
16
16
|
{ cmd: '/new', help: 'clear conversation and start over' },
|
|
17
|
+
{ cmd: '/clear', help: 'alias for /new — clear conversation' },
|
|
17
18
|
{ cmd: '/reset', help: 'alias for /new' },
|
|
18
19
|
{ cmd: '/usage', help: 'show message count + chars sent so far' },
|
|
19
20
|
{ cmd: '/skills', help: 'list and activate skills (alias /skill)' },
|
|
20
21
|
{ cmd: '/skill', help: 'switch active skills: /skill review,style (no arg → clear)' },
|
|
21
22
|
{ cmd: '/tools', help: 'list available tool registry verbs' },
|
|
22
|
-
{ cmd: '/provider', help: '
|
|
23
|
-
{ cmd: '/model', help: '
|
|
24
|
-
{ cmd: '/trainer', help: '
|
|
25
|
-
{ cmd: '/personality', help: '
|
|
23
|
+
{ cmd: '/provider', help: 'pick provider from a list (or pass a name: /provider openai)' },
|
|
24
|
+
{ cmd: '/model', help: 'pick a model from a list (or pass a name: /model gpt-4.1)' },
|
|
25
|
+
{ cmd: '/trainer', help: 'view or set trainer provider/model: /trainer show|set <p:m>|clear' },
|
|
26
|
+
{ cmd: '/personality', help: 'pick a personality (or sub: list|show|install|remove|use)' },
|
|
27
|
+
{ cmd: '/dashboard', help: 'open the lazyclaw web UI in your browser' },
|
|
26
28
|
{ cmd: '/loop', help: 'repeat one prompt: /loop "fix lint" [--max N] [--until "<regex>"]' },
|
|
27
29
|
{ cmd: '/goal', help: 'register/switch goal: /goal add NAME | /goal list' },
|
|
28
30
|
{ cmd: '/memory', help: 'show layered memory: /memory [core|recent|episodic [topic]]' },
|
|
@@ -30,10 +32,8 @@ export const SLASH_COMMANDS = [
|
|
|
30
32
|
{ cmd: '/dream', help: 'consolidate recent memory into per-topic episodic files' },
|
|
31
33
|
{ cmd: '/agent', help: 'spawn or switch agent: /agent NAME' },
|
|
32
34
|
{ cmd: '/team', help: 'list, create, or join a team' },
|
|
33
|
-
{ cmd: '/task', help: '
|
|
35
|
+
{ cmd: '/task', help: 'list/show/transcript/abandon/done/remove (start/tick: CLI)' },
|
|
34
36
|
{ cmd: '/handoff', help: 'hand current task off to another agent' },
|
|
35
|
-
{ cmd: '/dashboard', help: 'open the lazyclaw web UI in your browser' },
|
|
36
|
-
{ cmd: '/clear', help: 'alias for /new — clear conversation' },
|
|
37
37
|
{ cmd: '/exit', help: 'leave the chat' },
|
|
38
38
|
{ cmd: '/quit', help: 'alias for /exit' },
|
|
39
39
|
];
|
package/tui/slash_dispatcher.mjs
CHANGED
|
@@ -73,14 +73,19 @@ async function _help() {
|
|
|
73
73
|
|
|
74
74
|
async function _status(_args, ctx) {
|
|
75
75
|
const registry = await _mod(ctx, 'registryMod', () => import('../providers/registry.mjs'));
|
|
76
|
-
const
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
76
|
+
const provider = ctx.getActiveProvName();
|
|
77
|
+
const model = ctx.getActiveModel() || '(default)';
|
|
78
|
+
const keyMasked = registry.maskApiKey(ctx.cfg && ctx.cfg['api-key']);
|
|
79
|
+
const messageCount = ctx.getMessages().length;
|
|
80
|
+
const sessionId = ctx.getSessionId() || '(none — in-memory)';
|
|
81
|
+
return [
|
|
82
|
+
'status:',
|
|
83
|
+
` provider: ${provider}`,
|
|
84
|
+
` model: ${model}`,
|
|
85
|
+
` api key: ${keyMasked}`,
|
|
86
|
+
` messages: ${messageCount}`,
|
|
87
|
+
` session: ${sessionId}`,
|
|
88
|
+
].join('\n');
|
|
84
89
|
}
|
|
85
90
|
|
|
86
91
|
async function _version(_args, ctx) {
|
|
@@ -91,22 +96,33 @@ async function _version(_args, ctx) {
|
|
|
91
96
|
async function _usage(_args, ctx) {
|
|
92
97
|
const msgs = ctx.getMessages();
|
|
93
98
|
const runningUsage = ctx.getRunningUsage && ctx.getRunningUsage();
|
|
94
|
-
const
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
}
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
99
|
+
const charsSent = (ctx.getCharsSent && ctx.getCharsSent()) || 0;
|
|
100
|
+
const lines = [
|
|
101
|
+
'usage:',
|
|
102
|
+
` messages: ${msgs.length}`,
|
|
103
|
+
` chars sent: ${charsSent.toLocaleString('en-US')}`,
|
|
104
|
+
];
|
|
105
|
+
if (runningUsage) {
|
|
106
|
+
lines.push(
|
|
107
|
+
` tokens in: ${(runningUsage.inputTokens || 0).toLocaleString('en-US')}`,
|
|
108
|
+
` tokens out: ${(runningUsage.outputTokens || 0).toLocaleString('en-US')}`,
|
|
109
|
+
` tokens tot: ${(runningUsage.totalTokens || 0).toLocaleString('en-US')}`,
|
|
110
|
+
` turns: ${runningUsage.turnsWithUsage || 0}`,
|
|
111
|
+
);
|
|
112
|
+
if (ctx.cfg && ctx.cfg.rates && typeof ctx.cfg.rates === 'object') {
|
|
113
|
+
try {
|
|
114
|
+
const { costFromUsage } = await import('../providers/rates.mjs');
|
|
115
|
+
const r = costFromUsage(
|
|
116
|
+
{ provider: ctx.getActiveProvName(), model: ctx.getActiveModel(), usage: runningUsage },
|
|
117
|
+
ctx.cfg.rates,
|
|
118
|
+
);
|
|
119
|
+
if (r && r.totalUsd != null) {
|
|
120
|
+
lines.push(` cost (USD): $${Number(r.totalUsd).toFixed(4)}`);
|
|
121
|
+
}
|
|
122
|
+
} catch { /* never let cost-card lookup fail the slash */ }
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
return lines.join('\n');
|
|
110
126
|
}
|
|
111
127
|
|
|
112
128
|
async function _newReset(_args, ctx) {
|
|
@@ -295,8 +311,15 @@ async function _memory(args, ctx) {
|
|
|
295
311
|
return body || '(empty core memory)';
|
|
296
312
|
}
|
|
297
313
|
if (which === 'recent') {
|
|
298
|
-
const items = mem.loadRecent(20, ctx.cfgDir);
|
|
299
|
-
|
|
314
|
+
const items = mem.loadRecent(20, ctx.cfgDir) || [];
|
|
315
|
+
if (!items.length) return '(no recent memory)';
|
|
316
|
+
return ['recent memory (last ' + items.length + '):',
|
|
317
|
+
...items.map((it, i) => {
|
|
318
|
+
const role = it.role || 'msg';
|
|
319
|
+
const content = String(it.content || '').replace(/\s+/g, ' ').slice(0, 80);
|
|
320
|
+
return ` ${String(i + 1).padStart(2)}. [${role}] ${content}${(it.content || '').length > 80 ? '…' : ''}`;
|
|
321
|
+
})
|
|
322
|
+
].join('\n');
|
|
300
323
|
}
|
|
301
324
|
if (which === 'episodic') {
|
|
302
325
|
const topic = tokens[1];
|
|
@@ -304,7 +327,11 @@ async function _memory(args, ctx) {
|
|
|
304
327
|
const body = mem.loadEpisodic(topic, ctx.cfgDir);
|
|
305
328
|
return body || `(no episodic file "${topic}")`;
|
|
306
329
|
}
|
|
307
|
-
|
|
330
|
+
const items = mem.listEpisodic(ctx.cfgDir) || [];
|
|
331
|
+
if (!items.length) return '(no episodic files yet — run /dream to consolidate)';
|
|
332
|
+
return ['episodic files:',
|
|
333
|
+
...items.map((it) => ` • ${typeof it === 'string' ? it : (it.topic || JSON.stringify(it))}`)
|
|
334
|
+
].join('\n');
|
|
308
335
|
}
|
|
309
336
|
return 'usage: /memory [core|recent|episodic [topic]]';
|
|
310
337
|
}
|
|
@@ -817,53 +844,172 @@ async function _trainer(args, ctx) {
|
|
|
817
844
|
return `/trainer: unknown sub "${sub}" — show|set <p:m>|clear`;
|
|
818
845
|
}
|
|
819
846
|
|
|
820
|
-
// /dashboard — open the lazyclaw web UI.
|
|
821
|
-
//
|
|
822
|
-
//
|
|
823
|
-
//
|
|
824
|
-
//
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
847
|
+
// /dashboard — open the lazyclaw web UI.
|
|
848
|
+
//
|
|
849
|
+
// v5.4.4 ROOT-CAUSE FIX (was: rapid repeated /dashboard within one chat
|
|
850
|
+
// session spawned 20+ daemon children).
|
|
851
|
+
//
|
|
852
|
+
// Original implementation:
|
|
853
|
+
// probe /healthz → if !200, spawn detached `lazyclaw dashboard
|
|
854
|
+
// --no-open` and poll for up to 3s.
|
|
855
|
+
//
|
|
856
|
+
// Failure mode that produced the 20+ spawn pile-up:
|
|
857
|
+
// 1. User types /dashboard. probe fails (no daemon). Spawn child A.
|
|
858
|
+
// 2. Child A begins binding port 19600. Takes ~500ms-2s to be ready.
|
|
859
|
+
// 3. User types /dashboard again BEFORE A is ready. probe still fails.
|
|
860
|
+
// Spawn child B. Child B sees EADDRINUSE and calls _killPortOccupant
|
|
861
|
+
// (cli.mjs:3611) which SIGTERMs child A. B takes over.
|
|
862
|
+
// 4. Repeat. Each /dashboard kills the previous daemon and starts a
|
|
863
|
+
// new one. With autorepeat / many slash calls this stacks fast.
|
|
864
|
+
//
|
|
865
|
+
// Two-layer guard:
|
|
866
|
+
// - A module-level _dashboardSpawning latch refuses concurrent spawn
|
|
867
|
+
// attempts. While a spawn is in flight, /dashboard says so + returns
|
|
868
|
+
// without firing another child.
|
|
869
|
+
// - A _dashboardChildPid cache remembers the PID we already spawned;
|
|
870
|
+
// subsequent calls check kill(pid, 0) to confirm the child is alive
|
|
871
|
+
// and just open the browser without spawning.
|
|
872
|
+
//
|
|
873
|
+
// We probe both /healthz (HTTP) AND a raw net.connect port check so a
|
|
874
|
+
// slow-starting daemon (binding the listener but not yet answering HTTP)
|
|
875
|
+
// still counts as "running".
|
|
876
|
+
let _dashboardSpawning = false;
|
|
877
|
+
let _dashboardChildPid = null;
|
|
878
|
+
|
|
879
|
+
function _portIsListening(port, timeoutMs = 200) {
|
|
880
|
+
return new Promise((resolve) => {
|
|
881
|
+
import('node:net').then(({ createConnection }) => {
|
|
882
|
+
let settled = false;
|
|
883
|
+
const sock = createConnection({ host: '127.0.0.1', port });
|
|
884
|
+
const done = (ok) => {
|
|
885
|
+
if (settled) return;
|
|
886
|
+
settled = true;
|
|
887
|
+
try { sock.destroy(); } catch {}
|
|
888
|
+
resolve(ok);
|
|
889
|
+
};
|
|
890
|
+
sock.once('connect', () => done(true));
|
|
891
|
+
sock.once('error', () => done(false));
|
|
892
|
+
setTimeout(() => done(false), timeoutMs);
|
|
893
|
+
}).catch(() => resolve(false));
|
|
894
|
+
});
|
|
895
|
+
}
|
|
896
|
+
|
|
897
|
+
async function _dashboardProbe(port) {
|
|
898
|
+
// Fast path — port-level probe. Catches a daemon that has bound the
|
|
899
|
+
// socket but hasn't finished initializing its HTTP routes.
|
|
900
|
+
if (await _portIsListening(port, 200)) return true;
|
|
901
|
+
// Slow path — full /healthz fetch, for defense in depth.
|
|
902
|
+
if (typeof fetch !== 'function') return false;
|
|
903
|
+
try {
|
|
904
|
+
const ac = new AbortController();
|
|
905
|
+
const t = setTimeout(() => ac.abort(), 250);
|
|
906
|
+
const r = await fetch(`http://127.0.0.1:${port}/healthz`, { signal: ac.signal });
|
|
907
|
+
clearTimeout(t);
|
|
908
|
+
return !!(r && r.ok);
|
|
909
|
+
} catch { return false; }
|
|
910
|
+
}
|
|
911
|
+
|
|
912
|
+
function _openBrowser(url) {
|
|
913
|
+
return import('node:child_process').then(({ spawn }) => {
|
|
831
914
|
let cmd, args;
|
|
832
|
-
if (process.platform === 'darwin') { cmd = 'open'; args = [
|
|
833
|
-
else if (process.platform === 'win32') { cmd = 'cmd'; args = ['/c', 'start', '""',
|
|
834
|
-
else { cmd = 'xdg-open'; args = [
|
|
915
|
+
if (process.platform === 'darwin') { cmd = 'open'; args = [url]; }
|
|
916
|
+
else if (process.platform === 'win32') { cmd = 'cmd'; args = ['/c', 'start', '""', url]; }
|
|
917
|
+
else { cmd = 'xdg-open'; args = [url]; }
|
|
835
918
|
try { spawn(cmd, args, { stdio: 'ignore', detached: true }).unref(); } catch { /* swallow */ }
|
|
836
|
-
};
|
|
837
|
-
|
|
838
|
-
|
|
919
|
+
});
|
|
920
|
+
}
|
|
921
|
+
|
|
922
|
+
async function _dashboardStop(port) {
|
|
923
|
+
// Best-effort kill of every lazyclaw dashboard daemon on the box.
|
|
924
|
+
// Used to clean up after the v5.4.3 spawn pile-up bug.
|
|
925
|
+
if (process.platform === 'win32') {
|
|
926
|
+
return 'dashboard stop: not implemented on Windows yet — kill via Task Manager';
|
|
927
|
+
}
|
|
928
|
+
const { spawn } = await import('node:child_process');
|
|
929
|
+
// Step 1: lsof the port and SIGTERM each PID.
|
|
930
|
+
const portPids = await new Promise((resolve) => {
|
|
839
931
|
try {
|
|
840
|
-
const
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
|
|
932
|
+
const lsof = spawn('lsof', ['-ti', `tcp:${port}`], { stdio: ['ignore', 'pipe', 'ignore'] });
|
|
933
|
+
let buf = '';
|
|
934
|
+
lsof.stdout.on('data', (d) => { buf += d.toString('utf8'); });
|
|
935
|
+
lsof.on('error', () => resolve([]));
|
|
936
|
+
lsof.on('close', () => resolve(
|
|
937
|
+
buf.trim().split(/\s+/).map((s) => parseInt(s, 10)).filter(Number.isFinite)
|
|
938
|
+
));
|
|
939
|
+
} catch { resolve([]); }
|
|
940
|
+
});
|
|
941
|
+
for (const pid of portPids) {
|
|
942
|
+
try { process.kill(pid, 'SIGTERM'); } catch { /* gone */ }
|
|
850
943
|
}
|
|
851
|
-
//
|
|
944
|
+
// Step 2: pkill any process whose command line includes "lazyclaw dashboard"
|
|
945
|
+
// — catches detached children that bound a different (random) port via
|
|
946
|
+
// cmdDashboard's EADDRINUSE fallback.
|
|
947
|
+
let pkilled = 0;
|
|
852
948
|
try {
|
|
853
|
-
const
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
}
|
|
858
|
-
|
|
949
|
+
const pkill = spawn('pkill', ['-f', 'lazyclaw dashboard'], { stdio: ['ignore', 'ignore', 'ignore'] });
|
|
950
|
+
pkilled = await new Promise((r) => pkill.on('close', (code) => r(code === 0 ? 1 : 0)));
|
|
951
|
+
} catch { /* fine */ }
|
|
952
|
+
_dashboardChildPid = null;
|
|
953
|
+
return `✓ stopped ${portPids.length} listener(s) on :${port}${pkilled ? ' + remaining `lazyclaw dashboard` processes via pkill' : ''}`;
|
|
954
|
+
}
|
|
955
|
+
|
|
956
|
+
async function _dashboard(args) {
|
|
957
|
+
const port = 19600;
|
|
958
|
+
const url = `http://127.0.0.1:${port}/dashboard`;
|
|
959
|
+
const sub = splitWhitespace(args)[0];
|
|
960
|
+
if (sub === 'stop' || sub === 'kill') return _dashboardStop(port);
|
|
961
|
+
|
|
962
|
+
// 1. Already running anywhere on the machine? → reuse.
|
|
963
|
+
if (await _dashboardProbe(port)) {
|
|
964
|
+
await _openBrowser(url);
|
|
965
|
+
return `✓ dashboard already running — opened ${url}`;
|
|
966
|
+
}
|
|
967
|
+
|
|
968
|
+
// 2. We spawned in this chat — is that child still alive?
|
|
969
|
+
if (_dashboardChildPid != null) {
|
|
970
|
+
try {
|
|
971
|
+
process.kill(_dashboardChildPid, 0); // signal 0 = liveness probe
|
|
972
|
+
// Child alive but not answering yet. Don't re-spawn; just nudge.
|
|
973
|
+
await _openBrowser(url);
|
|
974
|
+
return `✓ dashboard starting (pid ${_dashboardChildPid}) — opened ${url}`;
|
|
975
|
+
} catch {
|
|
976
|
+
_dashboardChildPid = null; // child died; fall through and respawn.
|
|
977
|
+
}
|
|
859
978
|
}
|
|
860
|
-
|
|
861
|
-
|
|
862
|
-
|
|
863
|
-
|
|
864
|
-
|
|
979
|
+
|
|
980
|
+
// 3. Spawn already in flight from a concurrent /dashboard? Don't pile on.
|
|
981
|
+
if (_dashboardSpawning) {
|
|
982
|
+
await _openBrowser(url);
|
|
983
|
+
return `dashboard is still booting — opened ${url}; try again in a moment if it didn't load`;
|
|
984
|
+
}
|
|
985
|
+
|
|
986
|
+
// 4. Cold start. Spawn ONE detached child, poll up to 3s, latch the
|
|
987
|
+
// spawn flag in a finally so it always clears.
|
|
988
|
+
_dashboardSpawning = true;
|
|
989
|
+
try {
|
|
990
|
+
const { spawn } = await import('node:child_process');
|
|
991
|
+
let child;
|
|
992
|
+
try {
|
|
993
|
+
child = spawn(process.execPath, [process.argv[1], 'dashboard', '--no-open'], {
|
|
994
|
+
detached: true, stdio: 'ignore', cwd: process.cwd(), env: process.env,
|
|
995
|
+
});
|
|
996
|
+
child.unref();
|
|
997
|
+
_dashboardChildPid = child.pid;
|
|
998
|
+
} catch (e) {
|
|
999
|
+
return `dashboard error: failed to spawn — ${e?.message || e}`;
|
|
1000
|
+
}
|
|
1001
|
+
const start = Date.now();
|
|
1002
|
+
while (Date.now() - start < 3000) {
|
|
1003
|
+
if (await _dashboardProbe(port)) {
|
|
1004
|
+
await _openBrowser(url);
|
|
1005
|
+
return `✓ started dashboard (pid ${child.pid}) — opened ${url}`;
|
|
1006
|
+
}
|
|
1007
|
+
await new Promise((r) => setTimeout(r, 150));
|
|
1008
|
+
}
|
|
1009
|
+
return `⚠ dashboard didn't come up within 3s (pid ${child.pid}). URL: ${url}`;
|
|
1010
|
+
} finally {
|
|
1011
|
+
_dashboardSpawning = false;
|
|
865
1012
|
}
|
|
866
|
-
return `⚠ dashboard didn't come up within 3s. Try \`lazyclaw dashboard\` from another terminal. URL: ${url}`;
|
|
867
1013
|
}
|
|
868
1014
|
|
|
869
1015
|
// ─── dispatch table ──────────────────────────────────────────────────────
|