lazyclaw 3.99.15 → 3.99.16

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 = [
@@ -2493,6 +2498,7 @@ async function cmdDashboard(flags = {}) {
2493
2498
  port,
2494
2499
  once: false,
2495
2500
  readConfig,
2501
+ writeConfig,
2496
2502
  sessionsDirGetter: () => cfgDir,
2497
2503
  sessionsMod,
2498
2504
  version: () => readVersionFromRepo(),
@@ -2598,6 +2604,12 @@ async function cmdDaemon(flags) {
2598
2604
  port: Number.isFinite(port) ? port : 0,
2599
2605
  once,
2600
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,
2601
2613
  sessionsDirGetter: () => cfgDir,
2602
2614
  sessionsMod,
2603
2615
  version: () => readVersionFromRepo(),
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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lazyclaw",
3
- "version": "3.99.15",
3
+ "version": "3.99.16",
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);
@@ -457,20 +558,76 @@
457
558
  root.innerHTML = '';
458
559
  arr.forEach((s) => {
459
560
  const div = document.createElement('div');
460
- div.className = 'card row';
561
+ div.className = 'card row clickable';
461
562
  const id = s.id || s.sessionId || s.name || JSON.stringify(s);
462
563
  const turns = s.turns ?? s.turnCount ?? '';
463
564
  const updated = s.updatedAt || s.mtime || '';
464
- div.innerHTML = `<div class="name">${id}</div>
565
+ div.innerHTML = `<div class="name">${escHtml(id)}</div>
465
566
  <div class="dim">${turns ? turns + ' turns' : ''}</div>
466
- <div class="dim" style="margin-left:auto;">${updated}</div>`;
567
+ <div class="dim row-actions">${escHtml(updated)}</div>
568
+ <button class="btn btn-secondary btn-sm" data-action="view">View</button>
569
+ <button class="btn btn-secondary btn-sm" data-action="export">Export</button>
570
+ <button class="btn btn-danger btn-sm" data-action="delete">Delete</button>`;
571
+ div.addEventListener('click', (e) => {
572
+ const action = e.target.closest('button')?.dataset.action;
573
+ if (action === 'export') return openSessionExport(id);
574
+ if (action === 'delete') return deleteSession(id);
575
+ return openSessionDetail(id);
576
+ });
467
577
  root.appendChild(div);
468
578
  });
469
579
  } catch (e) {
470
- root.innerHTML = `<div class="empty">⚠ ${e.message}</div>`;
580
+ root.innerHTML = `<div class="empty">⚠ ${escHtml(e.message)}</div>`;
471
581
  }
472
582
  };
473
583
 
584
+ async function openSessionDetail(id) {
585
+ openModal({ title: `Session — ${id}`, bodyHtml: '<div class="empty">Loading…</div>' });
586
+ try {
587
+ const r = await api('/sessions/' + encodeURIComponent(id));
588
+ const turns = r.turns || r.entries || r;
589
+ if (!Array.isArray(turns) || turns.length === 0) {
590
+ document.getElementById('modal-body').innerHTML = '<div class="empty">Empty session.</div>';
591
+ return;
592
+ }
593
+ const html = turns.map((t) => {
594
+ const role = (t.role || 'note').toLowerCase();
595
+ const content = String(t.content ?? t.text ?? '');
596
+ const ts = t.ts || t.timestamp || '';
597
+ return `<div class="turn ${escHtml(role)}">
598
+ <span class="role-tag">${escHtml(role)}${ts ? ' · ' + escHtml(ts) : ''}</span>${escHtml(content)}
599
+ </div>`;
600
+ }).join('');
601
+ document.getElementById('modal-body').innerHTML = html;
602
+ } catch (e) {
603
+ document.getElementById('modal-body').innerHTML = `<div class="empty">⚠ ${escHtml(e.message)}</div>`;
604
+ }
605
+ }
606
+
607
+ async function openSessionExport(id) {
608
+ openModal({ title: `Export — ${id}`, bodyHtml: '<div class="empty">Loading…</div>' });
609
+ try {
610
+ const r = await fetch('/sessions/' + encodeURIComponent(id) + '/export?format=md');
611
+ const text = await r.text();
612
+ document.getElementById('modal-body').innerHTML = `<pre>${escHtml(text)}</pre>`;
613
+ document.getElementById('modal-foot').innerHTML = `
614
+ <button class="btn btn-secondary" onclick="navigator.clipboard.writeText(${JSON.stringify(text)}); this.textContent='Copied!'; setTimeout(()=>this.textContent='Copy markdown',1200)">Copy markdown</button>
615
+ <button class="btn" onclick="closeModal()">Close</button>`;
616
+ } catch (e) {
617
+ document.getElementById('modal-body').innerHTML = `<div class="empty">⚠ ${escHtml(e.message)}</div>`;
618
+ }
619
+ }
620
+
621
+ async function deleteSession(id) {
622
+ if (!confirm(`Delete session "${id}"?\nTurn log will be permanently removed.`)) return;
623
+ try {
624
+ await fetch('/sessions/' + encodeURIComponent(id), { method: 'DELETE' });
625
+ LOADERS.sessions();
626
+ } catch (e) {
627
+ alert('Delete failed: ' + e.message);
628
+ }
629
+ }
630
+
474
631
  LOADERS.skills = async function loadSkills() {
475
632
  const root = document.getElementById('skills-list');
476
633
  root.innerHTML = '<div class="empty">Loading…</div>';
@@ -484,25 +641,60 @@
484
641
  root.innerHTML = '';
485
642
  arr.forEach((s) => {
486
643
  const div = document.createElement('div');
487
- div.className = 'card';
644
+ div.className = 'card clickable';
488
645
  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>
646
+ <div class="name">${escHtml(s.name)}</div>
647
+ <div class="dim row-actions">${(s.bytes ?? '')} bytes</div>
648
+ <button class="btn btn-secondary btn-sm" data-action="view">View</button>
649
+ <button class="btn btn-danger btn-sm" data-action="delete">Delete</button>
491
650
  </div>
492
- <div class="dim" style="margin-top:6px;">${s.summary || ''}</div>`;
651
+ <div class="dim" style="margin-top:6px;">${escHtml(s.summary || '')}</div>`;
652
+ div.addEventListener('click', (e) => {
653
+ const action = e.target.closest('button')?.dataset.action;
654
+ if (action === 'delete') return deleteSkill(s.name);
655
+ return openSkillDetail(s.name);
656
+ });
493
657
  root.appendChild(div);
494
658
  });
495
659
  } catch (e) {
496
- root.innerHTML = `<div class="empty">⚠ ${e.message}</div>`;
660
+ root.innerHTML = `<div class="empty">⚠ ${escHtml(e.message)}</div>`;
497
661
  }
498
662
  };
499
663
 
664
+ async function openSkillDetail(name) {
665
+ openModal({ title: `Skill — ${name}`, bodyHtml: '<div class="empty">Loading…</div>' });
666
+ try {
667
+ // GET /skills/<name> returns the markdown body as text/markdown.
668
+ const r = await fetch('/skills/' + encodeURIComponent(name));
669
+ if (!r.ok) throw new Error(`${r.status} ${r.statusText}`);
670
+ const text = await r.text();
671
+ document.getElementById('modal-body').innerHTML = `<pre class="markdown">${escHtml(text)}</pre>`;
672
+ document.getElementById('modal-foot').innerHTML = `
673
+ <button class="btn btn-secondary" onclick="navigator.clipboard.writeText(${JSON.stringify(text)}); this.textContent='Copied!'; setTimeout(()=>this.textContent='Copy',1200)">Copy</button>
674
+ <button class="btn" onclick="closeModal()">Close</button>`;
675
+ } catch (e) {
676
+ document.getElementById('modal-body').innerHTML = `<div class="empty">⚠ ${escHtml(e.message)}</div>`;
677
+ }
678
+ }
679
+
680
+ async function deleteSkill(name) {
681
+ if (!confirm(`Remove skill "${name}"?`)) return;
682
+ try {
683
+ await fetch('/skills/' + encodeURIComponent(name), { method: 'DELETE' });
684
+ LOADERS.skills();
685
+ } catch (e) {
686
+ alert('Delete failed: ' + e.message);
687
+ }
688
+ }
689
+
500
690
  LOADERS.providers = async function loadProviders() {
501
691
  const root = document.getElementById('providers-list');
692
+ const meta = document.getElementById('providers-meta');
502
693
  root.innerHTML = '<div class="empty">Loading…</div>';
503
694
  try {
504
695
  const r = await api('/providers');
505
696
  const arr = r.providers || r;
697
+ meta.textContent = `${arr.length} registered`;
506
698
  root.innerHTML = '';
507
699
  arr.forEach((p) => {
508
700
  const div = document.createElement('div');
@@ -510,20 +702,132 @@
510
702
  const tag = p.requiresApiKey
511
703
  ? '<span class="pill warn">api key</span>'
512
704
  : '<span class="pill ok">no key</span>';
705
+ const customTag = p.custom ? ' <span class="pill" style="background:rgba(217,119,87,0.18);color:var(--accent);">custom</span>' : '';
706
+ const builtinCompat = p.builtinOpenAICompat ? ' <span class="pill" style="background:rgba(74,222,128,0.12);color:var(--ok);">openai-compat</span>' : '';
513
707
  const models = (p.suggestedModels || []).slice(0, 6).join(' · ') || '<span class="dim">(default)</span>';
708
+ const removeBtn = p.custom
709
+ ? `<button class="btn btn-danger btn-sm" data-action="remove">Remove</button>`
710
+ : '';
514
711
  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>
712
+ <div class="name">${escHtml(p.name)}</div>${tag}${customTag}${builtinCompat}
713
+ <div class="dim row-actions">${escHtml(p.endpoint || '')}</div>
714
+ <button class="btn btn-secondary btn-sm" data-action="test">Test</button>
715
+ ${removeBtn}
517
716
  </div>
518
- <div class="dim" style="margin-top:6px;">${p.docs || ''}</div>
519
- <div style="margin-top:8px;font-size:12px;">${models}</div>`;
717
+ <div class="dim" style="margin-top:6px;">${escHtml(p.docs || '')}</div>
718
+ <div style="margin-top:8px;font-size:12px;">${models}</div>
719
+ <div class="dim" data-test-result style="margin-top:6px;font-size:11px;"></div>`;
720
+ div.addEventListener('click', async (e) => {
721
+ const btn = e.target.closest('button');
722
+ if (!btn) return;
723
+ if (btn.dataset.action === 'test') return testProvider(p.name, div);
724
+ if (btn.dataset.action === 'remove') return removeProvider(p.name);
725
+ });
520
726
  root.appendChild(div);
521
727
  });
522
728
  } catch (e) {
523
- root.innerHTML = `<div class="empty">⚠ ${e.message}</div>`;
729
+ root.innerHTML = `<div class="empty">⚠ ${escHtml(e.message)}</div>`;
524
730
  }
525
731
  };
526
732
 
733
+ async function testProvider(name, cardEl) {
734
+ const out = cardEl.querySelector('[data-test-result]');
735
+ out.textContent = '⏳ probing…';
736
+ out.style.color = 'var(--dim)';
737
+ try {
738
+ const r = await fetch('/providers/' + encodeURIComponent(name) + '/test');
739
+ const body = await r.json();
740
+ if (body.ok) {
741
+ out.style.color = 'var(--ok)';
742
+ const reply = (body.reply || '').replace(/\s+/g, ' ').slice(0, 120);
743
+ out.textContent = `✓ ok · ${body.model} · ${body.durationMs}ms${reply ? ' · ' + reply : ''}`;
744
+ } else {
745
+ out.style.color = 'var(--err)';
746
+ out.textContent = `✗ ${body.error || 'failed'} · ${body.code || r.status}`;
747
+ }
748
+ } catch (e) {
749
+ out.style.color = 'var(--err)';
750
+ out.textContent = '✗ ' + (e.message || String(e));
751
+ }
752
+ }
753
+
754
+ async function removeProvider(name) {
755
+ if (!confirm(`Remove custom provider "${name}"?`)) return;
756
+ try {
757
+ const r = await fetch('/providers/' + encodeURIComponent(name), { method: 'DELETE' });
758
+ const body = await r.json();
759
+ if (!r.ok) throw new Error(body.error || `${r.status}`);
760
+ LOADERS.providers();
761
+ } catch (e) {
762
+ alert('Remove failed: ' + e.message);
763
+ }
764
+ }
765
+
766
+ function openAddProviderModal() {
767
+ openModal({
768
+ title: 'Add custom OpenAI-compat provider',
769
+ bodyHtml: `
770
+ <div class="dim" style="margin-bottom:14px;font-size:12px;">
771
+ Works with any service that speaks the OpenAI v1 wire format
772
+ (vLLM · LM Studio · private gateways · self-hosted DeepInfra).
773
+ Built-in aliases (<code>nim</code>, <code>openrouter</code>, <code>groq</code>, …)
774
+ can be overridden by registering a custom entry of the same name.
775
+ </div>
776
+ <div class="form-row">
777
+ <label for="add-prov-name">Name (short id, e.g. "nim", "openrouter")</label>
778
+ <input id="add-prov-name" autofocus placeholder="e.g. my-vllm" />
779
+ </div>
780
+ <div class="form-row">
781
+ <label for="add-prov-baseurl">Base URL (must end in /v1)</label>
782
+ <input id="add-prov-baseurl" placeholder="https://integrate.api.nvidia.com/v1" />
783
+ </div>
784
+ <div class="form-row">
785
+ <label for="add-prov-apikey">API key (blank for auth-less endpoints)</label>
786
+ <input id="add-prov-apikey" type="password" placeholder="nvapi-…" />
787
+ </div>
788
+ <div class="form-row">
789
+ <label for="add-prov-model">Default model id (optional)</label>
790
+ <input id="add-prov-model" placeholder="meta/llama-3.1-405b-instruct" />
791
+ </div>
792
+ <div id="add-prov-status" class="dim" style="font-size:12px;"></div>
793
+ `,
794
+ footHtml: `
795
+ <button class="btn btn-secondary" onclick="closeModal()">Cancel</button>
796
+ <button class="btn" onclick="submitAddProvider()">Save</button>
797
+ `,
798
+ });
799
+ }
800
+
801
+ async function submitAddProvider() {
802
+ const name = document.getElementById('add-prov-name').value.trim();
803
+ const baseUrl = document.getElementById('add-prov-baseurl').value.trim();
804
+ const apiKey = document.getElementById('add-prov-apikey').value.trim();
805
+ const defaultModel = document.getElementById('add-prov-model').value.trim();
806
+ const status = document.getElementById('add-prov-status');
807
+ status.style.color = 'var(--dim)';
808
+ status.textContent = 'Saving…';
809
+ try {
810
+ const r = await fetch('/providers', {
811
+ method: 'POST',
812
+ headers: { 'content-type': 'application/json' },
813
+ body: JSON.stringify({ name, baseUrl, apiKey: apiKey || undefined, defaultModel: defaultModel || undefined }),
814
+ });
815
+ const body = await r.json();
816
+ if (!r.ok) {
817
+ status.style.color = 'var(--err)';
818
+ status.textContent = '✗ ' + (body.error || `${r.status} ${r.statusText}`);
819
+ return;
820
+ }
821
+ status.style.color = 'var(--ok)';
822
+ const overrideNote = body.overridesBuiltin ? ' (overrides built-in)' : '';
823
+ status.textContent = `✓ saved — ${body.name} → ${body.baseUrl}${overrideNote}`;
824
+ setTimeout(() => { closeModal(); LOADERS.providers(); }, 700);
825
+ } catch (e) {
826
+ status.style.color = 'var(--err)';
827
+ status.textContent = '✗ ' + (e.message || String(e));
828
+ }
829
+ }
830
+
527
831
  LOADERS.status = async function loadStatus() {
528
832
  const root = document.getElementById('status-card');
529
833
  root.innerHTML = '<div class="empty">Loading…</div>';
@@ -584,23 +888,79 @@
584
888
  if (sm.resumable) tags.push('<span class="pill warn">resumable</span>');
585
889
  if (sm.done) tags.push('<span class="pill ok">done</span>');
586
890
  const total = sm.total ?? '';
587
- return `<tr>
891
+ return `<tr class="clickable" data-wf-id="${escHtml(s.sessionId)}">
588
892
  <td><code>${escHtml(s.sessionId)}</code></td>
589
893
  <td>${tags.join(' ') || '<span class="dim">—</span>'}</td>
590
894
  <td class="num">${sm.done ?? 0} / ${total}</td>
591
895
  <td class="num">${sm.failed ?? 0}</td>
592
896
  <td class="dim">${escHtml(s.updatedAt || s.startedAt || '')}</td>
897
+ <td><button class="btn btn-danger btn-sm" data-action="wf-delete">Delete</button></td>
593
898
  </tr>`;
594
899
  }).join('');
595
900
  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>
901
+ <thead><tr><th>Session</th><th>State</th><th>Done / Total</th><th>Failed</th><th>Updated</th><th></th></tr></thead>
597
902
  <tbody>${rows}</tbody>
598
903
  </table>`;
904
+ list.querySelectorAll('tr[data-wf-id]').forEach((tr) => {
905
+ tr.addEventListener('click', (e) => {
906
+ const id = tr.getAttribute('data-wf-id');
907
+ const action = e.target.closest('button')?.dataset.action;
908
+ if (action === 'wf-delete') return deleteWorkflow(id);
909
+ return openWorkflowDetail(id);
910
+ });
911
+ });
599
912
  } catch (e) {
600
913
  list.innerHTML = `<div class="empty">⚠ ${escHtml(e.message)}</div>`;
601
914
  }
602
915
  };
603
916
 
917
+ async function openWorkflowDetail(id) {
918
+ openModal({ title: `Workflow — ${id}`, bodyHtml: '<div class="empty">Loading…</div>' });
919
+ try {
920
+ const r = await api('/workflows/' + encodeURIComponent(id));
921
+ const sm = r.summary || {};
922
+ const nodes = r.state?.nodeResults || r.nodeResults || {};
923
+ const nodeRows = Object.entries(nodes).map(([nid, n]) => {
924
+ const status = (n.status || '').toLowerCase();
925
+ const pillClass = status === 'failed' ? 'err' : (status === 'done' ? 'ok' : (status === 'running' ? 'warn' : ''));
926
+ const dur = n.durationMs != null ? fmtDuration(n.durationMs) : '—';
927
+ const out = String(n.output ?? n.error ?? '');
928
+ const truncated = out.length > 240 ? out.slice(0, 240) + '…' : out;
929
+ return `<tr>
930
+ <td><code>${escHtml(nid)}</code></td>
931
+ <td>${pillClass ? `<span class="pill ${pillClass}">${escHtml(status)}</span>` : escHtml(status || '—')}</td>
932
+ <td class="num">${dur}</td>
933
+ <td class="dim">${escHtml(truncated)}</td>
934
+ </tr>`;
935
+ }).join('');
936
+ const summaryHtml = `<div class="grid" style="margin-bottom:14px;">
937
+ <div class="stat"><div class="label">Total</div><div class="value">${sm.total ?? '—'}</div></div>
938
+ <div class="stat"><div class="label">Done</div><div class="value">${sm.done ?? 0}</div></div>
939
+ <div class="stat"><div class="label">Failed</div><div class="value" style="color:${sm.failed ? 'var(--err)' : 'inherit'}">${sm.failed ?? 0}</div></div>
940
+ <div class="stat"><div class="label">Running</div><div class="value">${sm.running ?? 0}</div></div>
941
+ </div>`;
942
+ const tableHtml = nodeRows
943
+ ? `<table class="tbl">
944
+ <thead><tr><th>Node</th><th>Status</th><th>Duration</th><th>Output / Error</th></tr></thead>
945
+ <tbody>${nodeRows}</tbody>
946
+ </table>`
947
+ : '<div class="empty">No node results yet.</div>';
948
+ document.getElementById('modal-body').innerHTML = summaryHtml + tableHtml;
949
+ } catch (e) {
950
+ document.getElementById('modal-body').innerHTML = `<div class="empty">⚠ ${escHtml(e.message)}</div>`;
951
+ }
952
+ }
953
+
954
+ async function deleteWorkflow(id) {
955
+ if (!confirm(`Delete workflow session "${id}"?\nState file will be permanently removed.`)) return;
956
+ try {
957
+ await fetch('/workflows/' + encodeURIComponent(id), { method: 'DELETE' });
958
+ LOADERS.workflows();
959
+ } catch (e) {
960
+ alert('Delete failed: ' + e.message);
961
+ }
962
+ }
963
+
604
964
  // ── Rates ────────────────────────────────────────────────────
605
965
  document.getElementById('rates-filter').addEventListener('input', debounce(() => LOADERS.rates(), 250));
606
966
 
@@ -638,24 +998,115 @@
638
998
  }
639
999
  const rows = entries.map(([key, card]) => {
640
1000
  const c = card || {};
641
- return `<tr>
1001
+ return `<tr data-rate-key="${escHtml(key)}">
642
1002
  <td><code>${escHtml(key)}</code></td>
643
1003
  <td class="num">${c.in ?? '—'}</td>
644
1004
  <td class="num">${c.out ?? '—'}</td>
645
1005
  <td class="num">${c['cache-read'] ?? '—'}</td>
646
1006
  <td class="num">${c['cache-create'] ?? '—'}</td>
647
1007
  <td class="dim">${escHtml(c.currency || 'USD')} / 1M tok</td>
1008
+ <td>
1009
+ <button class="btn btn-secondary btn-sm" data-action="rate-edit">Edit</button>
1010
+ <button class="btn btn-danger btn-sm" data-action="rate-delete">Delete</button>
1011
+ </td>
648
1012
  </tr>`;
649
1013
  }).join('');
650
1014
  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>
1015
+ <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
1016
  <tbody>${rows}</tbody>
653
1017
  </table>`;
1018
+ root.querySelectorAll('tr[data-rate-key]').forEach((tr) => {
1019
+ const key = tr.getAttribute('data-rate-key');
1020
+ const card = (rates || {})[key] || {};
1021
+ tr.querySelector('[data-action="rate-edit"]')?.addEventListener('click', () => openRateCardModal(key, card));
1022
+ tr.querySelector('[data-action="rate-delete"]')?.addEventListener('click', () => deleteRateCard(key));
1023
+ });
654
1024
  } catch (e) {
655
1025
  root.innerHTML = `<div class="empty">⚠ ${escHtml(e.message)}</div>`;
656
1026
  }
657
1027
  };
658
1028
 
1029
+ function openRateCardModal(existingKey = '', existingCard = {}) {
1030
+ const c = existingCard || {};
1031
+ openModal({
1032
+ title: existingKey ? `Edit rate card — ${existingKey}` : 'Add rate card',
1033
+ bodyHtml: `
1034
+ <div class="dim" style="margin-bottom:12px;font-size:12px;">
1035
+ Cost per 1M tokens (input / output / optional cache pricing).
1036
+ Same shape as <code>lazyclaw rates set</code>. Saving the same
1037
+ key overwrites the existing card.
1038
+ </div>
1039
+ <div class="form-row">
1040
+ <label for="rate-key">Provider / model key</label>
1041
+ <input id="rate-key" placeholder="anthropic/claude-opus-4-7" value="${escHtml(existingKey)}" ${existingKey ? 'readonly' : ''}/>
1042
+ </div>
1043
+ <div class="grid" style="grid-template-columns:1fr 1fr;gap:10px;margin-bottom:0;">
1044
+ <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>
1045
+ <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>
1046
+ <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>
1047
+ <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>
1048
+ <div class="form-row"><label for="rate-currency">Currency</label><input id="rate-currency" value="${escHtml(c.currency || 'USD')}"/></div>
1049
+ </div>
1050
+ <div id="rate-status" class="dim" style="font-size:12px;margin-top:8px;"></div>
1051
+ `,
1052
+ footHtml: `
1053
+ <button class="btn btn-secondary" onclick="closeModal()">Cancel</button>
1054
+ <button class="btn" onclick="submitRateCard()">Save</button>
1055
+ `,
1056
+ });
1057
+ }
1058
+
1059
+ async function submitRateCard() {
1060
+ const key = document.getElementById('rate-key').value.trim();
1061
+ const status = document.getElementById('rate-status');
1062
+ if (!key) { status.style.color = 'var(--err)'; status.textContent = 'Key is required.'; return; }
1063
+ const card = {
1064
+ in: parseFloat(document.getElementById('rate-in').value) || 0,
1065
+ out: parseFloat(document.getElementById('rate-out').value) || 0,
1066
+ currency: document.getElementById('rate-currency').value.trim() || 'USD',
1067
+ };
1068
+ const cr = parseFloat(document.getElementById('rate-cache-read').value);
1069
+ const cc = parseFloat(document.getElementById('rate-cache-create').value);
1070
+ if (Number.isFinite(cr)) card['cache-read'] = cr;
1071
+ if (Number.isFinite(cc)) card['cache-create'] = cc;
1072
+ status.style.color = 'var(--dim)';
1073
+ status.textContent = 'Saving…';
1074
+ try {
1075
+ const r = await fetch('/rates/' + encodeURIComponent(key), {
1076
+ method: 'PUT',
1077
+ headers: { 'content-type': 'application/json' },
1078
+ body: JSON.stringify(card),
1079
+ });
1080
+ const body = await r.json();
1081
+ if (!r.ok) {
1082
+ status.style.color = 'var(--err)';
1083
+ const issues = (body.issues || []).map(i => typeof i === 'string' ? i : JSON.stringify(i)).join('; ');
1084
+ status.textContent = `✗ ${body.error || issues || `${r.status} ${r.statusText}`}`;
1085
+ return;
1086
+ }
1087
+ status.style.color = 'var(--ok)';
1088
+ status.textContent = `✓ saved`;
1089
+ setTimeout(() => { closeModal(); LOADERS.rates(); }, 600);
1090
+ } catch (e) {
1091
+ status.style.color = 'var(--err)';
1092
+ status.textContent = '✗ ' + (e.message || String(e));
1093
+ }
1094
+ }
1095
+
1096
+ async function deleteRateCard(key) {
1097
+ if (!confirm(`Delete rate card "${key}"?`)) return;
1098
+ try {
1099
+ const r = await fetch('/rates/' + encodeURIComponent(key), { method: 'DELETE' });
1100
+ if (!r.ok) {
1101
+ const body = await r.json().catch(() => ({}));
1102
+ throw new Error(body.error || `${r.status}`);
1103
+ }
1104
+ LOADERS.rates();
1105
+ } catch (e) {
1106
+ alert('Delete failed: ' + e.message);
1107
+ }
1108
+ }
1109
+
659
1110
  // ── Metrics ──────────────────────────────────────────────────
660
1111
  LOADERS.metrics = async function loadMetrics() {
661
1112
  const cards = document.getElementById('metrics-cards');
@@ -748,21 +1199,121 @@
748
1199
  root.innerHTML = '<div class="empty">No config yet. Run <code>lazyclaw onboard</code>.</div>';
749
1200
  return;
750
1201
  }
1202
+ const NESTED = new Set(['customProviders', 'rates', 'authProfiles', 'authActiveProfile']);
751
1203
  const rows = keys.sort().map((k) => {
752
1204
  const v = cfg[k];
753
1205
  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>`;
1206
+ const nested = NESTED.has(k);
1207
+ const editBtn = nested
1208
+ ? `<span class="dim" style="font-size:11px;">use the dedicated tab</span>`
1209
+ : `<button class="btn btn-secondary btn-sm" data-action="cfg-edit" data-key="${escHtml(k)}">Edit</button>
1210
+ <button class="btn btn-danger btn-sm" data-action="cfg-delete" data-key="${escHtml(k)}">Delete</button>`;
1211
+ return `<tr><td><code>${escHtml(k)}</code></td><td>${escHtml(display)}</td><td>${editBtn}</td></tr>`;
755
1212
  }).join('');
756
1213
  root.innerHTML = `<table class="tbl">
757
- <thead><tr><th style="width:30%">Key</th><th>Value</th></tr></thead>
1214
+ <thead><tr><th style="width:25%">Key</th><th>Value</th><th style="width:160px"></th></tr></thead>
758
1215
  <tbody>${rows}</tbody>
759
1216
  </table>`;
1217
+ root.querySelectorAll('[data-action="cfg-edit"]').forEach((b) => {
1218
+ b.addEventListener('click', () => openConfigEditModal(b.dataset.key, cfg[b.dataset.key]));
1219
+ });
1220
+ root.querySelectorAll('[data-action="cfg-delete"]').forEach((b) => {
1221
+ b.addEventListener('click', () => deleteConfigKey(b.dataset.key));
1222
+ });
760
1223
  raw.textContent = JSON.stringify(cfg, null, 2);
761
1224
  } catch (e) {
762
1225
  root.innerHTML = `<div class="empty">⚠ ${escHtml(e.message)}</div>`;
763
1226
  }
764
1227
  };
765
1228
 
1229
+ function openConfigEditModal(existingKey = '', existingValue = '') {
1230
+ // Stringify for the editor; objects/arrays become JSON, primitives stay
1231
+ // raw. Submitter parses JSON when the value looks like JSON, else
1232
+ // sends a string verbatim — same behaviour as `lazyclaw config set`.
1233
+ let display = '';
1234
+ if (typeof existingValue === 'string') display = existingValue;
1235
+ else if (existingValue == null) display = '';
1236
+ else display = JSON.stringify(existingValue, null, 2);
1237
+ openModal({
1238
+ title: existingKey ? `Edit config — ${existingKey}` : 'Set config key',
1239
+ bodyHtml: `
1240
+ <div class="dim" style="margin-bottom:12px;font-size:12px;">
1241
+ Mirrors <code>lazyclaw config set &lt;key&gt; &lt;value&gt;</code>. Values that look like
1242
+ JSON (start with <code>{</code> / <code>[</code> / <code>"</code> / <code>true</code> / <code>false</code> / a number)
1243
+ are parsed; everything else is stored as a plain string. Nested
1244
+ stores (<code>customProviders</code>, <code>rates</code>, <code>authProfiles</code>) have their own
1245
+ tabs — this form rejects them.
1246
+ </div>
1247
+ <div class="form-row">
1248
+ <label for="cfg-key">Key</label>
1249
+ <input id="cfg-key" placeholder="provider · model · api-key · skills · …" value="${escHtml(existingKey)}" ${existingKey ? 'readonly' : ''}/>
1250
+ </div>
1251
+ <div class="form-row">
1252
+ <label for="cfg-value">Value</label>
1253
+ <textarea id="cfg-value" rows="6">${escHtml(display)}</textarea>
1254
+ </div>
1255
+ <div id="cfg-status" class="dim" style="font-size:12px;"></div>
1256
+ `,
1257
+ footHtml: `
1258
+ <button class="btn btn-secondary" onclick="closeModal()">Cancel</button>
1259
+ <button class="btn" onclick="submitConfigEdit()">Save</button>
1260
+ `,
1261
+ });
1262
+ }
1263
+
1264
+ async function submitConfigEdit() {
1265
+ const key = document.getElementById('cfg-key').value.trim();
1266
+ const raw = document.getElementById('cfg-value').value;
1267
+ const status = document.getElementById('cfg-status');
1268
+ if (!key) { status.style.color = 'var(--err)'; status.textContent = 'Key is required.'; return; }
1269
+ // Heuristic JSON parse — same surface as the CLI: try parse; if it
1270
+ // throws, send the raw string. Numbers / true / false / null / objects /
1271
+ // arrays / quoted strings end up correctly typed.
1272
+ let value;
1273
+ const trimmed = raw.trim();
1274
+ if (trimmed === '') value = '';
1275
+ else {
1276
+ try { value = JSON.parse(trimmed); }
1277
+ catch { value = raw; }
1278
+ }
1279
+ status.style.color = 'var(--dim)';
1280
+ status.textContent = 'Saving…';
1281
+ try {
1282
+ const r = await fetch('/config/' + encodeURIComponent(key), {
1283
+ method: 'PUT',
1284
+ headers: { 'content-type': 'application/json' },
1285
+ body: JSON.stringify({ value }),
1286
+ });
1287
+ const body = await r.json();
1288
+ if (!r.ok) {
1289
+ status.style.color = 'var(--err)';
1290
+ const issues = (body.issues || []).map(i => typeof i === 'string' ? i : JSON.stringify(i)).join('; ');
1291
+ status.textContent = `✗ ${body.error || issues || `${r.status} ${r.statusText}`}`;
1292
+ return;
1293
+ }
1294
+ status.style.color = 'var(--ok)';
1295
+ status.textContent = '✓ saved';
1296
+ setTimeout(() => { closeModal(); LOADERS.config(); }, 600);
1297
+ } catch (e) {
1298
+ status.style.color = 'var(--err)';
1299
+ status.textContent = '✗ ' + (e.message || String(e));
1300
+ }
1301
+ }
1302
+
1303
+ async function deleteConfigKey(key) {
1304
+ if (!confirm(`Delete config key "${key}"?`)) return;
1305
+ try {
1306
+ const r = await fetch('/config/' + encodeURIComponent(key), { method: 'DELETE' });
1307
+ if (!r.ok) {
1308
+ const body = await r.json().catch(() => ({}));
1309
+ throw new Error(body.error || `${r.status}`);
1310
+ }
1311
+ LOADERS.config();
1312
+ } catch (e) {
1313
+ alert('Delete failed: ' + e.message);
1314
+ }
1315
+ }
1316
+
766
1317
  // First load = chat tab.
767
1318
  LOADERS.chat();
768
1319