lazyclaw 6.0.1 → 6.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (44) hide show
  1. package/README.ko.md +88 -25
  2. package/README.md +121 -190
  3. package/channels/handoff.mjs +18 -5
  4. package/channels/matrix.mjs +23 -5
  5. package/channels/slack.mjs +83 -50
  6. package/channels/slack_env.mjs +45 -0
  7. package/channels/telegram.mjs +49 -6
  8. package/cli.mjs +10 -21
  9. package/commands/channels.mjs +106 -107
  10. package/commands/chat.mjs +81 -45
  11. package/commands/daemon.mjs +15 -0
  12. package/commands/gateway.mjs +194 -0
  13. package/commands/providers.mjs +17 -38
  14. package/commands/service.mjs +113 -0
  15. package/commands/setup.mjs +55 -54
  16. package/commands/setup_channels.mjs +207 -0
  17. package/config_features.mjs +106 -0
  18. package/daemon/lib/inbound_dedup.mjs +108 -0
  19. package/daemon/lib/learn_queue.mjs +46 -0
  20. package/daemon/routes/_deps.mjs +6 -0
  21. package/daemon/routes/conversation.mjs +68 -4
  22. package/daemon/routes/ops.mjs +9 -29
  23. package/dotenv_min.mjs +28 -0
  24. package/first_run.mjs +9 -0
  25. package/lib/gateway_guard.mjs +100 -0
  26. package/lib/inbound_client.mjs +108 -0
  27. package/lib/service_install.mjs +207 -0
  28. package/package.json +2 -3
  29. package/providers/codex_cli.mjs +17 -1
  30. package/providers/compat_vendors.mjs +161 -0
  31. package/providers/gemini_cli.mjs +19 -3
  32. package/providers/model_catalogue.mjs +142 -9
  33. package/providers/probe.mjs +28 -0
  34. package/providers/registry.mjs +31 -162
  35. package/tui/editor.mjs +18 -2
  36. package/tui/pickers.mjs +3 -3
  37. package/tui/provider_families.mjs +8 -6
  38. package/tui/repl.mjs +25 -4
  39. package/tui/slash_commands.mjs +4 -0
  40. package/tui/slash_dispatcher.mjs +118 -0
  41. package/tui/splash_props.mjs +52 -0
  42. package/web/dashboard.css +6 -12
  43. package/web/dashboard.html +2 -34
  44. package/web/dashboard.js +3 -3
package/tui/editor.mjs CHANGED
@@ -359,7 +359,13 @@ export function Editor({
359
359
  // sees at most a one-tick flicker. The IME-correctness win is worth
360
360
  // the cosmetic cost. Opt out via LAZYCLAW_NO_CURSOR_ANCHOR=1.
361
361
  useEffect(() => {
362
- if (!altEnabled) return;
362
+ // Runs in BOTH the alt-buffer and the default (non-alt Static) layouts: the
363
+ // editor is the last child either way, so the cursor parks below it and the
364
+ // rowsUp math (editor geometry only) is identical. Anchoring in non-alt is
365
+ // what makes the terminal cursor visible AT the caret (so you can see where
366
+ // you're typing) and what keeps a CJK/Hangul IME pre-edit inside the box
367
+ // instead of leaking onto the row below. Opt out via LAZYCLAW_NO_CURSOR_ANCHOR=1.
368
+ void altEnabled;
363
369
  if (process.env.LAZYCLAW_NO_CURSOR_ANCHOR === '1') return;
364
370
  if (!(process.stdout && process.stdout.isTTY)) return;
365
371
  const cols = Math.max(20, process.stdout.columns || 80);
@@ -402,9 +408,19 @@ export function Editor({
402
408
  const prefixWidth = rowInEditor === 0 ? PROMPT_WIDTH : CONTINUATION_WIDTH;
403
409
  const colTarget = 3 + prefixWidth + colInLine;
404
410
  _installAnchorShim();
411
+ // If a previous anchor moved the cursor up and NO render's eraseLines has
412
+ // consumed that offset yet (two state updates between redraws — e.g. fast
413
+ // typing or backspace), the cursor is still parked up inside the editor.
414
+ // Undo that move first (\x1b[<pending>B) so we re-anchor from the true
415
+ // "below the frame" baseline. Without this the moves stacked, the shim
416
+ // only compensated for the LAST one, and eraseLines walked up into — and
417
+ // erased — the scrollback above the editor (corruption was invisible in
418
+ // the fixed alt-buffer canvas, visible in the default Static layout).
419
+ const pending = _anchorState.offset;
420
+ const undo = pending > 0 ? `\x1b[${pending}B\r` : '';
405
421
  _anchorState.offset = rowsUp;
406
422
  try {
407
- process.stdout.write(`\x1b[${rowsUp}A\x1b[${colTarget}G\x1b[?25h`);
423
+ process.stdout.write(`${undo}\x1b[${rowsUp}A\x1b[${colTarget}G\x1b[?25h`);
408
424
  } catch { /* stdout closed — swallow */ }
409
425
  }, [state.buffer, state.cursor, altEnabled]);
410
426
 
package/tui/pickers.mjs CHANGED
@@ -417,13 +417,13 @@ export async function _arrowMenu({ title, subtitle, footer, items, defaultIdx =
417
417
  // here (rather than registry.mjs) because it's a UX concept, not
418
418
  // an intrinsic provider attribute.
419
419
  export function _providerFamilies() {
420
- // Membership (api/cli/mock, orchestrator excluded) is shared with the Ink
421
- // picker via tui/provider_families.mjs so both paths bucket identically.
422
- // The ANSI tags below are readline-specific, so they're applied here.
420
+ // Membership (api/cli/meta/mock) is shared with the Ink picker via
421
+ // tui/provider_families.mjs; the ANSI tags below are readline-specific.
423
422
  const b = _bucketProviders(getRegistry());
424
423
  return {
425
424
  api: { label: 'API key', desc: 'paste an sk-... key during setup', tag: '\x1b[38;5;245m[needs key]\x1b[0m', members: b.api },
426
425
  cli: { label: 'CLI / Local', desc: 'keyless — uses an existing CLI login or a local daemon', tag: '\x1b[38;5;208m[no key]\x1b[0m', members: b.cli },
426
+ meta: { label: 'Multi-agent', desc: 'orchestrator — fan a task out to a planner + workers (advanced)', tag: '\x1b[38;5;245m[meta]\x1b[0m', members: b.meta },
427
427
  mock: { label: 'Mock', desc: 'offline echo, only useful for testing', tag: '\x1b[38;5;245m[test]\x1b[0m', members: b.mock },
428
428
  };
429
429
  }
@@ -5,17 +5,18 @@
5
5
  // (API key / CLI-Local / Mock) as shared, unit-testable data so both the
6
6
  // readline picker (cli.mjs) and the Ink dispatcher use one bucketing rule.
7
7
 
8
- // Bucket every registered provider into one of three auth families.
9
- // orchestrator is excluded entirely (strictly opt-in; never a wizard
10
- // default see cli.mjs v5.3.2 note). Returns { api:[], cli:[], mock:[] }
11
- // of provider-id strings.
8
+ // Bucket every registered provider into one of four auth families.
9
+ // orchestrator sits in its own `meta` family visible and pickable, but
10
+ // never a wizard default (the cursor always starts on api/cli see the
11
+ // cli.mjs v5.3.2 note). Returns { api:[], cli:[], mock:[], meta:[] } of
12
+ // provider-id strings.
12
13
  export function bucketProviders(registry) {
13
14
  const info = (registry && registry.PROVIDER_INFO) || {};
14
15
  const all = Object.keys((registry && registry.PROVIDERS) || {});
15
- const out = { api: [], cli: [], mock: [] };
16
+ const out = { api: [], cli: [], mock: [], meta: [] };
16
17
  for (const name of all) {
17
18
  if (name === 'mock') out.mock.push(name);
18
- else if (name === 'orchestrator') continue;
19
+ else if (name === 'orchestrator') out.meta.push(name);
19
20
  else if ((info[name] || {}).requiresApiKey) out.api.push(name);
20
21
  else out.cli.push(name);
21
22
  }
@@ -29,6 +30,7 @@ export function providerFamilies(registry) {
29
30
  return {
30
31
  api: { id: 'api', label: 'API key', desc: 'paste an sk-... key', tag: 'needs key', members: b.api },
31
32
  cli: { id: 'cli', label: 'CLI / Local', desc: 'keyless — an existing CLI login or a local daemon', tag: 'no key', members: b.cli },
33
+ meta: { id: 'meta', label: 'Multi-agent', desc: 'orchestrator — fan a task out to a planner + workers (advanced)', tag: 'meta', members: b.meta },
32
34
  mock: { id: 'mock', label: 'Mock', desc: 'offline echo, only useful for testing', tag: 'test', members: b.mock },
33
35
  };
34
36
  }
package/tui/repl.mjs CHANGED
@@ -154,9 +154,25 @@ export function onEscape(state) {
154
154
  };
155
155
  }
156
156
 
157
- // New reducer: stream chunk arrives, accumulate in liveAssistant.
157
+ // Stream chunk arrives. Completed lines are committed to the <Static>
158
+ // scrollback immediately (so they scroll up ABOVE the sticky editor), and only
159
+ // the in-progress trailing partial stays in the live region. Without this, a
160
+ // reply taller than the terminal grew the live frame past the viewport and
161
+ // spilled BELOW the input box (long orchestrator replies). Chunks without a
162
+ // newline still just accumulate (the prior behaviour), so short replies and the
163
+ // existing reducer tests are unchanged.
158
164
  export function onStreamChunk(state, { chunk }) {
159
- return { ...state, liveAssistant: state.liveAssistant + chunk };
165
+ const buf = state.liveAssistant + chunk;
166
+ const nl = buf.lastIndexOf('\n');
167
+ if (nl < 0) return { ...state, liveAssistant: buf };
168
+ const complete = buf.slice(0, nl); // one or more whole lines
169
+ const remainder = buf.slice(nl + 1); // trailing partial (may be '')
170
+ const id = `as-${state.turnCounter}-${state.scrollback.length}`;
171
+ return {
172
+ ...state,
173
+ scrollback: [...state.scrollback, { kind: 'assistant', id, text: complete }],
174
+ liveAssistant: remainder,
175
+ };
160
176
  }
161
177
 
162
178
  export function onTurnComplete(state, { reason, error } = {}) {
@@ -463,10 +479,15 @@ export function ReplApp({ splashProps, runTurn, runTurnFactory, slashCommands, o
463
479
  const modalOpen = !!modal;
464
480
 
465
481
  // Slash popup is suppressed whenever a modal picker is active so the
466
- // overlays don't stack.
482
+ // overlays don't stack, AND once the buffer has a space (the user is typing
483
+ // ARGS, e.g. `/orchestrator off`). Without the space guard the popup stayed
484
+ // open as a one-row hint and the editor treated Enter as "fill the matched
485
+ // command", which dropped the args and reverted the buffer to the bare
486
+ // command. With args, Enter must submit the full line instead.
467
487
  const showSlashPopup =
468
488
  !modalOpen &&
469
- bufferPeek.startsWith('/') && filtered.length > 0 && !_exactOnly;
489
+ bufferPeek.startsWith('/') && bufferPeek.indexOf(' ') < 0 &&
490
+ filtered.length > 0 && !_exactOnly;
470
491
 
471
492
  // Outer column height: pinned to rows-1 in alt-buffer mode so the
472
493
  // Editor truly sticks to the bottom. Non-alt keeps content-sized layout
@@ -26,6 +26,10 @@ export const SLASH_COMMANDS = [
26
26
  { cmd: '/personality', help: 'pick a personality (or sub: list|show|install|remove|use)' },
27
27
  { cmd: '/dashboard', help: 'open the lazyclaw web UI in your browser' },
28
28
  { cmd: '/menu', help: 'browse the full subcommand catalog (command palette)' },
29
+ { cmd: '/config', help: 'leave chat and re-run the setup wizard (provider, model, channels)' },
30
+ { cmd: '/channels', help: 'view configured channels; /channels <name> on|off to toggle' },
31
+ { cmd: '/orchestrator', help: 'multi-agent: status | on | off | planner <spec> | worker add|remove <spec>' },
32
+ { cmd: '/context', help: 'chat history window: status | turns <N> | tokens <N>' },
29
33
  { cmd: '/loop', help: 'repeat one prompt: /loop "fix lint" [--max N] [--until "<regex>"]' },
30
34
  { cmd: '/goal', help: 'register/switch goal: /goal add NAME | /goal list' },
31
35
  { cmd: '/memory', help: 'show layered memory: /memory [core|recent|episodic [topic]]' },
@@ -1670,6 +1670,119 @@ async function _menu(args, ctx) {
1670
1670
  ].join('\n');
1671
1671
  }
1672
1672
 
1673
+ // /channels — view configured channels and toggle them. `/channels` lists;
1674
+ // `/channels <name> on|off` enables/disables. Reads/writes cfg via ctx when
1675
+ // available, else lib/config directly, so it works on both REPL paths.
1676
+ async function _channels(args, ctx = {}) {
1677
+ const cf = await import('../config_features.mjs');
1678
+ const cfgMod = await import('../lib/config.mjs');
1679
+ const read = typeof ctx.readConfig === 'function' ? ctx.readConfig : cfgMod.readConfig;
1680
+ const write = typeof ctx.writeConfig === 'function' ? ctx.writeConfig : cfgMod.writeConfig;
1681
+ const [name, action] = (args || '').trim().split(/\s+/).filter(Boolean);
1682
+ if (name && /^(on|off|enable|disable)$/i.test(action || '')) {
1683
+ const en = /^(on|enable)$/i.test(action);
1684
+ const cfg = read();
1685
+ const key = name.toLowerCase();
1686
+ // Reject unknown names so a typo can't silently create a bogus
1687
+ // cfg.channels.<name> section (which would then leak into the list).
1688
+ // Stay permissive for pre-existing custom sections.
1689
+ const existing = (cfg.channels && typeof cfg.channels === 'object') ? cfg.channels : {};
1690
+ if (!cf.KNOWN_CHANNELS.includes(key) && !(key in existing)) {
1691
+ return `unknown channel: ${key} (known: ${cf.KNOWN_CHANNELS.join(', ')})`;
1692
+ }
1693
+ cf.channelSetEnabled(cfg, key, en); write(cfg);
1694
+ // Legacy fallback path: the readline ctx (_legacyCtx) has no
1695
+ // readConfig/writeConfig, so we read/wrote disk above against a fresh
1696
+ // cfg object. Mirror the toggle onto the in-session ctx.cfg so a
1697
+ // follow-up `/channels` (list) or other in-session read stays
1698
+ // consistent instead of showing the stale pre-toggle value.
1699
+ if (ctx.cfg && ctx.cfg !== cfg && typeof ctx.cfg === 'object') {
1700
+ cf.channelSetEnabled(ctx.cfg, key, en);
1701
+ }
1702
+ return `channel ${key} → ${en ? 'enabled' : 'disabled'}`;
1703
+ }
1704
+ const rows = cf.channelStatusList(read());
1705
+ if (!rows.length) return 'no channels configured. add creds with /config (re-runs setup) or `lazyclaw setup`.';
1706
+ const lines = ['configured channels:'];
1707
+ for (const c of rows) lines.push(` ${c.name} ${c.enabled ? 'enabled' : 'disabled'}${c.boundAgent ? ' · agent: ' + c.boundAgent : ''}`);
1708
+ lines.push('toggle: /channels <name> on|off · add creds: /config');
1709
+ return lines.join('\n');
1710
+ }
1711
+
1712
+ // /orchestrator — view/edit multi-agent config. `status` (default), `on`/`off`,
1713
+ // `planner <spec>`, `worker add|remove <spec>`, `maxsubtasks <N>`. ctx-or-
1714
+ // lib/config fallback so it works on both REPL paths.
1715
+ async function _orchestrator(args, ctx = {}) {
1716
+ const cf = await import('../config_features.mjs');
1717
+ const cfgMod = await import('../lib/config.mjs');
1718
+ const read = typeof ctx.readConfig === 'function' ? ctx.readConfig : cfgMod.readConfig;
1719
+ const write = typeof ctx.writeConfig === 'function' ? ctx.writeConfig : cfgMod.writeConfig;
1720
+ const persist = (cfg) => { write(cfg); if (ctx.cfg) ctx.cfg = cfg; };
1721
+ const parts = (args || '').trim().split(/\s+/).filter(Boolean);
1722
+ const fmt = () => {
1723
+ const s = cf.orchestratorGet(read());
1724
+ return `orchestrator: ${s.active ? 'ON' : 'off'} · planner: ${s.planner || '(default)'} · workers: ${s.workers.length ? s.workers.join(', ') : '(none)'} · maxSubtasks: ${s.maxSubtasks}`;
1725
+ };
1726
+ // Bare `/orchestrator` → arrow-key picker (Ink). Pick ON/OFF/Status instead
1727
+ // of typing the subcommand. Legacy path (no openPicker) shows status text.
1728
+ if (parts.length === 0 && typeof ctx.openPicker === 'function') {
1729
+ const s = cf.orchestratorGet(read());
1730
+ const picked = await ctx.openPicker({
1731
+ title: 'Orchestration',
1732
+ subtitle: `now ${s.active ? 'ON' : 'off'} · planner ${s.planner || '(default)'} · ${s.workers.length} worker(s)`,
1733
+ items: [
1734
+ { id: 'on', label: 'Turn ON', desc: 'route chats through planner + workers' },
1735
+ { id: 'off', label: 'Turn OFF', desc: 'back to a single provider' },
1736
+ { id: 'status', label: 'Status', desc: 'show current config' },
1737
+ ],
1738
+ });
1739
+ if (!picked || typeof picked !== 'string') return fmt();
1740
+ return _orchestrator(picked, ctx);
1741
+ }
1742
+ const sub = (parts[0] || 'status').toLowerCase();
1743
+ if (sub === 'status') return fmt();
1744
+ const cfg = read();
1745
+ if (sub === 'on' || sub === 'enable') {
1746
+ if (!cf.orchestratorGet(cfg).planner) {
1747
+ const base = cfg.provider && cfg.provider !== 'orchestrator' ? cfg.provider : 'claude-cli';
1748
+ cf.orchestratorSet(cfg, { planner: base });
1749
+ }
1750
+ cf.orchestratorEnable(cfg, true); persist(cfg);
1751
+ const after = cf.orchestratorGet(read());
1752
+ return after.workers.length ? 'orchestration ON.\n' + fmt() : 'orchestration ON — but no workers yet. Add one: /orchestrator worker add <provider[:model]>';
1753
+ }
1754
+ if (sub === 'off' || sub === 'disable') { cf.orchestratorEnable(cfg, false); persist(cfg); return 'orchestration off. provider → ' + read().provider; }
1755
+ if (sub === 'planner') { if (!parts[1]) return 'usage: /orchestrator planner <provider[:model]>'; cf.orchestratorSet(cfg, { planner: parts[1] }); persist(cfg); return 'planner → ' + parts[1]; }
1756
+ if (sub === 'maxsubtasks') { const n = parseInt(parts[1], 10); if (!Number.isFinite(n)) return 'usage: /orchestrator maxsubtasks <N>'; cf.orchestratorSet(cfg, { maxSubtasks: Math.max(1, Math.min(10, n)) }); persist(cfg); return fmt(); }
1757
+ if (sub === 'worker') {
1758
+ const action = (parts[1] || '').toLowerCase(); const spec = parts[2];
1759
+ const workers = [...cf.orchestratorGet(cfg).workers];
1760
+ if (action === 'add' && spec) { if (!workers.includes(spec)) workers.push(spec); cf.orchestratorSet(cfg, { workers }); persist(cfg); return 'workers: ' + workers.join(', '); }
1761
+ if ((action === 'remove' || action === 'rm') && spec) { const next = workers.filter((w) => w !== spec); cf.orchestratorSet(cfg, { workers: next }); persist(cfg); return 'workers: ' + (next.join(', ') || '(none)'); }
1762
+ return 'usage: /orchestrator worker add|remove <provider[:model]>';
1763
+ }
1764
+ return 'usage: /orchestrator [status|on|off|planner <spec>|worker add|remove <spec>|maxsubtasks <N>]';
1765
+ }
1766
+
1767
+ // /context — view/set the chat history window (turns + token budget). This is
1768
+ // the sliding history budget sent each turn, NOT the model's hard context
1769
+ // limit. ctx-or-lib/config fallback so it works on both REPL paths.
1770
+ async function _context(args, ctx = {}) {
1771
+ const cf = await import('../config_features.mjs');
1772
+ const cfgMod = await import('../lib/config.mjs');
1773
+ const read = typeof ctx.readConfig === 'function' ? ctx.readConfig : cfgMod.readConfig;
1774
+ const write = typeof ctx.writeConfig === 'function' ? ctx.writeConfig : cfgMod.writeConfig;
1775
+ const persist = (cfg) => { write(cfg); if (ctx.cfg) ctx.cfg = cfg; };
1776
+ const parts = (args || '').trim().split(/\s+/).filter(Boolean);
1777
+ const sub = (parts[0] || 'status').toLowerCase();
1778
+ const fmt = () => { const w = cf.chatWindowGet(read()); return `context window: ${w.turns} turns · ${w.tokens} tokens (history budget — not the model's hard limit)`; };
1779
+ if (sub === 'status') return fmt();
1780
+ const n = parseInt(parts[1], 10);
1781
+ if (sub === 'turns') { if (!Number.isFinite(n) || n < 1) return 'usage: /context turns <N>'; const cfg = read(); cf.chatWindowSet(cfg, { turns: n }); persist(cfg); return fmt(); }
1782
+ if (sub === 'tokens') { if (!Number.isFinite(n) || n < 256) return 'usage: /context tokens <N> (min 256)'; const cfg = read(); cf.chatWindowSet(cfg, { tokens: n }); persist(cfg); return fmt(); }
1783
+ return 'usage: /context [status | turns <N> | tokens <N>]';
1784
+ }
1785
+
1673
1786
  // ─── dispatch table ──────────────────────────────────────────────────────
1674
1787
 
1675
1788
  export const SLASH_HANDLERS = new Map([
@@ -1698,6 +1811,11 @@ export const SLASH_HANDLERS = new Map([
1698
1811
  ['/trainer', _trainer],
1699
1812
  ['/dashboard', _dashboard],
1700
1813
  ['/menu', _menu],
1814
+ ['/channels', _channels],
1815
+ ['/orchestrator', _orchestrator],
1816
+ ['/context', _context],
1817
+ // /config — unmount and let chat.mjs run the setup wizard (ctx.requestSetup).
1818
+ ['/config', async (_a, ctx) => { ctx.requestSetup = true; return 'EXIT'; }],
1701
1819
  ['/exit', async () => 'EXIT'],
1702
1820
  ['/quit', async () => 'EXIT'],
1703
1821
  ]);
@@ -0,0 +1,52 @@
1
+ // tui/splash_props.mjs — gather the dynamic props the splash panel needs
2
+ // (tool groups + skill groups), shared by the chat REPL and the setup wizard
3
+ // so both surfaces render the same lazyclaw splash instead of drifting apart
4
+ // (the setup wizard used to show a small figlet banner instead).
5
+ import path from 'node:path';
6
+ import { configPath } from '../lib/config.mjs';
7
+
8
+ // Re-export the renderer so setup/onboarding callers need one import.
9
+ export { renderSplashToString } from './splash.mjs';
10
+
11
+ // Verbatim from the chat REPL's former inline block: collapse the v5 tool
12
+ // registry to one row per category, and group installed skills by their
13
+ // filename hyphen-prefix. Failures degrade to empty lists, never throw.
14
+ export async function gatherToolAndSkillGroups(cfgDir) {
15
+ let tools = [];
16
+ try {
17
+ const registry = await import('../mas/tools/registry.mjs');
18
+ const byCat = registry.byCategory();
19
+ tools = Object.entries(byCat).map(([category, items]) => ({
20
+ category,
21
+ sensitive: items.some((t) => t.sensitive),
22
+ verbs: items.map((t) => t.name.replace(/^[a-z]+_/, '')).slice(0, 6),
23
+ })).sort((a, b) => a.category.localeCompare(b.category));
24
+ } catch { /* registry unavailable → empty list */ }
25
+
26
+ let skills = [];
27
+ try {
28
+ const { listSkills } = await import('../skills.mjs');
29
+ const flat = listSkills(cfgDir);
30
+ const byGroup = new Map();
31
+ for (const s of flat) {
32
+ const i = s.name.indexOf('-');
33
+ const group = i > 0 ? s.name.slice(0, i) : 'general';
34
+ const sub = i > 0 ? s.name.slice(i + 1) : s.name;
35
+ if (!byGroup.has(group)) byGroup.set(group, []);
36
+ byGroup.get(group).push(sub);
37
+ }
38
+ skills = [...byGroup.entries()]
39
+ .map(([group, names]) => ({ group, names: names.slice(0, 6) }))
40
+ .sort((a, b) => a.group.localeCompare(b.group));
41
+ } catch { /* skills dir unavailable → empty list */ }
42
+
43
+ return { tools, skills };
44
+ }
45
+
46
+ // Build the full splash props for the setup wizard. Self-contained (resolves
47
+ // the config dir itself) so call sites stay one line.
48
+ export async function splashPropsForSetup({ version = '', provider = '', model = '' } = {}) {
49
+ const cfgDir = path.dirname(configPath());
50
+ const { tools, skills } = await gatherToolAndSkillGroups(cfgDir);
51
+ return { provider, model, trainer: {}, sessionId: '', cwd: process.cwd(), version, tools, skills };
52
+ }
package/web/dashboard.css CHANGED
@@ -4,7 +4,8 @@
4
4
  --border: #2a2a36;
5
5
  --text: #e8e8ea;
6
6
  --dim: #a8a8b8;
7
- --accent: #d97757;
7
+ --accent: #d9b35a;
8
+ --accent-ink: #1a1610;
8
9
  --ok: #4ade80;
9
10
  --warn: #f59e0b;
10
11
  --err: #ef4444;
@@ -27,14 +28,7 @@
27
28
  gap: 14px;
28
29
  }
29
30
  .logo { font-weight: 700; font-size: 16px; color: var(--accent); display: flex; align-items: center; gap: 10px; }
30
- .logo .mascot { width: 44px; height: 44px; flex: none; image-rendering: pixelated; image-rendering: crisp-edges; }
31
31
  .ver { color: var(--dim); font-size: 11px; }
32
- /* lazyclaw 16x16 pixel mascot — Claude Design handoff (mascot sheet
33
- v0.1, "claude original" palette). Claude's asterisk star (#d97757)
34
- worn under a crustacean helmet (#c33d2a) with two antenna-claws.
35
- Idle pose (sleepy slits). Hover gently brightens the helmet. */
36
- .mascot { transition: filter 0.2s ease; }
37
- .logo:hover .mascot { filter: drop-shadow(0 0 6px rgba(217, 119, 87, 0.45)); }
38
32
  nav.tabs {
39
33
  display: flex;
40
34
  flex-wrap: wrap;
@@ -81,7 +75,7 @@
81
75
  .dim { color: var(--dim); font-size: 12px; }
82
76
  button.btn {
83
77
  background: var(--accent);
84
- color: #fff;
78
+ color: var(--accent-ink);
85
79
  border: 0;
86
80
  border-radius: 6px;
87
81
  padding: 8px 14px;
@@ -119,7 +113,7 @@
119
113
  gap: 14px;
120
114
  }
121
115
  .msg { padding: 8px 12px; border-radius: 6px; max-width: 90%; white-space: pre-wrap; word-wrap: break-word; }
122
- .msg.user { align-self: flex-end; background: rgba(217, 119, 87, 0.15); border: 1px solid rgba(217, 119, 87, 0.3); }
116
+ .msg.user { align-self: flex-end; background: rgba(217, 179, 90, 0.15); border: 1px solid rgba(217, 179, 90, 0.3); }
123
117
  .msg.assistant { align-self: flex-start; background: rgba(74, 222, 128, 0.06); border: 1px solid rgba(74, 222, 128, 0.18); }
124
118
  .msg.error { align-self: stretch; background: rgba(239, 68, 68, 0.12); border: 1px solid rgba(239, 68, 68, 0.3); color: #ffd3d3; }
125
119
  .input-row {
@@ -230,9 +224,9 @@
230
224
  .modal-body { padding: 16px 18px; overflow-y: auto; flex: 1; }
231
225
  .modal-foot { padding: 12px 18px; border-top: 1px solid var(--border); display: flex; gap: 8px; justify-content: flex-end; }
232
226
  .clickable { cursor: pointer; }
233
- .clickable:hover { background: rgba(217, 119, 87, 0.05); }
227
+ .clickable:hover { background: rgba(217, 179, 90, 0.05); }
234
228
  .turn { padding: 8px 12px; border-radius: 6px; margin-bottom: 8px; white-space: pre-wrap; word-wrap: break-word; font-size: 13px; }
235
- .turn.user { background: rgba(217, 119, 87, 0.10); border: 1px solid rgba(217, 119, 87, 0.25); }
229
+ .turn.user { background: rgba(217, 179, 90, 0.10); border: 1px solid rgba(217, 179, 90, 0.25); }
236
230
  .turn.assistant { background: rgba(74, 222, 128, 0.06); border: 1px solid rgba(74, 222, 128, 0.18); }
237
231
  .turn.system { background: rgba(245, 158, 11, 0.06); border: 1px solid rgba(245, 158, 11, 0.20); }
238
232
  .turn .role-tag { display: block; color: var(--dim); font-size: 10px; text-transform: uppercase; letter-spacing: 0.05em; margin-bottom: 4px; }
@@ -14,43 +14,11 @@
14
14
  <meta charset="utf-8">
15
15
  <meta name="viewport" content="width=device-width, initial-scale=1">
16
16
  <title>LazyClaw</title>
17
- <link rel="stylesheet" href="/dashboard.css">
17
+ <link rel="stylesheet" href="dashboard.css">
18
18
  </head>
19
19
  <body>
20
20
  <header>
21
21
  <div class="logo">
22
- <svg class="mascot" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" shape-rendering="crispEdges" aria-label="lazyclaw mascot" role="img">
23
- <!-- antennae / claws -->
24
- <rect x="3" y="1" width="1" height="1" fill="#a02b1c"/>
25
- <rect x="12" y="1" width="1" height="1" fill="#a02b1c"/>
26
- <rect x="3" y="2" width="1" height="1" fill="#a02b1c"/>
27
- <rect x="12" y="2" width="1" height="1" fill="#a02b1c"/>
28
- <!-- helmet -->
29
- <rect x="3" y="3" width="10" height="1" fill="#c33d2a"/>
30
- <rect x="2" y="4" width="12" height="1" fill="#c33d2a"/>
31
- <rect x="1" y="5" width="2" height="1" fill="#c33d2a"/>
32
- <rect x="3" y="5" width="1" height="1" fill="#ff8a6a"/>
33
- <rect x="4" y="5" width="8" height="1" fill="#c33d2a"/>
34
- <rect x="12" y="5" width="1" height="1" fill="#ff8a6a"/>
35
- <rect x="13" y="5" width="2" height="1" fill="#c33d2a"/>
36
- <rect x="1" y="6" width="14" height="1" fill="#c33d2a"/>
37
- <rect x="2" y="7" width="12" height="1" fill="#c33d2a"/>
38
- <!-- star body (Claude asterisk) — top edge below helmet brim -->
39
- <rect x="3" y="8" width="10" height="1" fill="#d97757"/>
40
- <!-- face row (idle: sleepy slit eyes) -->
41
- <rect x="2" y="9" width="3" height="1" fill="#d97757"/>
42
- <rect x="5" y="9" width="2" height="1" fill="#1a1410"/>
43
- <rect x="7" y="9" width="2" height="1" fill="#d97757"/>
44
- <rect x="9" y="9" width="2" height="1" fill="#1a1410"/>
45
- <rect x="11" y="9" width="3" height="1" fill="#d97757"/>
46
- <!-- star arms + tip -->
47
- <rect x="1" y="10" width="14" height="1" fill="#d97757"/>
48
- <rect x="0" y="11" width="16" height="1" fill="#d97757"/>
49
- <rect x="1" y="12" width="14" height="1" fill="#d97757"/>
50
- <rect x="3" y="13" width="10" height="1" fill="#d97757"/>
51
- <rect x="5" y="14" width="6" height="1" fill="#d97757"/>
52
- <rect x="7" y="15" width="2" height="1" fill="#d97757"/>
53
- </svg>
54
22
  <span>lazyclaw</span>
55
23
  </div>
56
24
  <div class="ver" id="version">…</div>
@@ -276,6 +244,6 @@
276
244
  </div>
277
245
  </div>
278
246
 
279
- <script src="/dashboard.js"></script>
247
+ <script src="dashboard.js"></script>
280
248
  </body>
281
249
  </html>
package/web/dashboard.js CHANGED
@@ -153,7 +153,7 @@
153
153
  ? `<span class="pill ok" title="trained by ${escHtml(s.trainedBy || 'trainer')}">trained: ${escHtml(s.trainedBy || 'on')}</span>`
154
154
  : '';
155
155
  const tagAgent = s.agentName
156
- ? `<span class="pill" style="background:rgba(217,119,87,0.18);color:var(--accent);">@${escHtml(s.agentName)}</span>`
156
+ ? `<span class="pill" style="background:rgba(217,179,90,0.18);color:var(--accent);">@${escHtml(s.agentName)}</span>`
157
157
  : '';
158
158
  const trajBtn = s.trajectoryId
159
159
  ? `<button class="btn btn-secondary btn-sm" data-action="trajectory" data-traj="${escHtml(s.trajectoryId)}">Trajectory</button>`
@@ -391,7 +391,7 @@
391
391
  const tag = p.requiresApiKey
392
392
  ? '<span class="pill warn">api key</span>'
393
393
  : '<span class="pill ok">no key</span>';
394
- const customTag = p.custom ? ' <span class="pill" style="background:rgba(217,119,87,0.18);color:var(--accent);">custom</span>' : '';
394
+ const customTag = p.custom ? ' <span class="pill" style="background:rgba(217,179,90,0.18);color:var(--accent);">custom</span>' : '';
395
395
  const builtinCompat = p.builtinOpenAICompat ? ' <span class="pill" style="background:rgba(74,222,128,0.12);color:var(--ok);">openai-compat</span>' : '';
396
396
  const models = (p.suggestedModels || []).slice(0, 6).join(' · ') || '<span class="dim">(default)</span>';
397
397
  const removeBtn = p.custom
@@ -1221,7 +1221,7 @@
1221
1221
  const label = md.session_id || md.skill_name || md.trajectory_id || md.topic || '—';
1222
1222
  return `<div class="card">
1223
1223
  <div class="row" style="border:0;padding:0;">
1224
- <span class="pill" style="background:rgba(217,119,87,0.18);color:var(--accent);">${escHtml(h.scope)}</span>
1224
+ <span class="pill" style="background:rgba(217,179,90,0.18);color:var(--accent);">${escHtml(h.scope)}</span>
1225
1225
  <div class="name" style="margin-left:8px;">${escHtml(String(label))}</div>
1226
1226
  <div class="dim row-actions">bm25 ${Number(h.bm25 || 0).toFixed(2)}</div>
1227
1227
  </div>