lazyclaw 3.99.16 → 3.99.17

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/cli.mjs CHANGED
@@ -2488,13 +2488,46 @@ async function cmdChat(flags = {}) {
2488
2488
  // `dashboard` is the discoverable name and it auto-opens the browser
2489
2489
  // (which the bare daemon doesn't, since most daemon callers are
2490
2490
  // scripts).
2491
+ // Best-effort port-occupant kill — macOS / Linux only. Returns true when
2492
+ // at least one PID was signalled. Used by cmdDashboard so a leftover
2493
+ // listener from a previous run doesn't crash the launch with EADDRINUSE.
2494
+ // Mirrors the Python server's auto-kill behaviour described in CLAUDE.md.
2495
+ async function _killPortOccupant(port) {
2496
+ if (process.platform === 'win32') return false;
2497
+ const { spawn } = await import('node:child_process');
2498
+ return new Promise((resolve) => {
2499
+ let lsof;
2500
+ try {
2501
+ lsof = spawn('lsof', ['-ti', `tcp:${port}`], { stdio: ['ignore', 'pipe', 'ignore'] });
2502
+ } catch (_) { return resolve(false); }
2503
+ let buf = '';
2504
+ lsof.stdout.on('data', (d) => { buf += d.toString('utf8'); });
2505
+ lsof.on('error', () => resolve(false));
2506
+ lsof.on('close', () => {
2507
+ const pids = buf.trim().split(/\s+/).map((s) => parseInt(s, 10)).filter(Number.isFinite);
2508
+ if (!pids.length) return resolve(false);
2509
+ // SIGTERM first so node has a chance to clean up; SIGKILL the
2510
+ // holdouts after a short grace window.
2511
+ for (const pid of pids) {
2512
+ try { process.kill(pid, 'SIGTERM'); } catch (_) { /* gone already */ }
2513
+ }
2514
+ setTimeout(() => {
2515
+ for (const pid of pids) {
2516
+ try { process.kill(pid, 'SIGKILL'); } catch (_) { /* gone */ }
2517
+ }
2518
+ resolve(true);
2519
+ }, 200);
2520
+ });
2521
+ });
2522
+ }
2523
+
2491
2524
  async function cmdDashboard(flags = {}) {
2492
2525
  await ensureRegistry();
2493
2526
  const sessionsMod = await import('./sessions.mjs');
2494
2527
  const { startDaemon } = await import('./daemon.mjs');
2495
2528
  const port = flags.port !== undefined ? parseInt(flags.port, 10) : 19600;
2496
2529
  const cfgDir = path.dirname(configPath());
2497
- const d = await startDaemon({
2530
+ const daemonOpts = {
2498
2531
  port,
2499
2532
  once: false,
2500
2533
  readConfig,
@@ -2512,7 +2545,34 @@ async function cmdDashboard(flags = {}) {
2512
2545
  responseCache: null,
2513
2546
  logger: null,
2514
2547
  costCap: null,
2515
- });
2548
+ };
2549
+ let d;
2550
+ try {
2551
+ d = await startDaemon(daemonOpts);
2552
+ } catch (err) {
2553
+ if (err?.code !== 'EADDRINUSE') throw err;
2554
+ // Port is held by a leftover dashboard / daemon. Try to free it
2555
+ // (lsof + kill on macOS/Linux); on failure, fall back to a random
2556
+ // port so the user always gets a working dashboard rather than a
2557
+ // crash trace.
2558
+ const portInUse = port;
2559
+ process.stderr.write(` ⚠ port ${portInUse} is in use — likely a previous dashboard didn't shut down.\n`);
2560
+ const killed = await _killPortOccupant(portInUse);
2561
+ if (killed) {
2562
+ process.stderr.write(` ✓ freed port ${portInUse} (killed prior listener) — retrying…\n`);
2563
+ // Short pause so the OS releases the port before we re-listen.
2564
+ await new Promise(r => setTimeout(r, 250));
2565
+ try { d = await startDaemon(daemonOpts); }
2566
+ catch (err2) {
2567
+ if (err2?.code !== 'EADDRINUSE') throw err2;
2568
+ process.stderr.write(` ⚠ still in use — falling back to a random port.\n`);
2569
+ d = await startDaemon({ ...daemonOpts, port: 0 });
2570
+ }
2571
+ } else {
2572
+ process.stderr.write(` ⚠ couldn't free port ${portInUse} automatically — falling back to a random port.\n`);
2573
+ d = await startDaemon({ ...daemonOpts, port: 0 });
2574
+ }
2575
+ }
2516
2576
  const url = `http://127.0.0.1:${d.port}/dashboard`;
2517
2577
  process.stdout.write(`🦞 LazyClaw dashboard listening at ${url}\n`);
2518
2578
  if (!flags['no-open']) {
@@ -2600,27 +2660,46 @@ async function cmdDaemon(flags) {
2600
2660
  || process.env.LAZYCLAW_WORKFLOW_STATE_DIR
2601
2661
  || '.workflow-state';
2602
2662
  const cfgDir = path.dirname(configPath());
2603
- const d = await startDaemon({
2604
- port: Number.isFinite(port) ? port : 0,
2605
- once,
2606
- readConfig,
2607
- // `lazyclaw daemon` exposes mutation endpoints (POST /providers,
2608
- // PUT /rates/<key>, etc.) only when an auth token is configured
2609
- // without one the daemon is loopback-only but still untrusted
2610
- // (any process on the box can hit it). dashboard subcommand sets
2611
- // writeConfig unconditionally because it always runs as the user.
2612
- writeConfig: authToken ? writeConfig : undefined,
2613
- sessionsDirGetter: () => cfgDir,
2614
- sessionsMod,
2615
- version: () => readVersionFromRepo(),
2616
- workflowStateDir: () => workflowStateDirValue,
2617
- authToken: authToken || undefined,
2618
- allowedOrigins,
2619
- rateLimit,
2620
- responseCache,
2621
- logger,
2622
- costCap: costCapOrNull,
2623
- });
2663
+ let d;
2664
+ try {
2665
+ d = await startDaemon({
2666
+ port: Number.isFinite(port) ? port : 0,
2667
+ once,
2668
+ readConfig,
2669
+ // `lazyclaw daemon` exposes mutation endpoints (POST /providers,
2670
+ // PUT /rates/<key>, etc.) only when an auth token is configured
2671
+ // without one the daemon is loopback-only but still untrusted
2672
+ // (any process on the box can hit it). dashboard subcommand sets
2673
+ // writeConfig unconditionally because it always runs as the user.
2674
+ writeConfig: authToken ? writeConfig : undefined,
2675
+ sessionsDirGetter: () => cfgDir,
2676
+ sessionsMod,
2677
+ version: () => readVersionFromRepo(),
2678
+ workflowStateDir: () => workflowStateDirValue,
2679
+ authToken: authToken || undefined,
2680
+ allowedOrigins,
2681
+ rateLimit,
2682
+ responseCache,
2683
+ logger,
2684
+ costCap: costCapOrNull,
2685
+ });
2686
+ } catch (err) {
2687
+ // `lazyclaw daemon` exits cleanly on EADDRINUSE with a readable
2688
+ // message instead of the historical unhandled-error stack trace.
2689
+ // Unlike `lazyclaw dashboard`, daemon doesn't auto-kill the prior
2690
+ // listener — bare daemon callers are usually scripts that expect
2691
+ // exact port semantics, so we surface the failure and let them
2692
+ // choose (re-run with --port 0 for random, or kill the holdout).
2693
+ if (err?.code === 'EADDRINUSE') {
2694
+ process.stderr.write(
2695
+ `lazyclaw daemon: port ${port} is in use.\n` +
2696
+ ` Re-run with --port 0 for a random port, or free the port:\n` +
2697
+ ` lsof -ti tcp:${port} | xargs kill -9\n`
2698
+ );
2699
+ process.exit(2);
2700
+ }
2701
+ throw err;
2702
+ }
2624
2703
  // Print the bound port immediately so test/script callers can pick it up
2625
2704
  // even when we asked for port 0. Indicate auth presence (not the token)
2626
2705
  // and the allowed-origin count (not the values, just whether browser
package/daemon.mjs CHANGED
@@ -1666,8 +1666,19 @@ export async function startDaemon(opts) {
1666
1666
  setImmediate(() => server.close());
1667
1667
  }
1668
1668
  });
1669
- return new Promise((resolve) => {
1669
+ return new Promise((resolve, reject) => {
1670
+ // EADDRINUSE (and other listen-time errors) used to crash the
1671
+ // process — listen() emits 'error' before the success callback
1672
+ // fires, and we never wired that channel. Capture it once so
1673
+ // callers (cmdDashboard / cmdDaemon) can choose to kill the
1674
+ // occupant or fall back to a random port.
1675
+ const onError = (err) => {
1676
+ server.off('error', onError);
1677
+ reject(err);
1678
+ };
1679
+ server.once('error', onError);
1670
1680
  server.listen(opts.port ?? 0, '127.0.0.1', () => {
1681
+ server.off('error', onError);
1671
1682
  const addr = server.address();
1672
1683
  const port = typeof addr === 'object' && addr ? addr.port : 0;
1673
1684
  resolve({
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lazyclaw",
3
- "version": "3.99.16",
3
+ "version": "3.99.17",
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",
@@ -523,9 +523,27 @@
523
523
  LOADERS.chat = async function loadChat() {
524
524
  try {
525
525
  const r = await api('/providers');
526
+ // GET /providers returns a bare array; older drafts wrapped it
527
+ // as { providers: [...] }. Accept both so the dashboard works
528
+ // against any daemon version users might happen to be running.
529
+ const arr = Array.isArray(r) ? r : (r.providers || []);
526
530
  const sel = document.getElementById('chat-assignee');
527
531
  sel.innerHTML = '';
528
- for (const p of r.providers || []) {
532
+ if (arr.length === 0) {
533
+ const opt = document.createElement('option');
534
+ opt.value = ''; opt.textContent = '(no providers — run lazyclaw onboard)';
535
+ sel.appendChild(opt);
536
+ return;
537
+ }
538
+ // Preselect the configured default when possible so the user
539
+ // doesn't have to scroll through the list before sending the
540
+ // first message.
541
+ let defaultStatus = null;
542
+ try { defaultStatus = await api('/status'); } catch { /* keep going */ }
543
+ const defaultProv = defaultStatus?.provider || null;
544
+ const defaultModel = defaultStatus?.model || null;
545
+ const defaultValue = defaultProv && defaultModel ? `${defaultProv}:${defaultModel}` : defaultProv;
546
+ for (const p of arr) {
529
547
  const ms = (p.suggestedModels || []);
530
548
  if (!ms.length) {
531
549
  const opt = document.createElement('option');
@@ -540,6 +558,18 @@
540
558
  sel.appendChild(opt);
541
559
  }
542
560
  }
561
+ if (defaultValue) {
562
+ // Try exact match first (provider:model); fall back to any
563
+ // option starting with `<provider>:` if the configured model
564
+ // isn't in the suggested list.
565
+ const exact = Array.from(sel.options).find((o) => o.value === defaultValue);
566
+ if (exact) sel.value = defaultValue;
567
+ else {
568
+ const prefix = (defaultProv || '') + ':';
569
+ const byProv = Array.from(sel.options).find((o) => o.value.startsWith(prefix) || o.value === defaultProv);
570
+ if (byProv) sel.value = byProv.value;
571
+ }
572
+ }
543
573
  } catch (e) {
544
574
  document.getElementById('chat-meta').textContent = '⚠ ' + e.message;
545
575
  }