lazyclaw 3.99.6 → 3.99.8

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 (3) hide show
  1. package/README.md +1 -1
  2. package/cli.mjs +385 -154
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -71,7 +71,7 @@ What you see on launch (TTY only):
71
71
  │ | |__ _ _____ _ _ │
72
72
  │ | / _` |_ / || | '_| │
73
73
  │ |_\__,_/__\_, |_| │
74
- │ LazyClaw |__/ 3.99.6
74
+ │ LazyClaw |__/ 3.99.8
75
75
  ╰──────────────────────────────╯
76
76
 
77
77
  provider · anthropic
package/cli.mjs CHANGED
@@ -1318,14 +1318,18 @@ async function cmdAgent(prompt, flags) {
1318
1318
  // (replaces rl.line with the full command). Tab still goes through
1319
1319
  // readline's tab-completer for cycling.
1320
1320
  function _attachGhostAutocomplete(rl) {
1321
- // Returns a `dispose()` callback that detaches the keypress listener
1322
- // and the rl 'line' listener installed below. Without disposal the
1323
- // process never exits Node keeps the event loop alive while
1324
- // process.stdin has a 'keypress' listener attached. (This was the
1325
- // root cause of the slow `/exit` users reported.)
1326
- if (!process.stdout.isTTY) return () => {};
1321
+ // Returns `{ dispose, suspend, resume }`. Dispose detaches the
1322
+ // keypress + rl 'line' listeners (failure to do so leaks the
1323
+ // event-loop ref, which is exactly the slow-exit bug v3.92
1324
+ // fixed). Suspend / resume gate the keypress handler so the
1325
+ // streaming chat output isn't interleaved with `\x1b[s\x1b[K\x1b[u`
1326
+ // ghost-render escapes that interleaving is what surfaces as
1327
+ // visible gaps between Korean characters in long replies.
1328
+ const noop = () => {};
1329
+ if (!process.stdout.isTTY) return { dispose: noop, suspend: noop, resume: noop };
1327
1330
  const cmds = SLASH_COMMANDS.map((c) => c.cmd);
1328
1331
  let lastGhost = '';
1332
+ let suspended = false;
1329
1333
  // Find the longest match for the current input. Returns '' when
1330
1334
  // nothing matches or when the input already equals a command.
1331
1335
  const findMatch = () => {
@@ -1362,6 +1366,10 @@ function _attachGhostAutocomplete(rl) {
1362
1366
  // _refreshLine, then return without forwarding the keypress.
1363
1367
  const onKeypress = (_str, key) => {
1364
1368
  if (!key) return;
1369
+ // While a streaming response is being printed, do nothing —
1370
+ // any ANSI cursor save / restore we emit would tear the wide-
1371
+ // character (CJK) output apart on the visible terminal.
1372
+ if (suspended) return;
1365
1373
  if (key.name === 'right' && lastGhost && rl.line === rl.line.trim() &&
1366
1374
  rl.cursor === (rl.line || '').length && (rl.line || '').length < lastGhost.length) {
1367
1375
  const accepted = lastGhost;
@@ -1387,13 +1395,23 @@ function _attachGhostAutocomplete(rl) {
1387
1395
  // over between turns.
1388
1396
  const onLine = () => { lastGhost = ''; };
1389
1397
  rl.on('line', onLine);
1390
- return () => {
1398
+ const dispose = () => {
1391
1399
  try { process.stdin.removeListener('keypress', onKeypress); } catch (_) {}
1392
1400
  try { rl.removeListener('line', onLine); } catch (_) {}
1393
1401
  // Wipe any leftover ghost on screen so the user's terminal doesn't
1394
1402
  // keep a dim suffix after we exit.
1395
1403
  try { process.stdout.write('\x1b[s\x1b[K\x1b[u'); } catch (_) {}
1396
1404
  };
1405
+ return {
1406
+ dispose,
1407
+ suspend: () => {
1408
+ suspended = true;
1409
+ // Wipe any half-rendered ghost before streaming starts so the
1410
+ // first chunk lands at the same column as the prompt.
1411
+ try { process.stdout.write('\x1b[s\x1b[K\x1b[u'); } catch (_) {}
1412
+ },
1413
+ resume: () => { suspended = false; },
1414
+ };
1397
1415
  }
1398
1416
 
1399
1417
  // LazyClaw banner — printed once at the top of every interactive chat
@@ -1466,31 +1484,25 @@ function _printChatBanner(activeProvName, activeModel, version) {
1466
1484
  // when the user passes --pick. Falls back to plain stdin reads when
1467
1485
  // stdout isn't a TTY (CI/script callers should pass --non-interactive
1468
1486
  // equivalents instead).
1469
- async function _pickProviderInteractive() {
1470
- const providers = Object.keys(_registryMod.PROVIDERS);
1471
- if (!providers.length) return { provider: 'mock', model: null };
1472
- const info = _registryMod.PROVIDER_INFO || {};
1473
- // Build one row per (provider, model) pair using the static
1474
- // PROVIDER_INFO.suggestedModels list (registry.mjs is the single
1475
- // source of truth). When a provider has no models (mock), we emit
1476
- // one row for the provider itself.
1477
- const items = [];
1478
- for (const name of providers) {
1479
- const meta = info[name] || {};
1480
- const models = (Array.isArray(meta.suggestedModels) && meta.suggestedModels.length)
1481
- ? meta.suggestedModels
1482
- : [null];
1483
- const keyTag = meta.requiresApiKey
1484
- ? '\x1b[38;5;245m[api key]\x1b[0m'
1485
- : (name === 'claude-cli' ? '\x1b[38;5;208m[subscription]\x1b[0m' : '\x1b[38;5;245m[no key]\x1b[0m');
1486
- for (const m of models) {
1487
- const label = m ? `${name.padEnd(11)} ${m}` : `${name.padEnd(11)} (no model)`;
1488
- items.push({ provider: name, model: m, label, keyTag });
1489
- }
1490
- }
1487
+ // Generic arrow-key menu used by the multi-step provider/model
1488
+ // picker below. Returns the picked item, or one of the sentinel
1489
+ // strings 'BACK' (Esc caller should retry the previous step) or
1490
+ // 'CANCEL' (q — caller should bail entirely). Ctrl-C exits the
1491
+ // process directly, matching every other interactive prompt in the
1492
+ // CLI.
1493
+ //
1494
+ // `items` is an array of { id, label, desc, tag }. `tag` is an
1495
+ // optional pre-coloured pill (e.g. "[api key]") that lands on the
1496
+ // right side of the row. `defaultIdx` lets the caller pin where the
1497
+ // cursor lands; default 0.
1498
+ async function _arrowMenu({ title, subtitle, footer, items, defaultIdx = 0 }) {
1491
1499
  if (!process.stdout.isTTY || !process.stdin.isTTY) {
1492
- // Fall back to a single prompt the picker UI is purely cosmetic.
1493
- process.stdout.write(`provider [${providers.join('|')}]: `);
1500
+ // Non-TTY fallback: print the labels on stderr and read a single
1501
+ // line of stdin. Used when somebody pipes input to `lazyclaw
1502
+ // setup` — the wizard still works, just without arrows.
1503
+ process.stderr.write(`${title}\n`);
1504
+ items.forEach((it, i) => process.stderr.write(` ${i + 1}. ${it.label}${it.desc ? ' — ' + it.desc : ''}\n`));
1505
+ process.stderr.write('pick (number / id, blank for first): ');
1494
1506
  const ans = await new Promise((resolve) => {
1495
1507
  let buf = '';
1496
1508
  const onData = (chunk) => {
@@ -1499,38 +1511,47 @@ async function _pickProviderInteractive() {
1499
1511
  };
1500
1512
  process.stdin.on('data', onData);
1501
1513
  });
1502
- return { provider: ans || providers[0], model: null };
1514
+ if (!ans) return items[0];
1515
+ const byNum = parseInt(ans, 10);
1516
+ if (Number.isFinite(byNum) && byNum >= 1 && byNum <= items.length) return items[byNum - 1];
1517
+ const byId = items.find((it) => it.id === ans || it.label === ans);
1518
+ return byId || items[0];
1503
1519
  }
1504
- // Default cursor: lands on item 0 (= the first row from PROVIDERS
1505
- // insertion order, which registry.mjs deliberately curates as the
1506
- // most user-familiar vendor — gemini at the time of writing).
1507
- let idx = 0;
1508
1520
 
1509
1521
  const readline = await import('node:readline');
1510
1522
  readline.emitKeypressEvents(process.stdin);
1511
1523
  if (process.stdin.setRawMode) process.stdin.setRawMode(true);
1512
- // Long lists need scrolling so the picker fits the terminal height.
1513
- // Reserve 6 rows for header + footer + breathing room.
1524
+ let idx = Math.max(0, Math.min(items.length - 1, defaultIdx));
1525
+ const accent = (s) => `\x1b[38;5;208m${s}\x1b[0m`;
1526
+ const dim = (s) => `\x1b[2m${s}\x1b[0m`;
1527
+ const bold = (s) => `\x1b[1m${s}\x1b[0m`;
1528
+
1514
1529
  const draw = () => {
1515
- process.stdout.write('\x1b[?25l'); // hide cursor
1516
- process.stdout.write('\x1b[2J\x1b[H'); // clear screen
1517
- process.stdout.write('\x1b[38;5;208mLazyClaw pick a provider/model\x1b[0m\n');
1518
- process.stdout.write('\x1b[2m↑/↓ to move · Enter to confirm · q to quit\x1b[0m\n');
1519
- process.stdout.write('\x1b[2m[subscription] = uses `claude` login (no key) · [api key] = needs sk-... key · [no key] = local\x1b[0m\n\n');
1520
- const rows = Math.max(6, (process.stdout.rows || 24) - 6);
1530
+ process.stdout.write('\x1b[?25l\x1b[2J\x1b[H');
1531
+ process.stdout.write(accent(title) + '\n');
1532
+ if (subtitle) process.stdout.write(dim(subtitle) + '\n');
1533
+ process.stdout.write(dim('↑/↓ to move · Enter to confirm · Esc to back · q to quit') + '\n\n');
1534
+ const rows = Math.max(6, (process.stdout.rows || 24) - 8);
1521
1535
  let from = Math.max(0, idx - Math.floor(rows / 2));
1522
1536
  if (from + rows > items.length) from = Math.max(0, items.length - rows);
1523
1537
  const to = Math.min(items.length, from + rows);
1538
+ // Pre-compute label width so descriptions line up across rows.
1539
+ const labelW = items.reduce((w, it) => Math.max(w, (it.label || '').length), 12);
1524
1540
  for (let i = from; i < to; i++) {
1525
1541
  const it = items[i];
1526
- const marker = i === idx ? '\x1b[38;5;208m❯\x1b[0m ' : ' ';
1527
- const text = i === idx ? `\x1b[1m${it.label}\x1b[0m` : it.label;
1528
- process.stdout.write(`${marker}${text} ${it.keyTag}\n`);
1542
+ const marker = i === idx ? accent(' ') : ' ';
1543
+ const lbl = (it.label || '').padEnd(labelW);
1544
+ const lblOut = i === idx ? bold(lbl) : lbl;
1545
+ const desc = it.desc ? ' ' + dim(it.desc) : '';
1546
+ const tag = it.tag ? ' ' + it.tag : '';
1547
+ process.stdout.write(`${marker}${lblOut}${desc}${tag}\n`);
1529
1548
  }
1530
1549
  if (to < items.length) {
1531
- process.stdout.write(`\x1b[2m …(${items.length - to} more)\x1b[0m\n`);
1550
+ process.stdout.write(`${dim(` …(${items.length - to} more)`)}\n`);
1532
1551
  }
1552
+ if (footer) process.stdout.write('\n' + dim(footer) + '\n');
1533
1553
  };
1554
+
1534
1555
  draw();
1535
1556
  return await new Promise((resolve) => {
1536
1557
  const onKey = (_str, key) => {
@@ -1543,18 +1564,145 @@ async function _pickProviderInteractive() {
1543
1564
  else if (key.name === 'end') { idx = items.length - 1; draw(); }
1544
1565
  else if (key.name === 'return') { cleanup(); resolve(items[idx]); }
1545
1566
  else if (key.ctrl && key.name === 'c') { cleanup(); process.exit(130); }
1546
- else if (key.name === 'q' || key.name === 'escape') { cleanup(); resolve(items[idx]); }
1567
+ else if (key.name === 'escape') { cleanup(); resolve('BACK'); }
1568
+ else if (key.name === 'q') { cleanup(); resolve('CANCEL'); }
1547
1569
  };
1548
1570
  const cleanup = () => {
1549
1571
  process.stdin.off('keypress', onKey);
1550
1572
  if (process.stdin.setRawMode) process.stdin.setRawMode(false);
1551
- process.stdout.write('\x1b[?25h'); // show cursor
1552
- process.stdout.write('\x1b[2J\x1b[H'); // clear screen
1573
+ process.stdout.write('\x1b[?25h\x1b[2J\x1b[H');
1553
1574
  };
1554
1575
  process.stdin.on('keypress', onKey);
1555
1576
  });
1556
1577
  }
1557
1578
 
1579
+ // Bucket every registered provider into one of three auth-method
1580
+ // families. The picker's first step asks the user which family
1581
+ // they want before drilling into specific providers — much less
1582
+ // overwhelming than a flat 40-row list. Bucket assignment lives
1583
+ // here (rather than registry.mjs) because it's a UX concept, not
1584
+ // an intrinsic provider attribute.
1585
+ function _providerFamilies() {
1586
+ const info = _registryMod.PROVIDER_INFO || {};
1587
+ const all = Object.keys(_registryMod.PROVIDERS);
1588
+ const buckets = {
1589
+ api: { label: 'API key', desc: 'paste an sk-... key during setup', tag: '\x1b[38;5;245m[needs key]\x1b[0m', members: [] },
1590
+ cli: { label: 'CLI / Local', desc: 'keyless — uses an existing CLI login or a local daemon', tag: '\x1b[38;5;208m[no key]\x1b[0m', members: [] },
1591
+ mock: { label: 'Mock', desc: 'offline echo, only useful for testing', tag: '\x1b[38;5;245m[test]\x1b[0m', members: [] },
1592
+ };
1593
+ for (const name of all) {
1594
+ if (name === 'mock') buckets.mock.members.push(name);
1595
+ else if ((info[name] || {}).requiresApiKey) buckets.api.members.push(name);
1596
+ else buckets.cli.members.push(name);
1597
+ }
1598
+ return buckets;
1599
+ }
1600
+
1601
+ // Multi-step provider / model picker — replaces the flat 40-row
1602
+ // list of v3.99.5 with a drill-in:
1603
+ //
1604
+ // Step 1 — auth family (API key / CLI-Local / Mock)
1605
+ // Step 2 — provider in that family (gemini / openai / claude-cli / …)
1606
+ // Step 3 — model in that provider's suggestedModels
1607
+ //
1608
+ // Esc at any step goes back one. q or Ctrl-C cancels entirely.
1609
+ // Steps that have only one option auto-advance so the user doesn't
1610
+ // stare at a single-row menu (e.g. the Mock family has just `mock`).
1611
+ async function _pickProviderInteractive() {
1612
+ const providers = Object.keys(_registryMod.PROVIDERS);
1613
+ if (!providers.length) return { provider: 'mock', model: null };
1614
+ const info = _registryMod.PROVIDER_INFO || {};
1615
+ const families = _providerFamilies();
1616
+
1617
+ // Non-TTY fallback — single-prompt picker, identical to before.
1618
+ if (!process.stdout.isTTY || !process.stdin.isTTY) {
1619
+ process.stdout.write(`provider [${providers.join('|')}]: `);
1620
+ const ans = await new Promise((resolve) => {
1621
+ let buf = '';
1622
+ const onData = (chunk) => {
1623
+ buf += chunk.toString();
1624
+ if (buf.includes('\n')) { process.stdin.off('data', onData); resolve(buf.trim()); }
1625
+ };
1626
+ process.stdin.on('data', onData);
1627
+ });
1628
+ return { provider: ans || providers[0], model: null };
1629
+ }
1630
+
1631
+ // ── Step 1 — auth family ──────────────────────────────────────
1632
+ let family = null;
1633
+ while (!family) {
1634
+ const familyItems = Object.entries(families)
1635
+ .filter(([, b]) => b.members.length > 0)
1636
+ .map(([id, b]) => ({
1637
+ id,
1638
+ label: b.label,
1639
+ desc: `${b.desc} · ${b.members.join(' / ')}`,
1640
+ tag: b.tag,
1641
+ }));
1642
+ const picked = await _arrowMenu({
1643
+ title: 'LazyClaw setup — Step 1 of 3: pick how you want to auth',
1644
+ subtitle: 'API: bring your own key · CLI/Local: use what\'s already on this machine · Mock: offline test',
1645
+ items: familyItems,
1646
+ });
1647
+ if (picked === 'CANCEL' || picked === 'BACK') return null;
1648
+ family = picked;
1649
+ }
1650
+
1651
+ // ── Step 2 — provider in that family ──────────────────────────
1652
+ let provider = null;
1653
+ while (!provider) {
1654
+ const memberNames = families[family.id].members;
1655
+ if (memberNames.length === 1) {
1656
+ // Auto-advance — no point making the user pick from a single
1657
+ // row.
1658
+ provider = { id: memberNames[0] };
1659
+ break;
1660
+ }
1661
+ const provItems = memberNames.map((name) => {
1662
+ const meta = info[name] || {};
1663
+ const models = (meta.suggestedModels || []).slice(0, 4).join(' · ') || '(default)';
1664
+ return {
1665
+ id: name,
1666
+ label: name,
1667
+ desc: `models: ${models}`,
1668
+ tag: meta.requiresApiKey ? '\x1b[38;5;245m[api key]\x1b[0m' : '\x1b[38;5;208m[no key]\x1b[0m',
1669
+ };
1670
+ });
1671
+ const picked = await _arrowMenu({
1672
+ title: `LazyClaw setup — Step 2 of 3: pick a ${family.label} provider`,
1673
+ subtitle: `Showing ${memberNames.length} ${family.label.toLowerCase()} provider(s).`,
1674
+ items: provItems,
1675
+ });
1676
+ if (picked === 'CANCEL') return null;
1677
+ if (picked === 'BACK') { family = null; return _pickProviderInteractive(); }
1678
+ provider = picked;
1679
+ }
1680
+
1681
+ // ── Step 3 — model ────────────────────────────────────────────
1682
+ const meta = info[provider.id] || {};
1683
+ const models = Array.isArray(meta.suggestedModels) ? meta.suggestedModels : [];
1684
+ if (!models.length) {
1685
+ // Provider has no curated models (mock) — return without a
1686
+ // model so the underlying call uses the provider default.
1687
+ return { provider: provider.id, model: null };
1688
+ }
1689
+ while (true) {
1690
+ const modelItems = models.map((m) => ({ id: m, label: m, desc: '' }));
1691
+ // Pin the cursor to the provider's defaultModel so Enter without
1692
+ // navigation picks the most-recommended one.
1693
+ const defaultIdx = Math.max(0, models.indexOf(meta.defaultModel || models[0]));
1694
+ const picked = await _arrowMenu({
1695
+ title: `LazyClaw setup — Step 3 of 3: pick a model for ${provider.id}`,
1696
+ subtitle: `Showing ${models.length} suggested model(s). Type the model id directly later via /model in chat to use anything not listed here.`,
1697
+ items: modelItems,
1698
+ defaultIdx,
1699
+ });
1700
+ if (picked === 'CANCEL') return null;
1701
+ if (picked === 'BACK') return _pickProviderInteractive(); // back to step 1
1702
+ return { provider: provider.id, model: picked.id };
1703
+ }
1704
+ }
1705
+
1558
1706
  async function cmdChat(flags = {}) {
1559
1707
  await ensureRegistry();
1560
1708
  const sessionsMod = await import('./sessions.mjs');
@@ -1597,13 +1745,13 @@ async function cmdChat(flags = {}) {
1597
1745
  terminal: useTerminal,
1598
1746
  prompt: useTerminal ? '\x1b[38;5;208m›\x1b[0m ' : '',
1599
1747
  });
1600
- let _disposeGhost = () => {};
1748
+ let _ghost = { dispose: () => {}, suspend: () => {}, resume: () => {} };
1601
1749
  if (useTerminal) {
1602
1750
  // Cursor-style ghost autocomplete: when the buffer starts with `/`,
1603
1751
  // render the longest matching command after the cursor in dim grey.
1604
1752
  // Right-arrow at end-of-line accepts. Tab still cycles via the
1605
1753
  // existing handleSlash branch; this only adds the inline preview.
1606
- _disposeGhost = _attachGhostAutocomplete(rl) || (() => {});
1754
+ _ghost = _attachGhostAutocomplete(rl) || _ghost;
1607
1755
  rl.prompt();
1608
1756
  }
1609
1757
 
@@ -1855,6 +2003,32 @@ async function cmdChat(flags = {}) {
1855
2003
  process.stdout.write('\n^C interrupted — prompt is back\n');
1856
2004
  };
1857
2005
  process.on('SIGINT', onSigint);
2006
+ // Pause the ghost-autocomplete keypress handler while the
2007
+ // provider is streaming. Without this, every stale stdin event
2008
+ // would trigger `\x1b[s\x1b[K\x1b[u` cursor save/restore writes
2009
+ // that interleave with the streamed text and surface as visible
2010
+ // gaps between CJK characters (visible in user-reported screen
2011
+ // captures of Korean replies).
2012
+ if (useTerminal) _ghost.suspend();
2013
+ // Buffered writer — coalesce single-character streaming chunks
2014
+ // into ~30 ms windows. Two reasons:
2015
+ // 1. Korean / Japanese / Chinese tokens often arrive as one
2016
+ // character per chunk. Each individual `process.stdout.write`
2017
+ // can race against terminal redraw on a wide-cell character,
2018
+ // producing the same "visible space between every character"
2019
+ // symptom the suspend above also addresses.
2020
+ // 2. Far fewer syscalls. A 200-char Korean reply was ~200
2021
+ // separate writes; this collapses to ~7-10.
2022
+ let _writeBuf = '';
2023
+ let _writeTimer = null;
2024
+ const _flush = () => {
2025
+ if (_writeBuf) { process.stdout.write(_writeBuf); _writeBuf = ''; }
2026
+ _writeTimer = null;
2027
+ };
2028
+ const _writeChunk = (s) => {
2029
+ _writeBuf += s;
2030
+ if (!_writeTimer) _writeTimer = setTimeout(_flush, 30);
2031
+ };
1858
2032
  try {
1859
2033
  for await (const chunk of prov.sendMessage(messages, {
1860
2034
  apiKey: _resolveAuthKey(cfg, activeProvName),
@@ -1863,13 +2037,21 @@ async function cmdChat(flags = {}) {
1863
2037
  signal: turnAc.signal,
1864
2038
  onUsage: accumulateUsage,
1865
2039
  })) {
1866
- process.stdout.write(chunk);
2040
+ _writeChunk(chunk);
1867
2041
  acc += chunk;
1868
2042
  }
2043
+ // Drain anything still buffered before the trailing newline so
2044
+ // the prompt lands on its own line cleanly.
2045
+ if (_writeTimer) clearTimeout(_writeTimer);
2046
+ _flush();
1869
2047
  process.stdout.write('\n');
1870
2048
  messages.push({ role: 'assistant', content: acc });
1871
2049
  persistTurn('assistant', acc);
1872
2050
  } catch (err) {
2051
+ // Drain pending buffer so partial reply stays on screen even
2052
+ // when the stream errors mid-flight.
2053
+ if (_writeTimer) clearTimeout(_writeTimer);
2054
+ _flush();
1873
2055
  // ABORT errors are user-initiated; partial assistant output is
1874
2056
  // discarded (we don't append a half-reply to the message history
1875
2057
  // because the next turn would treat it as a complete reply and
@@ -1879,6 +2061,7 @@ async function cmdChat(flags = {}) {
1879
2061
  }
1880
2062
  } finally {
1881
2063
  process.off('SIGINT', onSigint);
2064
+ if (useTerminal) _ghost.resume();
1882
2065
  }
1883
2066
  if (useTerminal) rl.prompt();
1884
2067
  } } finally {
@@ -1886,7 +2069,7 @@ async function cmdChat(flags = {}) {
1886
2069
  // hung for ~3-5 s while Node waited for stdin's keypress listener
1887
2070
  // and raw mode to release. Tearing them down explicitly drops the
1888
2071
  // exit time to <100 ms.
1889
- try { _disposeGhost(); } catch (_) {}
2072
+ try { _ghost.dispose(); } catch (_) {}
1890
2073
  try { rl.close(); } catch (_) {}
1891
2074
  if (useTerminal && process.stdin.isTTY && process.stdin.setRawMode) {
1892
2075
  try { process.stdin.setRawMode(false); } catch (_) {}
@@ -3360,32 +3543,46 @@ async function _runFirstTimeOnboard() {
3360
3543
  process.stdout.write('\n');
3361
3544
  }
3362
3545
 
3546
+ // Direct dispatch from a launcher pick. Replaces the previous
3547
+ // `process.argv = [...]; await main()` round-trip so we can reuse
3548
+ // the launcher across multiple iterations without compounding
3549
+ // state. Each menu choice maps to its native cmd handler with the
3550
+ // same flag defaults the bare CLI would parse.
3551
+ async function _dispatchMenuChoice(argv) {
3552
+ const sub = argv[0];
3553
+ const rest = argv.slice(1);
3554
+ switch (sub) {
3555
+ case 'chat': return cmdChat({});
3556
+ case 'agent': return cmdAgent(rest[0] || '-', {});
3557
+ case 'onboard': return cmdOnboard({});
3558
+ case 'setup': return cmdSetup(undefined, rest, {});
3559
+ case 'workspace': return cmdWorkspace(rest[0], rest.slice(1), {});
3560
+ case 'browse': return cmdBrowse(rest[0], {});
3561
+ case 'skills': return cmdSkills(rest[0], rest.slice(1), {});
3562
+ case 'sessions': return cmdSessions(rest[0], rest.slice(1), {});
3563
+ case 'providers': return cmdProviders(rest[0], rest.slice(1), {});
3564
+ case 'cron': return cmdCron(rest[0], rest.slice(1), {});
3565
+ case 'auth': return cmdAuth(rest[0], rest.slice(1), {});
3566
+ case 'pairing': return cmdPairing(rest[0], rest.slice(1), {});
3567
+ case 'nodes': return cmdNodes(rest[0], rest.slice(1), {});
3568
+ case 'message': return cmdMessage(rest[0], rest.slice(1), {});
3569
+ case 'doctor': return cmdDoctor();
3570
+ case 'status': return cmdStatus();
3571
+ case 'help': return cmdHelp();
3572
+ case 'dashboard': return cmdDashboard({});
3573
+ default: throw new Error(`unknown menu choice: ${sub}`);
3574
+ }
3575
+ }
3576
+
3363
3577
  async function cmdLauncher() {
3364
3578
  await ensureRegistry();
3365
- let cfg = readConfig();
3366
- // First-run guard: a fresh install has no `provider` set, so any
3367
- // menu pick that calls a provider (Chat / Agent / Doctor / etc.)
3368
- // would error halfway through with a confusing "missing api key"
3369
- // or "unknown provider". Detect that state up front and walk the
3370
- // user through onboard before showing the menu — once they've
3371
- // picked, re-read the config and continue normally.
3372
- if (!cfg.provider) {
3373
- // Delegate to the full setup wizard rather than the bare onboard
3374
- // picker — first-time users benefit from the workspace / skill /
3375
- // ping steps too. cmdSetup exits the process on abort, so the
3376
- // re-read below only fires when the wizard completed successfully.
3377
- await cmdSetup(undefined, [], {});
3378
- cfg = readConfig();
3379
- if (!cfg.provider) {
3380
- process.stdout.write('\n Setup not completed — exiting.\n Run `lazyclaw setup` when ready, then try `lazyclaw` again.\n\n');
3381
- process.exit(0);
3382
- }
3383
- }
3384
- const provider = cfg.provider;
3385
- const model = cfg.model || '(default)';
3579
+ // Item table is fixed across iterations — only the dispatcher and
3580
+ // the per-iteration draw redraw on each loop tick.
3386
3581
  const items = [
3387
3582
  { id: 'chat', label: 'Chat', desc: 'interactive REPL with the configured provider', argv: ['chat'] },
3388
3583
  { id: 'agent', label: 'Agent', desc: 'one-shot prompt — read text and exit', argv: ['agent'], promptForBody: true },
3584
+ { id: 'dashboard', label: 'Dashboard', desc: 'open the lazyclaw web UI in your browser', argv: ['dashboard'] },
3585
+ { id: 'setup', label: 'Setup', desc: 'multi-step provider / workspace / skill wizard', argv: ['setup'] },
3389
3586
  { id: 'onboard', label: 'Onboard', desc: 'pick provider / model / api-key', argv: ['onboard'] },
3390
3587
  { id: 'workspace', label: 'Workspace', desc: 'AGENTS.md / SOUL.md / TOOLS.md prompt bundles', argv: ['workspace', 'list'] },
3391
3588
  { id: 'browse', label: 'Browse', desc: 'fetch a URL → markdown', argv: ['browse'], promptForUrl: true },
@@ -3396,97 +3593,131 @@ async function cmdLauncher() {
3396
3593
  { id: 'doctor', label: 'Doctor', desc: 'diagnostic — config, providers, workflows', argv: ['doctor'] },
3397
3594
  { id: 'status', label: 'Status', desc: 'current provider / model / masked key', argv: ['status'] },
3398
3595
  { id: 'help', label: 'Help', desc: 'one-line summary of every subcommand', argv: ['help'] },
3399
- { id: 'quit', label: 'Quit', desc: 'exit without doing anything', argv: null },
3596
+ { id: 'quit', label: 'Quit', desc: 'exit lazyclaw', argv: null },
3400
3597
  ];
3401
3598
 
3402
- const readline = await import('node:readline');
3403
- readline.emitKeypressEvents(process.stdin);
3404
- if (process.stdin.setRawMode) process.stdin.setRawMode(true);
3405
- let idx = 0;
3406
-
3407
- // Pretty header — same accent palette as _printChatBanner so
3408
- // returning users recognise it.
3409
3599
  const accent = (s) => `\x1b[38;5;208m${s}\x1b[0m`;
3410
3600
  const dim = (s) => `\x1b[2m${s}\x1b[0m`;
3411
3601
  const bold = (s) => `\x1b[1m${s}\x1b[0m`;
3412
3602
  const ok = (s) => `\x1b[32m${s}\x1b[0m`;
3413
3603
  const warn = (s) => `\x1b[33m${s}\x1b[0m`;
3414
3604
 
3415
- const draw = () => {
3416
- process.stdout.write('\x1b[?25l\x1b[2J\x1b[H'); // hide cursor + clear
3417
- _renderBanner(readVersionFromRepo()).forEach((l) => process.stdout.write(l + '\n'));
3418
- process.stdout.write('\n');
3419
- const provDisplay = provider === '(unset pick during onboard)'
3420
- ? warn(provider)
3421
- : ok(provider);
3422
- process.stdout.write(` ${dim('provider ·')} ${provDisplay}\n`);
3423
- process.stdout.write(` ${dim('model ·')} ${ok(model)}\n`);
3424
- process.stdout.write(` ${dim('config ·')} ${dim(configPath())}\n`);
3425
- process.stdout.write('\n');
3426
- process.stdout.write(` ${dim('↑/↓ to move · Enter to select · q or Esc to quit')}\n\n`);
3427
-
3428
- // Trim list to terminal height so the menu still fits when
3429
- // someone shrinks the window or runs in a small split pane.
3430
- const rowsAvail = Math.max(items.length, (process.stdout.rows || 30) - 14);
3431
- const fromIdx = Math.max(0, Math.min(items.length - rowsAvail, idx - Math.floor(rowsAvail / 2)));
3432
- const toIdx = Math.min(items.length, fromIdx + rowsAvail);
3433
- for (let i = fromIdx; i < toIdx; i++) {
3434
- const it = items[i];
3435
- const marker = i === idx ? accent('❯ ') : ' ';
3436
- const lbl = i === idx ? bold(it.label.padEnd(11)) : it.label.padEnd(11);
3437
- process.stdout.write(`${marker}${lbl} ${dim(it.desc)}\n`);
3605
+ let idx = 0;
3606
+ // Outer loop each iteration is one menu render → pick →
3607
+ // dispatch round. Subcommand return drops back here and the menu
3608
+ // is redrawn. Quit / Esc / Ctrl-C breaks the loop and returns,
3609
+ // which lets the calling main() exit naturally (no process.exit
3610
+ // so the buffered writer / open file descriptors close cleanly).
3611
+ while (true) {
3612
+ // First-run / config-missing guard: a fresh install has no
3613
+ // `provider` set, so any menu pick that calls a provider would
3614
+ // error halfway through. Funnel through cmdSetup before
3615
+ // rendering the menu the first time around.
3616
+ let cfg = readConfig();
3617
+ if (!cfg.provider) {
3618
+ try { await cmdSetup(undefined, [], {}); }
3619
+ catch (e) {
3620
+ process.stderr.write(`setup error: ${e?.message || e}\n`);
3621
+ }
3622
+ cfg = readConfig();
3623
+ if (!cfg.provider) {
3624
+ process.stdout.write('\n Setup not completed — exiting.\n Run `lazyclaw setup` when ready, then try `lazyclaw` again.\n\n');
3625
+ return;
3626
+ }
3438
3627
  }
3439
- process.stdout.write('\n');
3440
- };
3628
+ const provider = cfg.provider;
3629
+ const model = cfg.model || '(default)';
3441
3630
 
3442
- // Tear down raw mode + listeners cleanly so the next subcommand
3443
- // starts with a sane stdin (otherwise `chat` after launcher inherits
3444
- // the launcher's raw mode and behaves weirdly).
3445
- const teardown = (onKey) => {
3446
- if (onKey) process.stdin.off('keypress', onKey);
3447
- if (process.stdin.setRawMode) process.stdin.setRawMode(false);
3448
- process.stdout.write('\x1b[?25h'); // show cursor
3449
- process.stdout.write('\x1b[2J\x1b[H'); // clear screen
3450
- };
3451
-
3452
- draw();
3453
- const picked = await new Promise((resolve) => {
3454
- const onKey = (_str, key) => {
3455
- if (!key) return;
3456
- if (key.name === 'up') { idx = (idx - 1 + items.length) % items.length; draw(); }
3457
- else if (key.name === 'down') { idx = (idx + 1) % items.length; draw(); }
3458
- else if (key.name === 'home') { idx = 0; draw(); }
3459
- else if (key.name === 'end') { idx = items.length - 1; draw(); }
3460
- else if (key.name === 'pageup') { idx = Math.max(0, idx - 5); draw(); }
3461
- else if (key.name === 'pagedown') { idx = Math.min(items.length - 1, idx + 5); draw(); }
3462
- else if (key.name === 'return') { teardown(onKey); resolve(items[idx]); }
3463
- else if (key.ctrl && key.name === 'c') { teardown(onKey); resolve({ id: 'quit', argv: null }); }
3464
- else if (key.name === 'escape' || key.name === 'q') { teardown(onKey); resolve({ id: 'quit', argv: null }); }
3631
+ // Re-establish stdin in raw / ref'd mode. A previous iteration
3632
+ // (e.g. `chat`) deliberately paused + unref'd stdin in its
3633
+ // exit-cleanup path so the process could end on /exit; now that
3634
+ // we want to keep going, re-attach.
3635
+ const readline = await import('node:readline');
3636
+ readline.emitKeypressEvents(process.stdin);
3637
+ if (process.stdin.setRawMode) process.stdin.setRawMode(true);
3638
+ process.stdin.resume();
3639
+ process.stdin.ref();
3640
+
3641
+ const draw = () => {
3642
+ process.stdout.write('\x1b[?25l\x1b[2J\x1b[H'); // hide cursor + clear
3643
+ _renderBanner(readVersionFromRepo()).forEach((l) => process.stdout.write(l + '\n'));
3644
+ process.stdout.write('\n');
3645
+ process.stdout.write(` ${dim('provider ·')} ${ok(provider)}\n`);
3646
+ process.stdout.write(` ${dim('model ·')} ${ok(model)}\n`);
3647
+ process.stdout.write(` ${dim('config ·')} ${dim(configPath())}\n`);
3648
+ process.stdout.write('\n');
3649
+ process.stdout.write(` ${dim('↑/↓ to move · Enter to select · q or Esc to quit')}\n\n`);
3650
+ const rowsAvail = Math.max(items.length, (process.stdout.rows || 30) - 14);
3651
+ const fromIdx = Math.max(0, Math.min(items.length - rowsAvail, idx - Math.floor(rowsAvail / 2)));
3652
+ const toIdx = Math.min(items.length, fromIdx + rowsAvail);
3653
+ for (let i = fromIdx; i < toIdx; i++) {
3654
+ const it = items[i];
3655
+ const marker = i === idx ? accent('❯ ') : ' ';
3656
+ const lbl = i === idx ? bold(it.label.padEnd(11)) : it.label.padEnd(11);
3657
+ process.stdout.write(`${marker}${lbl} ${dim(it.desc)}\n`);
3658
+ }
3659
+ process.stdout.write('\n');
3465
3660
  };
3466
- process.stdin.on('keypress', onKey);
3467
- });
3468
3661
 
3469
- if (!picked || !picked.argv) {
3470
- process.exit(0);
3662
+ draw();
3663
+ const picked = await new Promise((resolve) => {
3664
+ const onKey = (_str, key) => {
3665
+ if (!key) return;
3666
+ if (key.name === 'up') { idx = (idx - 1 + items.length) % items.length; draw(); }
3667
+ else if (key.name === 'down') { idx = (idx + 1) % items.length; draw(); }
3668
+ else if (key.name === 'home') { idx = 0; draw(); }
3669
+ else if (key.name === 'end') { idx = items.length - 1; draw(); }
3670
+ else if (key.name === 'pageup') { idx = Math.max(0, idx - 5); draw(); }
3671
+ else if (key.name === 'pagedown') { idx = Math.min(items.length - 1, idx + 5); draw(); }
3672
+ else if (key.name === 'return') { cleanup(); resolve(items[idx]); }
3673
+ else if (key.ctrl && key.name === 'c') { cleanup(); resolve({ id: 'quit', argv: null }); }
3674
+ else if (key.name === 'escape' || key.name === 'q') { cleanup(); resolve({ id: 'quit', argv: null }); }
3675
+ function cleanup() {
3676
+ process.stdin.off('keypress', onKey);
3677
+ if (process.stdin.setRawMode) process.stdin.setRawMode(false);
3678
+ process.stdout.write('\x1b[?25h\x1b[2J\x1b[H');
3679
+ }
3680
+ };
3681
+ process.stdin.on('keypress', onKey);
3682
+ });
3683
+
3684
+ if (!picked || picked.id === 'quit' || !picked.argv) {
3685
+ // Plain return so main() can exit naturally.
3686
+ return;
3687
+ }
3688
+
3689
+ // Two menu items need a follow-up question before they can run:
3690
+ // agent (prompt body), browse (URL). Ask once, then dispatch.
3691
+ let argv = picked.argv;
3692
+ if (picked.promptForBody) {
3693
+ const body = await _quickPrompt('prompt: ');
3694
+ if (!body) continue; // back to menu
3695
+ argv = ['agent', body];
3696
+ } else if (picked.promptForUrl) {
3697
+ const url = await _quickPrompt('url: ');
3698
+ if (!url) continue; // back to menu
3699
+ argv = ['browse', url];
3700
+ }
3701
+
3702
+ // Dispatch. Errors don't terminate the launcher — they're
3703
+ // surfaced as a stderr line and the menu redraws. Lets the
3704
+ // user recover from a transient API hiccup without a relaunch.
3705
+ try {
3706
+ await _dispatchMenuChoice(argv);
3707
+ } catch (e) {
3708
+ process.stderr.write(`\n ${accent('✗')} ${e?.message || String(e)}\n`);
3709
+ }
3710
+
3711
+ // Pause before re-drawing so the user can read the subcommand's
3712
+ // output. `chat` is the special case: its REPL has already kept
3713
+ // the user oriented for a long session, and they typed /exit
3714
+ // explicitly, so jumping straight back to the menu reads as
3715
+ // "ok, done with that conversation, back to the dashboard."
3716
+ if (picked.id !== 'chat') {
3717
+ process.stdout.write('\n');
3718
+ await _quickPrompt(` ${dim('Press Enter to return to the menu… ')}`);
3719
+ }
3471
3720
  }
3472
- // Two surfaces need a follow-up question before they can run:
3473
- // - `agent`: needs a prompt body
3474
- // - `browse`: needs a URL
3475
- // Ask via a simple readline prompt so the launcher stays
3476
- // self-contained instead of forwarding into a half-typed argv.
3477
- if (picked.promptForBody) {
3478
- const body = await _quickPrompt('prompt: ');
3479
- if (!body) process.exit(0);
3480
- picked.argv = ['agent', body];
3481
- } else if (picked.promptForUrl) {
3482
- const url = await _quickPrompt('url: ');
3483
- if (!url) process.exit(0);
3484
- picked.argv = ['browse', url];
3485
- }
3486
- // Replace argv and re-enter main(). The chosen subcommand sees
3487
- // the same parser surface as if the user had typed it directly.
3488
- process.argv = [process.argv[0], process.argv[1], ...picked.argv];
3489
- await main();
3490
3721
  }
3491
3722
 
3492
3723
  async function _quickPrompt(label) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lazyclaw",
3
- "version": "3.99.6",
3
+ "version": "3.99.8",
4
4
  "description": "Lazy, elegant terminal CLI for chatting with Claude / OpenAI / Gemini / Ollama and orchestrating multi-step LLM workflows. Banner-on-launch, slash-command ghost autocomplete, persistent sessions, local HTTP gateway.",
5
5
  "keywords": [
6
6
  "claude",