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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lazyclaw",
3
- "version": "5.4.3",
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 */ }
@@ -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: 'switch provider: /provider openai (no arg print current)' },
23
- { cmd: '/model', help: 'switch model: /model gpt-4.1 or anthropic/claude-opus-4-7' },
24
- { cmd: '/trainer', help: 'configure trainer provider/model for learning loop' },
25
- { cmd: '/personality', help: 'switch agent personality preset' },
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: 'create, list, or manage tasks' },
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
  ];
@@ -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 out = {
77
- provider: ctx.getActiveProvName(),
78
- model: ctx.getActiveModel(),
79
- keyMasked: registry.maskApiKey(ctx.cfg && ctx.cfg['api-key']),
80
- messageCount: ctx.getMessages().length,
81
- sessionId: ctx.getSessionId() || null,
82
- };
83
- return JSON.stringify(out);
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 out = {
95
- messageCount: msgs.length,
96
- charsSent: (ctx.getCharsSent && ctx.getCharsSent()) || 0,
97
- };
98
- if (runningUsage) out.tokens = runningUsage;
99
- if (runningUsage && ctx.cfg && ctx.cfg.rates && typeof ctx.cfg.rates === 'object') {
100
- try {
101
- const { costFromUsage } = await import('../providers/rates.mjs');
102
- const r = costFromUsage(
103
- { provider: ctx.getActiveProvName(), model: ctx.getActiveModel(), usage: runningUsage },
104
- ctx.cfg.rates,
105
- );
106
- if (r) out.cost = r;
107
- } catch { /* never let cost-card lookup fail the slash */ }
108
- }
109
- return JSON.stringify(out);
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
- return JSON.stringify(items, null, 2);
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
- return JSON.stringify(mem.listEpisodic(ctx.cfgDir), null, 2);
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. Reuses an already-running
821
- // daemon if one answers /healthz on 19600; otherwise spawns a detached
822
- // `node cli.mjs dashboard --no-open` child so the daemon outlives this
823
- // chat session. Never installs signal handlers / never process.exits —
824
- // Ctrl-C inside chat must NOT touch the dashboard.
825
- async function _dashboard(_args) {
826
- const port = 19600;
827
- const url = `http://127.0.0.1:${port}/dashboard`;
828
- const healthUrl = `http://127.0.0.1:${port}/healthz`;
829
- const { spawn } = await import('node:child_process');
830
- const openBrowser = (u) => {
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 = [u]; }
833
- else if (process.platform === 'win32') { cmd = 'cmd'; args = ['/c', 'start', '""', u]; }
834
- else { cmd = 'xdg-open'; args = [u]; }
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
- const probe = async () => {
838
- if (typeof fetch !== 'function') return false;
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 ac = new AbortController();
841
- const t = setTimeout(() => ac.abort(), 300);
842
- const r = await fetch(healthUrl, { signal: ac.signal });
843
- clearTimeout(t);
844
- return !!(r && r.ok);
845
- } catch { return false; }
846
- };
847
- if (await probe()) {
848
- openBrowser(url);
849
- return `✓ dashboard already running — opened ${url}`;
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
- // Spawn detached so the daemon outlives this chat process.
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 child = spawn(process.execPath, [process.argv[1], 'dashboard', '--no-open'], {
854
- detached: true, stdio: 'ignore', cwd: process.cwd(), env: process.env,
855
- });
856
- child.unref();
857
- } catch (e) {
858
- return `dashboard error: failed to spawn — ${e?.message || e}`;
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
- // Poll /healthz up to ~3s before opening the browser.
861
- const start = Date.now();
862
- while (Date.now() - start < 3000) {
863
- if (await probe()) { openBrowser(url); return `✓ started dashboard — opened ${url}`; }
864
- await new Promise((r) => setTimeout(r, 150));
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 ──────────────────────────────────────────────────────