lazyclaw 3.99.15 → 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
@@ -1455,7 +1455,10 @@ function _attachGhostAutocomplete(rl) {
1455
1455
  function _renderBanner(version) {
1456
1456
  const W = 30;
1457
1457
  const accent = (s) => `\x1b[38;5;208m${s}\x1b[0m`;
1458
- const dim = (s) => `\x1b[2m${s}\x1b[0m`;
1458
+ // 24-bit color so the mascot reads in the same warm orange as the
1459
+ // dist/index.html SVG (#d97757). Falls back gracefully on terminals
1460
+ // that ignore truecolor — the glyphs are visible regardless.
1461
+ const orange = (s) => `\x1b[38;2;217;119;87m${s}\x1b[0m`;
1459
1462
  // Inner content of each banner row — DO NOT pad here, the wrapper
1460
1463
  // does it. Backslashes are JS-escaped so each `\\` renders as one
1461
1464
  // literal `\` in the output.
@@ -1466,16 +1469,18 @@ function _renderBanner(version) {
1466
1469
  ' |_\\__,_/__\\_, |_|',
1467
1470
  ' LazyClaw |__/ ' + String(version || '?.?.?').padEnd(10).slice(0, 10),
1468
1471
  ];
1469
- // Sleepy-cat mascot on the right, lined up with the busiest part
1470
- // of the wordmark. Three rows of ASCII art + "zz" trail. Plain
1471
- // ASCII (no box-drawing on the cat) so it lands well in any font.
1472
+ // Pixel-art mascot mirrored from the lazyclaude SPA's #claudeMascot
1473
+ // SVG (orange rectangles block characters). Squashed to 5 rows so
1474
+ // it lines up with `inner` in the banner. Eye sockets are left blank
1475
+ // (the SVG fills them with #000); a hollow gap reads as eyes against
1476
+ // the orange body in any monospace font.
1472
1477
  const mascot = [
1473
1478
  '',
1474
- '',
1475
- ' /\\_/\\',
1476
- ' ( -.- ) ' + dim('z z'),
1477
- ' > ^ < ' + dim('z'),
1478
- '',
1479
+ orange(' ██ ██'),
1480
+ orange(' ██████████████'),
1481
+ orange(' ██ ') + '██' + orange(' ') + '██' + orange(' ██'),
1482
+ orange(' ██████████████'),
1483
+ orange(' ██ ██'),
1479
1484
  '',
1480
1485
  ];
1481
1486
  const banner = [
@@ -2483,16 +2488,50 @@ async function cmdChat(flags = {}) {
2483
2488
  // `dashboard` is the discoverable name and it auto-opens the browser
2484
2489
  // (which the bare daemon doesn't, since most daemon callers are
2485
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
+
2486
2524
  async function cmdDashboard(flags = {}) {
2487
2525
  await ensureRegistry();
2488
2526
  const sessionsMod = await import('./sessions.mjs');
2489
2527
  const { startDaemon } = await import('./daemon.mjs');
2490
2528
  const port = flags.port !== undefined ? parseInt(flags.port, 10) : 19600;
2491
2529
  const cfgDir = path.dirname(configPath());
2492
- const d = await startDaemon({
2530
+ const daemonOpts = {
2493
2531
  port,
2494
2532
  once: false,
2495
2533
  readConfig,
2534
+ writeConfig,
2496
2535
  sessionsDirGetter: () => cfgDir,
2497
2536
  sessionsMod,
2498
2537
  version: () => readVersionFromRepo(),
@@ -2506,7 +2545,34 @@ async function cmdDashboard(flags = {}) {
2506
2545
  responseCache: null,
2507
2546
  logger: null,
2508
2547
  costCap: null,
2509
- });
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
+ }
2510
2576
  const url = `http://127.0.0.1:${d.port}/dashboard`;
2511
2577
  process.stdout.write(`🦞 LazyClaw dashboard listening at ${url}\n`);
2512
2578
  if (!flags['no-open']) {
@@ -2594,21 +2660,46 @@ async function cmdDaemon(flags) {
2594
2660
  || process.env.LAZYCLAW_WORKFLOW_STATE_DIR
2595
2661
  || '.workflow-state';
2596
2662
  const cfgDir = path.dirname(configPath());
2597
- const d = await startDaemon({
2598
- port: Number.isFinite(port) ? port : 0,
2599
- once,
2600
- readConfig,
2601
- sessionsDirGetter: () => cfgDir,
2602
- sessionsMod,
2603
- version: () => readVersionFromRepo(),
2604
- workflowStateDir: () => workflowStateDirValue,
2605
- authToken: authToken || undefined,
2606
- allowedOrigins,
2607
- rateLimit,
2608
- responseCache,
2609
- logger,
2610
- costCap: costCapOrNull,
2611
- });
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
+ }
2612
2703
  // Print the bound port immediately so test/script callers can pick it up
2613
2704
  // even when we asked for port 0. Indicate auth presence (not the token)
2614
2705
  // and the allowed-origin count (not the values, just whether browser
package/daemon.mjs CHANGED
@@ -245,6 +245,7 @@ function isOriginAllowed(req, allowedOrigins) {
245
245
  /**
246
246
  * @param {{
247
247
  * readConfig: () => Record<string, unknown>,
248
+ * writeConfig?: (cfg: Record<string, unknown>) => void,
248
249
  * sessionsDirGetter: () => string,
249
250
  * sessionsMod: typeof import('./sessions.mjs'),
250
251
  * version: () => string,
@@ -256,6 +257,12 @@ function isOriginAllowed(req, allowedOrigins) {
256
257
  * logger?: ReturnType<typeof createLogger> | null,
257
258
  * costCap?: Record<string, number> | null,
258
259
  * }} ctx
260
+ *
261
+ * `writeConfig` is optional; when omitted the mutation endpoints (POST
262
+ * /providers, DELETE /providers/<name>, PUT/DELETE /rates/<key>, PUT
263
+ * /config/<key>) reject with 405 Method Not Allowed. The CLI's
264
+ * `cmdDashboard` always supplies it; bare `lazyclaw daemon --once` callers
265
+ * can opt out by leaving it undefined.
259
266
  */
260
267
  export function makeHandler(ctx) {
261
268
  const authToken = ctx.authToken || null;
@@ -373,10 +380,12 @@ export function makeHandler(ctx) {
373
380
  const route = `${req.method} ${url.pathname}`;
374
381
  const sessionMatch = url.pathname.match(/^\/sessions\/([^/]+)$/);
375
382
  const providerMatch = url.pathname.match(/^\/providers\/([^/]+)$/);
383
+ const providerTestMatch = url.pathname.match(/^\/providers\/([^/]+)\/test$/);
376
384
  const sessionExportMatch = url.pathname.match(/^\/sessions\/([^/]+)\/export$/);
377
385
  const skillMatch = url.pathname.match(/^\/skills\/([^/]+)$/);
378
386
  const workflowMatch = url.pathname.match(/^\/workflows\/([^/]+)$/);
379
387
  const configKeyMatch = url.pathname.match(/^\/config\/([^/]+)$/);
388
+ const ratesKeyMatch = url.pathname.match(/^\/rates\/([^/]+)$/);
380
389
  switch (true) {
381
390
  case route === 'GET /' || route === 'GET /dashboard': {
382
391
  // Serve the lazyclaw-only web dashboard (a single static
@@ -487,9 +496,25 @@ export function makeHandler(ctx) {
487
496
  }
488
497
  case route === 'GET /providers': {
489
498
  // ?filter=<substr>&limit=<N> mirror v3.33+ list flags.
499
+ // The dashboard reads `custom` / `builtinOpenAICompat` / `endpoint`
500
+ // / `docs` to render the right pills + tooltips; CLI callers only
501
+ // need `name` / `requiresApiKey` / `suggestedModels` and ignore
502
+ // the extras (additive change, no migration).
490
503
  let out = Object.keys(PROVIDERS).map(name => {
491
504
  const meta = PROVIDER_INFO[name] || { name };
492
- return { name, requiresApiKey: !!meta.requiresApiKey, defaultModel: meta.defaultModel || null, suggestedModels: meta.suggestedModels || [] };
505
+ return {
506
+ name,
507
+ requiresApiKey: !!meta.requiresApiKey,
508
+ defaultModel: meta.defaultModel || null,
509
+ suggestedModels: meta.suggestedModels || [],
510
+ endpoint: meta.endpoint || null,
511
+ docs: meta.docs || null,
512
+ custom: !!meta.custom,
513
+ builtinOpenAICompat: !!meta.builtinOpenAICompat,
514
+ baseUrl: meta.baseUrl || null,
515
+ envKey: meta.envKey || null,
516
+ keyPrefix: meta.keyPrefix || null,
517
+ };
493
518
  });
494
519
  const filter = url.searchParams.get('filter');
495
520
  if (filter) {
@@ -569,6 +594,100 @@ export function makeHandler(ctx) {
569
594
  results,
570
595
  });
571
596
  }
597
+ case req.method === 'GET' && !!providerTestMatch: {
598
+ // GET /providers/<name>/test — single-provider 1-token reachability
599
+ // probe. Same shape as one entry of GET /providers/test, but the
600
+ // endpoint stops on the first failure and exposes the reply body
601
+ // (truncated) so the dashboard can show a real signal of life.
602
+ const name = providerTestMatch[1];
603
+ const provider = PROVIDERS[name];
604
+ if (!provider) return writeJson(res, 404, { error: `unknown provider: ${name}` });
605
+ const cfg = ctx.readConfig();
606
+ const apiKey = cfg['api-key'] || '';
607
+ const meta = PROVIDER_INFO[name] || {};
608
+ const model = url.searchParams.get('model') || cfg.model || meta.defaultModel || 'unknown';
609
+ const prompt = url.searchParams.get('prompt') || 'ping';
610
+ const t0 = Date.now();
611
+ try {
612
+ let reply = '';
613
+ const stream = provider.sendMessage([{ role: 'user', content: prompt }], { apiKey, model });
614
+ for await (const chunk of stream) {
615
+ if (typeof chunk === 'string') reply += chunk;
616
+ }
617
+ return writeJson(res, reply.length > 0 ? 200 : 503, {
618
+ ok: reply.length > 0,
619
+ name, model,
620
+ durationMs: Date.now() - t0,
621
+ replyLength: reply.length,
622
+ reply: reply.slice(0, 500),
623
+ });
624
+ } catch (err) {
625
+ return writeJson(res, 503, {
626
+ ok: false, name, model,
627
+ durationMs: Date.now() - t0,
628
+ error: err?.message || String(err),
629
+ code: err?.code || null,
630
+ });
631
+ }
632
+ }
633
+ case route === 'POST /providers': {
634
+ // Register or overwrite a custom OpenAI-compatible provider.
635
+ // Body: { name, baseUrl, apiKey?, defaultModel? }. Persists into
636
+ // cfg.customProviders[] and hot-registers via the registry's
637
+ // registerCustomProviders() so the new entry is callable in this
638
+ // same process. 405 when the daemon was started without
639
+ // writeConfig (read-only mode). The same name as a built-in
640
+ // OpenAI-compat alias is allowed and overrides the built-in.
641
+ if (typeof ctx.writeConfig !== 'function') {
642
+ return writeJson(res, 405, { error: 'mutation disabled — daemon was started without writeConfig' });
643
+ }
644
+ let body;
645
+ try { body = await readJson(req); }
646
+ catch (e) { return writeJson(res, 400, { error: e?.message || String(e) }); }
647
+ const reg = await import('./providers/registry.mjs');
648
+ let name;
649
+ try { name = reg.validateCustomProviderName(body.name); }
650
+ catch (e) { return writeJson(res, 400, { error: e.message }); }
651
+ if (!body.baseUrl || typeof body.baseUrl !== 'string' || !/^https?:\/\//i.test(body.baseUrl)) {
652
+ return writeJson(res, 400, { error: 'baseUrl must be a string starting with http:// or https://' });
653
+ }
654
+ const cfg = ctx.readConfig();
655
+ cfg.customProviders = Array.isArray(cfg.customProviders) ? cfg.customProviders : [];
656
+ const idx = cfg.customProviders.findIndex((p) => p && p.name === name);
657
+ const entry = {
658
+ name,
659
+ baseUrl: String(body.baseUrl).replace(/\/+$/, ''),
660
+ apiKey: body.apiKey || undefined,
661
+ defaultModel: body.defaultModel || undefined,
662
+ };
663
+ if (idx >= 0) cfg.customProviders[idx] = { ...cfg.customProviders[idx], ...entry };
664
+ else cfg.customProviders.push(entry);
665
+ ctx.writeConfig(cfg);
666
+ try { reg.registerCustomProviders(cfg); } catch { /* keep going */ }
667
+ const overridesBuiltin = typeof reg.isBuiltinOpenAICompatName === 'function'
668
+ ? reg.isBuiltinOpenAICompatName(name)
669
+ : false;
670
+ return writeJson(res, 200, { ok: true, name, baseUrl: entry.baseUrl, overridesBuiltin });
671
+ }
672
+ case req.method === 'DELETE' && !!providerMatch && providerMatch[1] !== 'test': {
673
+ // DELETE /providers/<name> — drop a custom registration. Idempotent:
674
+ // 200 with `removed: false` when the name wasn't a custom entry.
675
+ // Built-in providers can't be deleted; their PROVIDERS row is
676
+ // restored on next process boot if the user previously overrode it.
677
+ if (typeof ctx.writeConfig !== 'function') {
678
+ return writeJson(res, 405, { error: 'mutation disabled' });
679
+ }
680
+ const name = providerMatch[1];
681
+ const cfg = ctx.readConfig();
682
+ if (!Array.isArray(cfg.customProviders) || cfg.customProviders.length === 0) {
683
+ return writeJson(res, 200, { ok: true, name, removed: false });
684
+ }
685
+ const before = cfg.customProviders.length;
686
+ cfg.customProviders = cfg.customProviders.filter((p) => !(p && p.name === name));
687
+ const removed = cfg.customProviders.length < before;
688
+ if (removed) ctx.writeConfig(cfg);
689
+ return writeJson(res, 200, { ok: true, name, removed });
690
+ }
572
691
  case route === 'GET /rates': {
573
692
  // Read-only view of cfg.rates so a dashboard's cost panel
574
693
  // can render the configured cards without shelling to the
@@ -608,6 +727,42 @@ export function makeHandler(ctx) {
608
727
  // fields without shelling to the CLI.
609
728
  return writeJson(res, 200, RATE_CARD_SHAPE);
610
729
  }
730
+ case req.method === 'PUT' && !!ratesKeyMatch && ratesKeyMatch[1] !== 'validate' && ratesKeyMatch[1] !== 'shape': {
731
+ // PUT /rates/<key> — set a rate card. Body is the card object
732
+ // ({ in, out, "cache-read"?, "cache-create"?, currency? }). The
733
+ // payload is merged into cfg.rates and validated as a whole;
734
+ // 422 on validation failure. 405 when writeConfig is unset.
735
+ if (typeof ctx.writeConfig !== 'function') {
736
+ return writeJson(res, 405, { error: 'mutation disabled' });
737
+ }
738
+ const key = decodeURIComponent(ratesKeyMatch[1]);
739
+ let body;
740
+ try { body = await readJson(req); }
741
+ catch (e) { return writeJson(res, 400, { error: e?.message || String(e) }); }
742
+ if (!body || typeof body !== 'object') return writeJson(res, 400, { error: 'body must be a JSON object' });
743
+ const cfg = ctx.readConfig();
744
+ cfg.rates = cfg.rates && typeof cfg.rates === 'object' ? cfg.rates : {};
745
+ cfg.rates[key] = body;
746
+ const v = validateRates(cfg.rates, PROVIDERS);
747
+ if (!v.ok) return writeJson(res, 422, v);
748
+ ctx.writeConfig(cfg);
749
+ return writeJson(res, 200, { ok: true, key, card: cfg.rates[key] });
750
+ }
751
+ case req.method === 'DELETE' && !!ratesKeyMatch && ratesKeyMatch[1] !== 'validate' && ratesKeyMatch[1] !== 'shape': {
752
+ // DELETE /rates/<key> — idempotent: 200 with `removed: false`
753
+ // when the card didn't exist.
754
+ if (typeof ctx.writeConfig !== 'function') {
755
+ return writeJson(res, 405, { error: 'mutation disabled' });
756
+ }
757
+ const key = decodeURIComponent(ratesKeyMatch[1]);
758
+ const cfg = ctx.readConfig();
759
+ if (!cfg.rates || typeof cfg.rates !== 'object' || !(key in cfg.rates)) {
760
+ return writeJson(res, 200, { ok: true, key, removed: false });
761
+ }
762
+ delete cfg.rates[key];
763
+ ctx.writeConfig(cfg);
764
+ return writeJson(res, 200, { ok: true, key, removed: true });
765
+ }
611
766
  case route === 'GET /status': {
612
767
  const cfg = ctx.readConfig();
613
768
  return writeJson(res, 200, {
@@ -654,6 +809,52 @@ export function makeHandler(ctx) {
654
809
  const value = key === 'api-key' ? maskApiKey(raw) : raw;
655
810
  return writeJson(res, 200, { key, value });
656
811
  }
812
+ case req.method === 'PUT' && !!configKeyMatch && configKeyMatch[1] !== 'validate': {
813
+ // PUT /config/<key> body: { value: <any> }
814
+ // Mirror of `lazyclaw config set <key> <value>`. Re-validates the
815
+ // whole config after the write so we never persist a broken state.
816
+ // Nested cargo (customProviders / rates / authProfiles) goes
817
+ // through its own dedicated endpoint — guarded here so a
818
+ // dashboard PUT can't bypass schema validation.
819
+ if (typeof ctx.writeConfig !== 'function') {
820
+ return writeJson(res, 405, { error: 'mutation disabled' });
821
+ }
822
+ const key = configKeyMatch[1];
823
+ if (key === 'customProviders' || key === 'rates' || key === 'authProfiles') {
824
+ return writeJson(res, 400, {
825
+ error: `use the dedicated endpoint for "${key}" — POST /providers · PUT /rates/<key> · authProfiles via CLI`,
826
+ });
827
+ }
828
+ let body;
829
+ try { body = await readJson(req); }
830
+ catch (e) { return writeJson(res, 400, { error: e?.message || String(e) }); }
831
+ const value = body && Object.prototype.hasOwnProperty.call(body, 'value') ? body.value : undefined;
832
+ const cfg = ctx.readConfig();
833
+ if (value === null || value === undefined) delete cfg[key];
834
+ else cfg[key] = value;
835
+ const v = validateConfig(cfg, PROVIDERS);
836
+ if (!v.ok) return writeJson(res, 422, v);
837
+ ctx.writeConfig(cfg);
838
+ const stored = key === 'api-key' && typeof cfg[key] === 'string' ? maskApiKey(cfg[key]) : cfg[key];
839
+ return writeJson(res, 200, { ok: true, key, value: stored });
840
+ }
841
+ case req.method === 'DELETE' && !!configKeyMatch && configKeyMatch[1] !== 'validate': {
842
+ // DELETE /config/<key> — same as `lazyclaw config delete`.
843
+ // Idempotent: 200 with `removed: false` when the key wasn't
844
+ // present.
845
+ if (typeof ctx.writeConfig !== 'function') {
846
+ return writeJson(res, 405, { error: 'mutation disabled' });
847
+ }
848
+ const key = configKeyMatch[1];
849
+ if (key === 'customProviders' || key === 'rates' || key === 'authProfiles') {
850
+ return writeJson(res, 400, { error: `delete via the dedicated endpoint for "${key}"` });
851
+ }
852
+ const cfg = ctx.readConfig();
853
+ if (!(key in cfg)) return writeJson(res, 200, { ok: true, key, removed: false });
854
+ delete cfg[key];
855
+ ctx.writeConfig(cfg);
856
+ return writeJson(res, 200, { ok: true, key, removed: true });
857
+ }
657
858
  case route === 'GET /doctor': {
658
859
  // Mirror the CLI doctor output — same field set so any tool that
659
860
  // already knows how to read CLI doctor JSON can hit this endpoint.
@@ -1465,8 +1666,19 @@ export async function startDaemon(opts) {
1465
1666
  setImmediate(() => server.close());
1466
1667
  }
1467
1668
  });
1468
- 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);
1469
1680
  server.listen(opts.port ?? 0, '127.0.0.1', () => {
1681
+ server.off('error', onError);
1470
1682
  const addr = server.address();
1471
1683
  const port = typeof addr === 'object' && addr ? addr.port : 0;
1472
1684
  resolve({
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lazyclaw",
3
- "version": "3.99.15",
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",
@@ -41,8 +41,25 @@
41
41
  align-items: center;
42
42
  gap: 14px;
43
43
  }
44
- .logo { font-weight: 700; font-size: 16px; color: var(--accent); }
44
+ .logo { font-weight: 700; font-size: 16px; color: var(--accent); display: flex; align-items: center; gap: 10px; }
45
+ .logo .mascot { width: 36px; height: 32px; flex: none; }
45
46
  .ver { color: var(--dim); font-size: 11px; }
47
+ /* lazyclaude pixel-mascot — copied verbatim from dist/index.html so the
48
+ lazyclaw web dashboard wears the same character as the larger SPA. */
49
+ .mascot .mj-body { animation: mj-jump 1s ease-in-out infinite; transform-origin: center bottom; }
50
+ .mascot .mj-shadow { animation: mj-sh 1s ease-in-out infinite; }
51
+ .mascot .mj-la { animation: mj-wl 1s ease-in-out infinite; transform-origin: right center; }
52
+ .mascot .mj-ra { animation: mj-wr 1s ease-in-out infinite; transform-origin: left center; }
53
+ .mascot .mj-le { animation: mj-ear 1s ease-in-out infinite; transform-origin: center bottom; }
54
+ .mascot .mj-re { animation: mj-ear 1s ease-in-out infinite .1s; transform-origin: center bottom; }
55
+ @keyframes mj-jump { 0%, 100% { transform: translateY(0) scaleY(1) scaleX(1); } 30% { transform: translateY(-10px) scaleY(1.1) scaleX(.95); } 50% { transform: translateY(-12px) scaleY(1.05) scaleX(.98); } 80% { transform: translateY(-3px) scaleY(.95) scaleX(1.05); } }
56
+ @keyframes mj-sh { 0%, 100% { transform: scaleX(1); opacity: .25; } 50% { transform: scaleX(.4); opacity: .08; } }
57
+ @keyframes mj-wl { 0%, 100% { transform: rotate(0); } 50% { transform: rotate(-25deg); } }
58
+ @keyframes mj-wr { 0%, 100% { transform: rotate(0); } 50% { transform: rotate(25deg); } }
59
+ @keyframes mj-ear { 0%, 100% { transform: scaleY(1); } 40% { transform: scaleY(1.2); } 60% { transform: scaleY(.85); } }
60
+ @media (prefers-reduced-motion: reduce) {
61
+ .mascot .mj-body, .mascot .mj-shadow, .mascot .mj-la, .mascot .mj-ra, .mascot .mj-le, .mascot .mj-re { animation: none; }
62
+ }
46
63
  nav.tabs {
47
64
  display: flex;
48
65
  gap: 2px;
@@ -221,6 +238,35 @@
221
238
  .banner.err { border-color: rgba(239, 68, 68, 0.4); background: rgba(239, 68, 68, 0.06); }
222
239
  .banner ul { margin: 6px 0 0 18px; padding: 0; }
223
240
  .banner li { font-size: 12px; }
241
+ /* Modal — used by session/workflow/skill detail views, the rate-card
242
+ editor, and the custom-provider form. One stacking layer; only one
243
+ open at a time. */
244
+ .modal-backdrop { position: fixed; inset: 0; background: rgba(0,0,0,0.65); display: none; align-items: center; justify-content: center; z-index: 100; padding: 20px; }
245
+ .modal-backdrop.open { display: flex; }
246
+ .modal { background: var(--card); border: 1px solid var(--border); border-radius: 10px; max-width: min(720px, 96vw); width: 100%; max-height: 86vh; display: flex; flex-direction: column; box-shadow: 0 20px 60px rgba(0,0,0,0.5); }
247
+ .modal-head { padding: 14px 18px; border-bottom: 1px solid var(--border); display: flex; align-items: center; gap: 10px; }
248
+ .modal-head h3 { margin: 0; font-size: 16px; font-weight: 600; flex: 1; }
249
+ .modal-close { background: none; border: 0; color: var(--dim); cursor: pointer; font-size: 20px; line-height: 1; padding: 4px 8px; }
250
+ .modal-close:hover { color: var(--text); }
251
+ .modal-body { padding: 16px 18px; overflow-y: auto; flex: 1; }
252
+ .modal-foot { padding: 12px 18px; border-top: 1px solid var(--border); display: flex; gap: 8px; justify-content: flex-end; }
253
+ .clickable { cursor: pointer; }
254
+ .clickable:hover { background: rgba(217, 119, 87, 0.05); }
255
+ .turn { padding: 8px 12px; border-radius: 6px; margin-bottom: 8px; white-space: pre-wrap; word-wrap: break-word; font-size: 13px; }
256
+ .turn.user { background: rgba(217, 119, 87, 0.10); border: 1px solid rgba(217, 119, 87, 0.25); }
257
+ .turn.assistant { background: rgba(74, 222, 128, 0.06); border: 1px solid rgba(74, 222, 128, 0.18); }
258
+ .turn.system { background: rgba(245, 158, 11, 0.06); border: 1px solid rgba(245, 158, 11, 0.20); }
259
+ .turn .role-tag { display: block; color: var(--dim); font-size: 10px; text-transform: uppercase; letter-spacing: 0.05em; margin-bottom: 4px; }
260
+ .form-row { display: flex; flex-direction: column; gap: 4px; margin-bottom: 12px; }
261
+ .form-row label { color: var(--dim); font-size: 11px; text-transform: uppercase; letter-spacing: 0.05em; }
262
+ .form-row input, .form-row textarea { background: var(--bg); border: 1px solid var(--border); color: var(--text); padding: 8px 10px; border-radius: 6px; font: inherit; font-size: 13px; }
263
+ .form-row textarea { resize: vertical; min-height: 80px; font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; font-size: 12px; }
264
+ .row-actions { display: flex; gap: 6px; align-items: center; margin-left: auto; }
265
+ button.btn-sm { font-size: 12px; padding: 4px 10px; }
266
+ button.btn-danger { background: rgba(239,68,68,0.15); color: #ffb4b4; border: 1px solid rgba(239,68,68,0.3); }
267
+ button.btn-danger:hover { background: rgba(239,68,68,0.25); }
268
+ .markdown { font-size: 13px; line-height: 1.55; }
269
+ .markdown pre { font-size: 12px; }
224
270
  @media (max-width: 480px) {
225
271
  main { padding: 14px; }
226
272
  .grid { grid-template-columns: 1fr; }
@@ -239,7 +285,24 @@
239
285
  </head>
240
286
  <body>
241
287
  <header>
242
- <div class="logo">🦞 LazyClaw</div>
288
+ <div class="logo">
289
+ <svg class="mascot" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 90" aria-hidden="true">
290
+ <ellipse class="mj-shadow" cx="50" cy="82" rx="22" ry="5" fill="#000"/>
291
+ <g class="mj-body">
292
+ <rect class="mj-le" x="22" y="10" width="8" height="14" fill="#d97757"/>
293
+ <rect class="mj-re" x="70" y="10" width="8" height="14" fill="#d97757"/>
294
+ <rect x="18" y="24" width="64" height="4" fill="#d97757"/>
295
+ <rect x="14" y="28" width="72" height="32" fill="#d97757"/>
296
+ <rect x="30" y="34" width="8" height="10" fill="#000"/>
297
+ <rect x="62" y="34" width="8" height="10" fill="#000"/>
298
+ <rect class="mj-la" x="2" y="36" width="12" height="8" fill="#d97757"/>
299
+ <rect class="mj-ra" x="86" y="36" width="12" height="8" fill="#d97757"/>
300
+ <rect x="24" y="60" width="12" height="14" fill="#d97757"/>
301
+ <rect x="64" y="60" width="12" height="14" fill="#d97757"/>
302
+ </g>
303
+ </svg>
304
+ <span>LazyClaw</span>
305
+ </div>
243
306
  <div class="ver" id="version">…</div>
244
307
  </header>
245
308
 
@@ -283,6 +346,11 @@
283
346
 
284
347
  <section id="tab-providers">
285
348
  <h2>Providers</h2>
349
+ <div class="toolbar">
350
+ <button class="btn" onclick="openAddProviderModal()">+ Add custom OpenAI-compat</button>
351
+ <button class="btn btn-secondary" onclick="LOADERS.providers()">Refresh</button>
352
+ <span class="dim" id="providers-meta"></span>
353
+ </div>
286
354
  <div id="providers-list"><div class="empty">Loading…</div></div>
287
355
  </section>
288
356
 
@@ -312,6 +380,7 @@
312
380
  <section id="tab-rates">
313
381
  <h2>Rates</h2>
314
382
  <div class="toolbar">
383
+ <button class="btn" onclick="openRateCardModal()">+ Add / edit rate card</button>
315
384
  <input type="search" id="rates-filter" placeholder="filter by provider/model">
316
385
  <button class="btn btn-secondary" onclick="LOADERS.rates()">Refresh</button>
317
386
  <span class="dim" id="rates-meta"></span>
@@ -342,6 +411,7 @@
342
411
  <section id="tab-config">
343
412
  <h2>Config</h2>
344
413
  <div class="toolbar">
414
+ <button class="btn" onclick="openConfigEditModal()">+ Set key</button>
345
415
  <button class="btn btn-secondary" onclick="LOADERS.config()">Refresh</button>
346
416
  <span class="dim" id="config-meta"></span>
347
417
  </div>
@@ -359,6 +429,18 @@
359
429
  <span id="footer-url"></span>
360
430
  </footer>
361
431
 
432
+ <!-- Shared modal — opened by openModal({title, bodyHtml, footHtml}). -->
433
+ <div id="modal-backdrop" class="modal-backdrop" onclick="if(event.target===this)closeModal()">
434
+ <div class="modal" role="dialog" aria-modal="true">
435
+ <div class="modal-head">
436
+ <h3 id="modal-title"></h3>
437
+ <button class="modal-close" onclick="closeModal()" aria-label="Close">×</button>
438
+ </div>
439
+ <div class="modal-body" id="modal-body"></div>
440
+ <div class="modal-foot" id="modal-foot"></div>
441
+ </div>
442
+ </div>
443
+
362
444
  <script>
363
445
  // Tab switching ────────────────────────────────────────────────
364
446
  const tabs = document.querySelectorAll('nav.tabs button');
@@ -394,6 +476,25 @@
394
476
  function escHtml(s) {
395
477
  return String(s ?? '').replace(/[&<>"']/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]));
396
478
  }
479
+ // ── Shared modal ────────────────────────────────────────────────
480
+ // openModal({ title, bodyHtml, footHtml }) renders into the markup
481
+ // declared at the bottom of <body> and shows the backdrop. ESC and
482
+ // backdrop click close. Only one modal is open at a time — calling
483
+ // openModal while another is already open replaces its contents.
484
+ function openModal({ title, bodyHtml, footHtml }) {
485
+ document.getElementById('modal-title').textContent = title || '';
486
+ document.getElementById('modal-body').innerHTML = bodyHtml || '';
487
+ document.getElementById('modal-foot').innerHTML = footHtml || '';
488
+ document.getElementById('modal-backdrop').classList.add('open');
489
+ }
490
+ function closeModal() {
491
+ document.getElementById('modal-backdrop').classList.remove('open');
492
+ document.getElementById('modal-body').innerHTML = '';
493
+ document.getElementById('modal-foot').innerHTML = '';
494
+ }
495
+ document.addEventListener('keydown', (e) => {
496
+ if (e.key === 'Escape' && document.getElementById('modal-backdrop').classList.contains('open')) closeModal();
497
+ });
397
498
  function fmtDuration(ms) {
398
499
  if (!Number.isFinite(ms) || ms < 0) return '—';
399
500
  const s = Math.floor(ms / 1000);
@@ -422,9 +523,27 @@
422
523
  LOADERS.chat = async function loadChat() {
423
524
  try {
424
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 || []);
425
530
  const sel = document.getElementById('chat-assignee');
426
531
  sel.innerHTML = '';
427
- 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) {
428
547
  const ms = (p.suggestedModels || []);
429
548
  if (!ms.length) {
430
549
  const opt = document.createElement('option');
@@ -439,6 +558,18 @@
439
558
  sel.appendChild(opt);
440
559
  }
441
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
+ }
442
573
  } catch (e) {
443
574
  document.getElementById('chat-meta').textContent = '⚠ ' + e.message;
444
575
  }
@@ -457,20 +588,76 @@
457
588
  root.innerHTML = '';
458
589
  arr.forEach((s) => {
459
590
  const div = document.createElement('div');
460
- div.className = 'card row';
591
+ div.className = 'card row clickable';
461
592
  const id = s.id || s.sessionId || s.name || JSON.stringify(s);
462
593
  const turns = s.turns ?? s.turnCount ?? '';
463
594
  const updated = s.updatedAt || s.mtime || '';
464
- div.innerHTML = `<div class="name">${id}</div>
595
+ div.innerHTML = `<div class="name">${escHtml(id)}</div>
465
596
  <div class="dim">${turns ? turns + ' turns' : ''}</div>
466
- <div class="dim" style="margin-left:auto;">${updated}</div>`;
597
+ <div class="dim row-actions">${escHtml(updated)}</div>
598
+ <button class="btn btn-secondary btn-sm" data-action="view">View</button>
599
+ <button class="btn btn-secondary btn-sm" data-action="export">Export</button>
600
+ <button class="btn btn-danger btn-sm" data-action="delete">Delete</button>`;
601
+ div.addEventListener('click', (e) => {
602
+ const action = e.target.closest('button')?.dataset.action;
603
+ if (action === 'export') return openSessionExport(id);
604
+ if (action === 'delete') return deleteSession(id);
605
+ return openSessionDetail(id);
606
+ });
467
607
  root.appendChild(div);
468
608
  });
469
609
  } catch (e) {
470
- root.innerHTML = `<div class="empty">⚠ ${e.message}</div>`;
610
+ root.innerHTML = `<div class="empty">⚠ ${escHtml(e.message)}</div>`;
471
611
  }
472
612
  };
473
613
 
614
+ async function openSessionDetail(id) {
615
+ openModal({ title: `Session — ${id}`, bodyHtml: '<div class="empty">Loading…</div>' });
616
+ try {
617
+ const r = await api('/sessions/' + encodeURIComponent(id));
618
+ const turns = r.turns || r.entries || r;
619
+ if (!Array.isArray(turns) || turns.length === 0) {
620
+ document.getElementById('modal-body').innerHTML = '<div class="empty">Empty session.</div>';
621
+ return;
622
+ }
623
+ const html = turns.map((t) => {
624
+ const role = (t.role || 'note').toLowerCase();
625
+ const content = String(t.content ?? t.text ?? '');
626
+ const ts = t.ts || t.timestamp || '';
627
+ return `<div class="turn ${escHtml(role)}">
628
+ <span class="role-tag">${escHtml(role)}${ts ? ' · ' + escHtml(ts) : ''}</span>${escHtml(content)}
629
+ </div>`;
630
+ }).join('');
631
+ document.getElementById('modal-body').innerHTML = html;
632
+ } catch (e) {
633
+ document.getElementById('modal-body').innerHTML = `<div class="empty">⚠ ${escHtml(e.message)}</div>`;
634
+ }
635
+ }
636
+
637
+ async function openSessionExport(id) {
638
+ openModal({ title: `Export — ${id}`, bodyHtml: '<div class="empty">Loading…</div>' });
639
+ try {
640
+ const r = await fetch('/sessions/' + encodeURIComponent(id) + '/export?format=md');
641
+ const text = await r.text();
642
+ document.getElementById('modal-body').innerHTML = `<pre>${escHtml(text)}</pre>`;
643
+ document.getElementById('modal-foot').innerHTML = `
644
+ <button class="btn btn-secondary" onclick="navigator.clipboard.writeText(${JSON.stringify(text)}); this.textContent='Copied!'; setTimeout(()=>this.textContent='Copy markdown',1200)">Copy markdown</button>
645
+ <button class="btn" onclick="closeModal()">Close</button>`;
646
+ } catch (e) {
647
+ document.getElementById('modal-body').innerHTML = `<div class="empty">⚠ ${escHtml(e.message)}</div>`;
648
+ }
649
+ }
650
+
651
+ async function deleteSession(id) {
652
+ if (!confirm(`Delete session "${id}"?\nTurn log will be permanently removed.`)) return;
653
+ try {
654
+ await fetch('/sessions/' + encodeURIComponent(id), { method: 'DELETE' });
655
+ LOADERS.sessions();
656
+ } catch (e) {
657
+ alert('Delete failed: ' + e.message);
658
+ }
659
+ }
660
+
474
661
  LOADERS.skills = async function loadSkills() {
475
662
  const root = document.getElementById('skills-list');
476
663
  root.innerHTML = '<div class="empty">Loading…</div>';
@@ -484,25 +671,60 @@
484
671
  root.innerHTML = '';
485
672
  arr.forEach((s) => {
486
673
  const div = document.createElement('div');
487
- div.className = 'card';
674
+ div.className = 'card clickable';
488
675
  div.innerHTML = `<div class="row" style="border:0;padding:0;">
489
- <div class="name">${s.name}</div>
490
- <div class="dim" style="margin-left:auto;">${(s.bytes ?? '')} bytes</div>
676
+ <div class="name">${escHtml(s.name)}</div>
677
+ <div class="dim row-actions">${(s.bytes ?? '')} bytes</div>
678
+ <button class="btn btn-secondary btn-sm" data-action="view">View</button>
679
+ <button class="btn btn-danger btn-sm" data-action="delete">Delete</button>
491
680
  </div>
492
- <div class="dim" style="margin-top:6px;">${s.summary || ''}</div>`;
681
+ <div class="dim" style="margin-top:6px;">${escHtml(s.summary || '')}</div>`;
682
+ div.addEventListener('click', (e) => {
683
+ const action = e.target.closest('button')?.dataset.action;
684
+ if (action === 'delete') return deleteSkill(s.name);
685
+ return openSkillDetail(s.name);
686
+ });
493
687
  root.appendChild(div);
494
688
  });
495
689
  } catch (e) {
496
- root.innerHTML = `<div class="empty">⚠ ${e.message}</div>`;
690
+ root.innerHTML = `<div class="empty">⚠ ${escHtml(e.message)}</div>`;
497
691
  }
498
692
  };
499
693
 
694
+ async function openSkillDetail(name) {
695
+ openModal({ title: `Skill — ${name}`, bodyHtml: '<div class="empty">Loading…</div>' });
696
+ try {
697
+ // GET /skills/<name> returns the markdown body as text/markdown.
698
+ const r = await fetch('/skills/' + encodeURIComponent(name));
699
+ if (!r.ok) throw new Error(`${r.status} ${r.statusText}`);
700
+ const text = await r.text();
701
+ document.getElementById('modal-body').innerHTML = `<pre class="markdown">${escHtml(text)}</pre>`;
702
+ document.getElementById('modal-foot').innerHTML = `
703
+ <button class="btn btn-secondary" onclick="navigator.clipboard.writeText(${JSON.stringify(text)}); this.textContent='Copied!'; setTimeout(()=>this.textContent='Copy',1200)">Copy</button>
704
+ <button class="btn" onclick="closeModal()">Close</button>`;
705
+ } catch (e) {
706
+ document.getElementById('modal-body').innerHTML = `<div class="empty">⚠ ${escHtml(e.message)}</div>`;
707
+ }
708
+ }
709
+
710
+ async function deleteSkill(name) {
711
+ if (!confirm(`Remove skill "${name}"?`)) return;
712
+ try {
713
+ await fetch('/skills/' + encodeURIComponent(name), { method: 'DELETE' });
714
+ LOADERS.skills();
715
+ } catch (e) {
716
+ alert('Delete failed: ' + e.message);
717
+ }
718
+ }
719
+
500
720
  LOADERS.providers = async function loadProviders() {
501
721
  const root = document.getElementById('providers-list');
722
+ const meta = document.getElementById('providers-meta');
502
723
  root.innerHTML = '<div class="empty">Loading…</div>';
503
724
  try {
504
725
  const r = await api('/providers');
505
726
  const arr = r.providers || r;
727
+ meta.textContent = `${arr.length} registered`;
506
728
  root.innerHTML = '';
507
729
  arr.forEach((p) => {
508
730
  const div = document.createElement('div');
@@ -510,20 +732,132 @@
510
732
  const tag = p.requiresApiKey
511
733
  ? '<span class="pill warn">api key</span>'
512
734
  : '<span class="pill ok">no key</span>';
735
+ const customTag = p.custom ? ' <span class="pill" style="background:rgba(217,119,87,0.18);color:var(--accent);">custom</span>' : '';
736
+ const builtinCompat = p.builtinOpenAICompat ? ' <span class="pill" style="background:rgba(74,222,128,0.12);color:var(--ok);">openai-compat</span>' : '';
513
737
  const models = (p.suggestedModels || []).slice(0, 6).join(' · ') || '<span class="dim">(default)</span>';
738
+ const removeBtn = p.custom
739
+ ? `<button class="btn btn-danger btn-sm" data-action="remove">Remove</button>`
740
+ : '';
514
741
  div.innerHTML = `<div class="row" style="border:0;padding:0;">
515
- <div class="name">${p.name}</div>${tag}
516
- <div class="dim" style="margin-left:auto;">${p.endpoint || ''}</div>
742
+ <div class="name">${escHtml(p.name)}</div>${tag}${customTag}${builtinCompat}
743
+ <div class="dim row-actions">${escHtml(p.endpoint || '')}</div>
744
+ <button class="btn btn-secondary btn-sm" data-action="test">Test</button>
745
+ ${removeBtn}
517
746
  </div>
518
- <div class="dim" style="margin-top:6px;">${p.docs || ''}</div>
519
- <div style="margin-top:8px;font-size:12px;">${models}</div>`;
747
+ <div class="dim" style="margin-top:6px;">${escHtml(p.docs || '')}</div>
748
+ <div style="margin-top:8px;font-size:12px;">${models}</div>
749
+ <div class="dim" data-test-result style="margin-top:6px;font-size:11px;"></div>`;
750
+ div.addEventListener('click', async (e) => {
751
+ const btn = e.target.closest('button');
752
+ if (!btn) return;
753
+ if (btn.dataset.action === 'test') return testProvider(p.name, div);
754
+ if (btn.dataset.action === 'remove') return removeProvider(p.name);
755
+ });
520
756
  root.appendChild(div);
521
757
  });
522
758
  } catch (e) {
523
- root.innerHTML = `<div class="empty">⚠ ${e.message}</div>`;
759
+ root.innerHTML = `<div class="empty">⚠ ${escHtml(e.message)}</div>`;
524
760
  }
525
761
  };
526
762
 
763
+ async function testProvider(name, cardEl) {
764
+ const out = cardEl.querySelector('[data-test-result]');
765
+ out.textContent = '⏳ probing…';
766
+ out.style.color = 'var(--dim)';
767
+ try {
768
+ const r = await fetch('/providers/' + encodeURIComponent(name) + '/test');
769
+ const body = await r.json();
770
+ if (body.ok) {
771
+ out.style.color = 'var(--ok)';
772
+ const reply = (body.reply || '').replace(/\s+/g, ' ').slice(0, 120);
773
+ out.textContent = `✓ ok · ${body.model} · ${body.durationMs}ms${reply ? ' · ' + reply : ''}`;
774
+ } else {
775
+ out.style.color = 'var(--err)';
776
+ out.textContent = `✗ ${body.error || 'failed'} · ${body.code || r.status}`;
777
+ }
778
+ } catch (e) {
779
+ out.style.color = 'var(--err)';
780
+ out.textContent = '✗ ' + (e.message || String(e));
781
+ }
782
+ }
783
+
784
+ async function removeProvider(name) {
785
+ if (!confirm(`Remove custom provider "${name}"?`)) return;
786
+ try {
787
+ const r = await fetch('/providers/' + encodeURIComponent(name), { method: 'DELETE' });
788
+ const body = await r.json();
789
+ if (!r.ok) throw new Error(body.error || `${r.status}`);
790
+ LOADERS.providers();
791
+ } catch (e) {
792
+ alert('Remove failed: ' + e.message);
793
+ }
794
+ }
795
+
796
+ function openAddProviderModal() {
797
+ openModal({
798
+ title: 'Add custom OpenAI-compat provider',
799
+ bodyHtml: `
800
+ <div class="dim" style="margin-bottom:14px;font-size:12px;">
801
+ Works with any service that speaks the OpenAI v1 wire format
802
+ (vLLM · LM Studio · private gateways · self-hosted DeepInfra).
803
+ Built-in aliases (<code>nim</code>, <code>openrouter</code>, <code>groq</code>, …)
804
+ can be overridden by registering a custom entry of the same name.
805
+ </div>
806
+ <div class="form-row">
807
+ <label for="add-prov-name">Name (short id, e.g. "nim", "openrouter")</label>
808
+ <input id="add-prov-name" autofocus placeholder="e.g. my-vllm" />
809
+ </div>
810
+ <div class="form-row">
811
+ <label for="add-prov-baseurl">Base URL (must end in /v1)</label>
812
+ <input id="add-prov-baseurl" placeholder="https://integrate.api.nvidia.com/v1" />
813
+ </div>
814
+ <div class="form-row">
815
+ <label for="add-prov-apikey">API key (blank for auth-less endpoints)</label>
816
+ <input id="add-prov-apikey" type="password" placeholder="nvapi-…" />
817
+ </div>
818
+ <div class="form-row">
819
+ <label for="add-prov-model">Default model id (optional)</label>
820
+ <input id="add-prov-model" placeholder="meta/llama-3.1-405b-instruct" />
821
+ </div>
822
+ <div id="add-prov-status" class="dim" style="font-size:12px;"></div>
823
+ `,
824
+ footHtml: `
825
+ <button class="btn btn-secondary" onclick="closeModal()">Cancel</button>
826
+ <button class="btn" onclick="submitAddProvider()">Save</button>
827
+ `,
828
+ });
829
+ }
830
+
831
+ async function submitAddProvider() {
832
+ const name = document.getElementById('add-prov-name').value.trim();
833
+ const baseUrl = document.getElementById('add-prov-baseurl').value.trim();
834
+ const apiKey = document.getElementById('add-prov-apikey').value.trim();
835
+ const defaultModel = document.getElementById('add-prov-model').value.trim();
836
+ const status = document.getElementById('add-prov-status');
837
+ status.style.color = 'var(--dim)';
838
+ status.textContent = 'Saving…';
839
+ try {
840
+ const r = await fetch('/providers', {
841
+ method: 'POST',
842
+ headers: { 'content-type': 'application/json' },
843
+ body: JSON.stringify({ name, baseUrl, apiKey: apiKey || undefined, defaultModel: defaultModel || undefined }),
844
+ });
845
+ const body = await r.json();
846
+ if (!r.ok) {
847
+ status.style.color = 'var(--err)';
848
+ status.textContent = '✗ ' + (body.error || `${r.status} ${r.statusText}`);
849
+ return;
850
+ }
851
+ status.style.color = 'var(--ok)';
852
+ const overrideNote = body.overridesBuiltin ? ' (overrides built-in)' : '';
853
+ status.textContent = `✓ saved — ${body.name} → ${body.baseUrl}${overrideNote}`;
854
+ setTimeout(() => { closeModal(); LOADERS.providers(); }, 700);
855
+ } catch (e) {
856
+ status.style.color = 'var(--err)';
857
+ status.textContent = '✗ ' + (e.message || String(e));
858
+ }
859
+ }
860
+
527
861
  LOADERS.status = async function loadStatus() {
528
862
  const root = document.getElementById('status-card');
529
863
  root.innerHTML = '<div class="empty">Loading…</div>';
@@ -584,23 +918,79 @@
584
918
  if (sm.resumable) tags.push('<span class="pill warn">resumable</span>');
585
919
  if (sm.done) tags.push('<span class="pill ok">done</span>');
586
920
  const total = sm.total ?? '';
587
- return `<tr>
921
+ return `<tr class="clickable" data-wf-id="${escHtml(s.sessionId)}">
588
922
  <td><code>${escHtml(s.sessionId)}</code></td>
589
923
  <td>${tags.join(' ') || '<span class="dim">—</span>'}</td>
590
924
  <td class="num">${sm.done ?? 0} / ${total}</td>
591
925
  <td class="num">${sm.failed ?? 0}</td>
592
926
  <td class="dim">${escHtml(s.updatedAt || s.startedAt || '')}</td>
927
+ <td><button class="btn btn-danger btn-sm" data-action="wf-delete">Delete</button></td>
593
928
  </tr>`;
594
929
  }).join('');
595
930
  list.innerHTML = `<table class="tbl">
596
- <thead><tr><th>Session</th><th>State</th><th>Done / Total</th><th>Failed</th><th>Updated</th></tr></thead>
931
+ <thead><tr><th>Session</th><th>State</th><th>Done / Total</th><th>Failed</th><th>Updated</th><th></th></tr></thead>
597
932
  <tbody>${rows}</tbody>
598
933
  </table>`;
934
+ list.querySelectorAll('tr[data-wf-id]').forEach((tr) => {
935
+ tr.addEventListener('click', (e) => {
936
+ const id = tr.getAttribute('data-wf-id');
937
+ const action = e.target.closest('button')?.dataset.action;
938
+ if (action === 'wf-delete') return deleteWorkflow(id);
939
+ return openWorkflowDetail(id);
940
+ });
941
+ });
599
942
  } catch (e) {
600
943
  list.innerHTML = `<div class="empty">⚠ ${escHtml(e.message)}</div>`;
601
944
  }
602
945
  };
603
946
 
947
+ async function openWorkflowDetail(id) {
948
+ openModal({ title: `Workflow — ${id}`, bodyHtml: '<div class="empty">Loading…</div>' });
949
+ try {
950
+ const r = await api('/workflows/' + encodeURIComponent(id));
951
+ const sm = r.summary || {};
952
+ const nodes = r.state?.nodeResults || r.nodeResults || {};
953
+ const nodeRows = Object.entries(nodes).map(([nid, n]) => {
954
+ const status = (n.status || '').toLowerCase();
955
+ const pillClass = status === 'failed' ? 'err' : (status === 'done' ? 'ok' : (status === 'running' ? 'warn' : ''));
956
+ const dur = n.durationMs != null ? fmtDuration(n.durationMs) : '—';
957
+ const out = String(n.output ?? n.error ?? '');
958
+ const truncated = out.length > 240 ? out.slice(0, 240) + '…' : out;
959
+ return `<tr>
960
+ <td><code>${escHtml(nid)}</code></td>
961
+ <td>${pillClass ? `<span class="pill ${pillClass}">${escHtml(status)}</span>` : escHtml(status || '—')}</td>
962
+ <td class="num">${dur}</td>
963
+ <td class="dim">${escHtml(truncated)}</td>
964
+ </tr>`;
965
+ }).join('');
966
+ const summaryHtml = `<div class="grid" style="margin-bottom:14px;">
967
+ <div class="stat"><div class="label">Total</div><div class="value">${sm.total ?? '—'}</div></div>
968
+ <div class="stat"><div class="label">Done</div><div class="value">${sm.done ?? 0}</div></div>
969
+ <div class="stat"><div class="label">Failed</div><div class="value" style="color:${sm.failed ? 'var(--err)' : 'inherit'}">${sm.failed ?? 0}</div></div>
970
+ <div class="stat"><div class="label">Running</div><div class="value">${sm.running ?? 0}</div></div>
971
+ </div>`;
972
+ const tableHtml = nodeRows
973
+ ? `<table class="tbl">
974
+ <thead><tr><th>Node</th><th>Status</th><th>Duration</th><th>Output / Error</th></tr></thead>
975
+ <tbody>${nodeRows}</tbody>
976
+ </table>`
977
+ : '<div class="empty">No node results yet.</div>';
978
+ document.getElementById('modal-body').innerHTML = summaryHtml + tableHtml;
979
+ } catch (e) {
980
+ document.getElementById('modal-body').innerHTML = `<div class="empty">⚠ ${escHtml(e.message)}</div>`;
981
+ }
982
+ }
983
+
984
+ async function deleteWorkflow(id) {
985
+ if (!confirm(`Delete workflow session "${id}"?\nState file will be permanently removed.`)) return;
986
+ try {
987
+ await fetch('/workflows/' + encodeURIComponent(id), { method: 'DELETE' });
988
+ LOADERS.workflows();
989
+ } catch (e) {
990
+ alert('Delete failed: ' + e.message);
991
+ }
992
+ }
993
+
604
994
  // ── Rates ────────────────────────────────────────────────────
605
995
  document.getElementById('rates-filter').addEventListener('input', debounce(() => LOADERS.rates(), 250));
606
996
 
@@ -638,24 +1028,115 @@
638
1028
  }
639
1029
  const rows = entries.map(([key, card]) => {
640
1030
  const c = card || {};
641
- return `<tr>
1031
+ return `<tr data-rate-key="${escHtml(key)}">
642
1032
  <td><code>${escHtml(key)}</code></td>
643
1033
  <td class="num">${c.in ?? '—'}</td>
644
1034
  <td class="num">${c.out ?? '—'}</td>
645
1035
  <td class="num">${c['cache-read'] ?? '—'}</td>
646
1036
  <td class="num">${c['cache-create'] ?? '—'}</td>
647
1037
  <td class="dim">${escHtml(c.currency || 'USD')} / 1M tok</td>
1038
+ <td>
1039
+ <button class="btn btn-secondary btn-sm" data-action="rate-edit">Edit</button>
1040
+ <button class="btn btn-danger btn-sm" data-action="rate-delete">Delete</button>
1041
+ </td>
648
1042
  </tr>`;
649
1043
  }).join('');
650
1044
  root.innerHTML = `<table class="tbl">
651
- <thead><tr><th>Provider / Model</th><th>In</th><th>Out</th><th>Cache read</th><th>Cache create</th><th>Unit</th></tr></thead>
1045
+ <thead><tr><th>Provider / Model</th><th>In</th><th>Out</th><th>Cache read</th><th>Cache create</th><th>Unit</th><th></th></tr></thead>
652
1046
  <tbody>${rows}</tbody>
653
1047
  </table>`;
1048
+ root.querySelectorAll('tr[data-rate-key]').forEach((tr) => {
1049
+ const key = tr.getAttribute('data-rate-key');
1050
+ const card = (rates || {})[key] || {};
1051
+ tr.querySelector('[data-action="rate-edit"]')?.addEventListener('click', () => openRateCardModal(key, card));
1052
+ tr.querySelector('[data-action="rate-delete"]')?.addEventListener('click', () => deleteRateCard(key));
1053
+ });
654
1054
  } catch (e) {
655
1055
  root.innerHTML = `<div class="empty">⚠ ${escHtml(e.message)}</div>`;
656
1056
  }
657
1057
  };
658
1058
 
1059
+ function openRateCardModal(existingKey = '', existingCard = {}) {
1060
+ const c = existingCard || {};
1061
+ openModal({
1062
+ title: existingKey ? `Edit rate card — ${existingKey}` : 'Add rate card',
1063
+ bodyHtml: `
1064
+ <div class="dim" style="margin-bottom:12px;font-size:12px;">
1065
+ Cost per 1M tokens (input / output / optional cache pricing).
1066
+ Same shape as <code>lazyclaw rates set</code>. Saving the same
1067
+ key overwrites the existing card.
1068
+ </div>
1069
+ <div class="form-row">
1070
+ <label for="rate-key">Provider / model key</label>
1071
+ <input id="rate-key" placeholder="anthropic/claude-opus-4-7" value="${escHtml(existingKey)}" ${existingKey ? 'readonly' : ''}/>
1072
+ </div>
1073
+ <div class="grid" style="grid-template-columns:1fr 1fr;gap:10px;margin-bottom:0;">
1074
+ <div class="form-row"><label for="rate-in">Input (USD / 1M)</label><input id="rate-in" type="number" step="0.01" value="${c.in ?? ''}"/></div>
1075
+ <div class="form-row"><label for="rate-out">Output (USD / 1M)</label><input id="rate-out" type="number" step="0.01" value="${c.out ?? ''}"/></div>
1076
+ <div class="form-row"><label for="rate-cache-read">Cache read (optional)</label><input id="rate-cache-read" type="number" step="0.01" value="${c['cache-read'] ?? ''}"/></div>
1077
+ <div class="form-row"><label for="rate-cache-create">Cache create (optional)</label><input id="rate-cache-create" type="number" step="0.01" value="${c['cache-create'] ?? ''}"/></div>
1078
+ <div class="form-row"><label for="rate-currency">Currency</label><input id="rate-currency" value="${escHtml(c.currency || 'USD')}"/></div>
1079
+ </div>
1080
+ <div id="rate-status" class="dim" style="font-size:12px;margin-top:8px;"></div>
1081
+ `,
1082
+ footHtml: `
1083
+ <button class="btn btn-secondary" onclick="closeModal()">Cancel</button>
1084
+ <button class="btn" onclick="submitRateCard()">Save</button>
1085
+ `,
1086
+ });
1087
+ }
1088
+
1089
+ async function submitRateCard() {
1090
+ const key = document.getElementById('rate-key').value.trim();
1091
+ const status = document.getElementById('rate-status');
1092
+ if (!key) { status.style.color = 'var(--err)'; status.textContent = 'Key is required.'; return; }
1093
+ const card = {
1094
+ in: parseFloat(document.getElementById('rate-in').value) || 0,
1095
+ out: parseFloat(document.getElementById('rate-out').value) || 0,
1096
+ currency: document.getElementById('rate-currency').value.trim() || 'USD',
1097
+ };
1098
+ const cr = parseFloat(document.getElementById('rate-cache-read').value);
1099
+ const cc = parseFloat(document.getElementById('rate-cache-create').value);
1100
+ if (Number.isFinite(cr)) card['cache-read'] = cr;
1101
+ if (Number.isFinite(cc)) card['cache-create'] = cc;
1102
+ status.style.color = 'var(--dim)';
1103
+ status.textContent = 'Saving…';
1104
+ try {
1105
+ const r = await fetch('/rates/' + encodeURIComponent(key), {
1106
+ method: 'PUT',
1107
+ headers: { 'content-type': 'application/json' },
1108
+ body: JSON.stringify(card),
1109
+ });
1110
+ const body = await r.json();
1111
+ if (!r.ok) {
1112
+ status.style.color = 'var(--err)';
1113
+ const issues = (body.issues || []).map(i => typeof i === 'string' ? i : JSON.stringify(i)).join('; ');
1114
+ status.textContent = `✗ ${body.error || issues || `${r.status} ${r.statusText}`}`;
1115
+ return;
1116
+ }
1117
+ status.style.color = 'var(--ok)';
1118
+ status.textContent = `✓ saved`;
1119
+ setTimeout(() => { closeModal(); LOADERS.rates(); }, 600);
1120
+ } catch (e) {
1121
+ status.style.color = 'var(--err)';
1122
+ status.textContent = '✗ ' + (e.message || String(e));
1123
+ }
1124
+ }
1125
+
1126
+ async function deleteRateCard(key) {
1127
+ if (!confirm(`Delete rate card "${key}"?`)) return;
1128
+ try {
1129
+ const r = await fetch('/rates/' + encodeURIComponent(key), { method: 'DELETE' });
1130
+ if (!r.ok) {
1131
+ const body = await r.json().catch(() => ({}));
1132
+ throw new Error(body.error || `${r.status}`);
1133
+ }
1134
+ LOADERS.rates();
1135
+ } catch (e) {
1136
+ alert('Delete failed: ' + e.message);
1137
+ }
1138
+ }
1139
+
659
1140
  // ── Metrics ──────────────────────────────────────────────────
660
1141
  LOADERS.metrics = async function loadMetrics() {
661
1142
  const cards = document.getElementById('metrics-cards');
@@ -748,21 +1229,121 @@
748
1229
  root.innerHTML = '<div class="empty">No config yet. Run <code>lazyclaw onboard</code>.</div>';
749
1230
  return;
750
1231
  }
1232
+ const NESTED = new Set(['customProviders', 'rates', 'authProfiles', 'authActiveProfile']);
751
1233
  const rows = keys.sort().map((k) => {
752
1234
  const v = cfg[k];
753
1235
  const display = v && typeof v === 'object' ? JSON.stringify(v) : String(v);
754
- return `<tr><td><code>${escHtml(k)}</code></td><td>${escHtml(display)}</td></tr>`;
1236
+ const nested = NESTED.has(k);
1237
+ const editBtn = nested
1238
+ ? `<span class="dim" style="font-size:11px;">use the dedicated tab</span>`
1239
+ : `<button class="btn btn-secondary btn-sm" data-action="cfg-edit" data-key="${escHtml(k)}">Edit</button>
1240
+ <button class="btn btn-danger btn-sm" data-action="cfg-delete" data-key="${escHtml(k)}">Delete</button>`;
1241
+ return `<tr><td><code>${escHtml(k)}</code></td><td>${escHtml(display)}</td><td>${editBtn}</td></tr>`;
755
1242
  }).join('');
756
1243
  root.innerHTML = `<table class="tbl">
757
- <thead><tr><th style="width:30%">Key</th><th>Value</th></tr></thead>
1244
+ <thead><tr><th style="width:25%">Key</th><th>Value</th><th style="width:160px"></th></tr></thead>
758
1245
  <tbody>${rows}</tbody>
759
1246
  </table>`;
1247
+ root.querySelectorAll('[data-action="cfg-edit"]').forEach((b) => {
1248
+ b.addEventListener('click', () => openConfigEditModal(b.dataset.key, cfg[b.dataset.key]));
1249
+ });
1250
+ root.querySelectorAll('[data-action="cfg-delete"]').forEach((b) => {
1251
+ b.addEventListener('click', () => deleteConfigKey(b.dataset.key));
1252
+ });
760
1253
  raw.textContent = JSON.stringify(cfg, null, 2);
761
1254
  } catch (e) {
762
1255
  root.innerHTML = `<div class="empty">⚠ ${escHtml(e.message)}</div>`;
763
1256
  }
764
1257
  };
765
1258
 
1259
+ function openConfigEditModal(existingKey = '', existingValue = '') {
1260
+ // Stringify for the editor; objects/arrays become JSON, primitives stay
1261
+ // raw. Submitter parses JSON when the value looks like JSON, else
1262
+ // sends a string verbatim — same behaviour as `lazyclaw config set`.
1263
+ let display = '';
1264
+ if (typeof existingValue === 'string') display = existingValue;
1265
+ else if (existingValue == null) display = '';
1266
+ else display = JSON.stringify(existingValue, null, 2);
1267
+ openModal({
1268
+ title: existingKey ? `Edit config — ${existingKey}` : 'Set config key',
1269
+ bodyHtml: `
1270
+ <div class="dim" style="margin-bottom:12px;font-size:12px;">
1271
+ Mirrors <code>lazyclaw config set &lt;key&gt; &lt;value&gt;</code>. Values that look like
1272
+ JSON (start with <code>{</code> / <code>[</code> / <code>"</code> / <code>true</code> / <code>false</code> / a number)
1273
+ are parsed; everything else is stored as a plain string. Nested
1274
+ stores (<code>customProviders</code>, <code>rates</code>, <code>authProfiles</code>) have their own
1275
+ tabs — this form rejects them.
1276
+ </div>
1277
+ <div class="form-row">
1278
+ <label for="cfg-key">Key</label>
1279
+ <input id="cfg-key" placeholder="provider · model · api-key · skills · …" value="${escHtml(existingKey)}" ${existingKey ? 'readonly' : ''}/>
1280
+ </div>
1281
+ <div class="form-row">
1282
+ <label for="cfg-value">Value</label>
1283
+ <textarea id="cfg-value" rows="6">${escHtml(display)}</textarea>
1284
+ </div>
1285
+ <div id="cfg-status" class="dim" style="font-size:12px;"></div>
1286
+ `,
1287
+ footHtml: `
1288
+ <button class="btn btn-secondary" onclick="closeModal()">Cancel</button>
1289
+ <button class="btn" onclick="submitConfigEdit()">Save</button>
1290
+ `,
1291
+ });
1292
+ }
1293
+
1294
+ async function submitConfigEdit() {
1295
+ const key = document.getElementById('cfg-key').value.trim();
1296
+ const raw = document.getElementById('cfg-value').value;
1297
+ const status = document.getElementById('cfg-status');
1298
+ if (!key) { status.style.color = 'var(--err)'; status.textContent = 'Key is required.'; return; }
1299
+ // Heuristic JSON parse — same surface as the CLI: try parse; if it
1300
+ // throws, send the raw string. Numbers / true / false / null / objects /
1301
+ // arrays / quoted strings end up correctly typed.
1302
+ let value;
1303
+ const trimmed = raw.trim();
1304
+ if (trimmed === '') value = '';
1305
+ else {
1306
+ try { value = JSON.parse(trimmed); }
1307
+ catch { value = raw; }
1308
+ }
1309
+ status.style.color = 'var(--dim)';
1310
+ status.textContent = 'Saving…';
1311
+ try {
1312
+ const r = await fetch('/config/' + encodeURIComponent(key), {
1313
+ method: 'PUT',
1314
+ headers: { 'content-type': 'application/json' },
1315
+ body: JSON.stringify({ value }),
1316
+ });
1317
+ const body = await r.json();
1318
+ if (!r.ok) {
1319
+ status.style.color = 'var(--err)';
1320
+ const issues = (body.issues || []).map(i => typeof i === 'string' ? i : JSON.stringify(i)).join('; ');
1321
+ status.textContent = `✗ ${body.error || issues || `${r.status} ${r.statusText}`}`;
1322
+ return;
1323
+ }
1324
+ status.style.color = 'var(--ok)';
1325
+ status.textContent = '✓ saved';
1326
+ setTimeout(() => { closeModal(); LOADERS.config(); }, 600);
1327
+ } catch (e) {
1328
+ status.style.color = 'var(--err)';
1329
+ status.textContent = '✗ ' + (e.message || String(e));
1330
+ }
1331
+ }
1332
+
1333
+ async function deleteConfigKey(key) {
1334
+ if (!confirm(`Delete config key "${key}"?`)) return;
1335
+ try {
1336
+ const r = await fetch('/config/' + encodeURIComponent(key), { method: 'DELETE' });
1337
+ if (!r.ok) {
1338
+ const body = await r.json().catch(() => ({}));
1339
+ throw new Error(body.error || `${r.status}`);
1340
+ }
1341
+ LOADERS.config();
1342
+ } catch (e) {
1343
+ alert('Delete failed: ' + e.message);
1344
+ }
1345
+ }
1346
+
766
1347
  // First load = chat tab.
767
1348
  LOADERS.chat();
768
1349