lazyclaw 5.4.4 → 6.0.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 (45) hide show
  1. package/channels/handoff.mjs +36 -0
  2. package/cli.mjs +73 -7399
  3. package/daemon.mjs +23 -2085
  4. package/mas/agent_turn.mjs +2 -1
  5. package/mas/index_db.mjs +82 -0
  6. package/mas/learning.mjs +17 -1
  7. package/mas/mention_router.mjs +38 -10
  8. package/mas/provider_adapters.mjs +28 -4
  9. package/mas/scrub_env.mjs +34 -0
  10. package/mas/tool_runner.mjs +23 -7
  11. package/mas/tools/bash.mjs +10 -5
  12. package/mas/tools/browser.mjs +18 -0
  13. package/mas/tools/learning.mjs +24 -14
  14. package/mas/tools/recall.mjs +5 -1
  15. package/mas/tools/web.mjs +47 -11
  16. package/mas/trajectory_store.mjs +7 -4
  17. package/package.json +3 -2
  18. package/providers/auth_store.mjs +22 -0
  19. package/providers/claude_cli.mjs +28 -2
  20. package/providers/claude_cli_detect.mjs +46 -0
  21. package/providers/custom_provider.mjs +70 -0
  22. package/providers/model_catalogue.mjs +86 -0
  23. package/providers/orchestrator.mjs +30 -9
  24. package/providers/rates.mjs +12 -2
  25. package/providers/registry.mjs +10 -7
  26. package/sandbox/confiners/landlock.mjs +14 -8
  27. package/sandbox/confiners/seatbelt.mjs +18 -2
  28. package/scripts/loop-worker.mjs +18 -7
  29. package/scripts/migrate-v5.mjs +5 -61
  30. package/sessions.mjs +0 -0
  31. package/tui/modal_filter.mjs +59 -0
  32. package/tui/modal_picker.mjs +12 -37
  33. package/tui/pickers.mjs +917 -0
  34. package/tui/provider_families.mjs +41 -0
  35. package/tui/repl.mjs +67 -36
  36. package/tui/slash_commands.mjs +2 -1
  37. package/tui/slash_dispatcher.mjs +717 -58
  38. package/tui/splash.mjs +5 -12
  39. package/tui/subcommands.mjs +17 -0
  40. package/tui/terminal_approve.mjs +37 -0
  41. package/web/dashboard.css +275 -0
  42. package/web/dashboard.html +2 -1685
  43. package/web/dashboard.js +1406 -0
  44. package/workflow/persistent.mjs +13 -6
  45. package/mas/tools/skill_view.mjs +0 -43
@@ -0,0 +1,41 @@
1
+ // tui/provider_families.mjs — pure (react-free) provider bucketing for the
2
+ // drill-in provider picker. The flat "every provider in one list" picker the
3
+ // v5.4 Ink port shipped is overwhelming and even exposed the orchestrator
4
+ // (which must never be a default). This restores the legacy family grouping
5
+ // (API key / CLI-Local / Mock) as shared, unit-testable data so both the
6
+ // readline picker (cli.mjs) and the Ink dispatcher use one bucketing rule.
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.
12
+ export function bucketProviders(registry) {
13
+ const info = (registry && registry.PROVIDER_INFO) || {};
14
+ const all = Object.keys((registry && registry.PROVIDERS) || {});
15
+ const out = { api: [], cli: [], mock: [] };
16
+ for (const name of all) {
17
+ if (name === 'mock') out.mock.push(name);
18
+ else if (name === 'orchestrator') continue;
19
+ else if ((info[name] || {}).requiresApiKey) out.api.push(name);
20
+ else out.cli.push(name);
21
+ }
22
+ return out;
23
+ }
24
+
25
+ // Full family descriptors (label/desc/plain-text tag/members) for the Ink
26
+ // first-step picker. Tags are plain text — Ink applies its own styling.
27
+ export function providerFamilies(registry) {
28
+ const b = bucketProviders(registry);
29
+ return {
30
+ api: { id: 'api', label: 'API key', desc: 'paste an sk-... key', tag: 'needs key', members: b.api },
31
+ cli: { id: 'cli', label: 'CLI / Local', desc: 'keyless — an existing CLI login or a local daemon', tag: 'no key', members: b.cli },
32
+ mock: { id: 'mock', label: 'Mock', desc: 'offline echo, only useful for testing', tag: 'test', members: b.mock },
33
+ };
34
+ }
35
+
36
+ // Plain-text per-row tag for a provider, from its PROVIDER_INFO meta.
37
+ export function providerTag(meta) {
38
+ const m = meta || {};
39
+ if (m.custom) return 'custom';
40
+ return m.requiresApiKey ? 'api key' : 'no key';
41
+ }
package/tui/repl.mjs CHANGED
@@ -38,7 +38,7 @@ import { Splash, renderSplashToString } from './splash.mjs';
38
38
  import { Editor } from './editor.mjs';
39
39
  import { SlashPopup, filterSlashCommands } from './slash_popup.mjs';
40
40
  import { SLASH_COMMANDS } from './slash_commands.mjs';
41
- import { ModalPicker, filterModalItems } from './modal_picker.mjs';
41
+ import { ModalPicker, filterModalItems, resolveModalPick } from './modal_picker.mjs';
42
42
  import { theme } from './theme.mjs';
43
43
 
44
44
  // ─── Alt-buffer mount (DEC 1049) ─────────────────────────────────────────
@@ -63,6 +63,14 @@ export const ALT_BUFFER_ENTER = '\x1b[?1049h';
63
63
  export const ALT_BUFFER_LEAVE = '\x1b[?1049l';
64
64
  export const CURSOR_VISIBLE = '\x1b[?25h';
65
65
 
66
+ // Rendering-mode decision. Default = Static scrollback (no flicker; splash
67
+ // prints once + scrolls naturally). Alt-buffer fullscreen is opt-in via
68
+ // LAZYCLAW_ALT=1; LAZYCLAW_NO_ALT=1 forces it off. TTY-only either way.
69
+ export function computeAltEnabled(env, hasTTY) {
70
+ const e = env || {};
71
+ return !!hasTTY && !!e.LAZYCLAW_ALT && !e.LAZYCLAW_NO_ALT;
72
+ }
73
+
66
74
  export function FullScreen({ enabled, children }) {
67
75
  useEffect(() => {
68
76
  if (!enabled) return undefined;
@@ -151,10 +159,10 @@ export function onStreamChunk(state, { chunk }) {
151
159
  return { ...state, liveAssistant: state.liveAssistant + chunk };
152
160
  }
153
161
 
154
- export function onTurnComplete(state, { reason } = {}) {
162
+ export function onTurnComplete(state, { reason, error } = {}) {
155
163
  const promoted = state.pendingPrepend;
156
164
  const suffix = reason === 'aborted' ? ' [aborted]'
157
- : reason === 'error' ? ' [error]'
165
+ : reason === 'error' ? (error ? ` [error: ${error}]` : ' [error]')
158
166
  : '';
159
167
  const text = (state.liveAssistant || '') + suffix;
160
168
  // Commit any accumulated live text to scrollback. If the turn produced
@@ -188,13 +196,21 @@ export function consumeNextTurnFirstMessage(state) {
188
196
  // - runTurnFactory(writeFn) → runTurn(text, signal) (sticky layout)
189
197
  // - runTurn(text, signal) (legacy, stdout)
190
198
  // Legacy mode is preserved verbatim for the existing cli.mjs callsite.
191
- export function ReplApp({ splashProps, runTurn, runTurnFactory, slashCommands, onSlashCommand, statusInfo, pickerRef }) {
192
- // statusInfo lets the host supply provider/model/ctx to the StatusBar
193
- // independently of splashProps. Needed for the alt-buffer hand-off
194
- // where splashProps is pre-printed to the primary buffer (not the
195
- // alt canvas) and nulled out here to suppress the in-tree Static
196
- // splash item — but the StatusBar still needs provider/model strings.
197
- const _status = statusInfo || splashProps || {};
199
+ export function ReplApp({ splashProps, runTurn, runTurnFactory, slashCommands, onSlashCommand, statusInfo, getStatus, pickerRef }) {
200
+ // statusInfo seeds the StatusBar's provider/model/ctx. getStatus (optional)
201
+ // returns the live values so the bar refreshes after a /provider or /model
202
+ // switch and after each turn (token/ctx gauge) without it the bar would
203
+ // show whatever was captured at mount (stale after a slash mutates the
204
+ // active provider/model). v5.5.
205
+ const [statusState, setStatusState] = useState(() => statusInfo || splashProps || {});
206
+ const refreshStatus = useCallback(() => {
207
+ if (typeof getStatus !== 'function') return;
208
+ try {
209
+ const s = getStatus();
210
+ if (s) setStatusState((prev) => ({ ...prev, ...s }));
211
+ } catch { /* never let a status read break the turn */ }
212
+ }, [getStatus]);
213
+ const _status = statusState;
198
214
  // Splash is rendered ONCE as scrollback[0] via <Static>. Build it lazily
199
215
  // so SSR-style imports without a TTY don't crash on process.stdout.
200
216
  const splashItemRef = useRef(null);
@@ -206,12 +222,16 @@ export function ReplApp({ splashProps, runTurn, runTurnFactory, slashCommands, o
206
222
  const [state, setState] = useState(() => makeReplState({ splashItem: splashItemRef.current }));
207
223
  const { exit } = useApp();
208
224
 
209
- // Alt-buffer eligibility TTY only, opt-out via LAZYCLAW_NO_ALT=1.
210
- // Captured in a ref so the value is stable across renders (it cannot
211
- // change at runtime; isTTY + env are read once on mount).
225
+ // Rendering mode. Default is the Static scrollback (no full-frame redraw,
226
+ // so no typing flicker; the splash prints once to the primary buffer and
227
+ // scrolls naturally it never hits the alt-canvas vanish/blank bugs). The
228
+ // alt-buffer fullscreen (sticky-bottom Editor) is opt-in via LAZYCLAW_ALT=1,
229
+ // and LAZYCLAW_NO_ALT=1 still forces it off. TTY-only either way.
230
+ // Captured in a ref so the value is stable across renders (isTTY + env are
231
+ // read once on mount; they cannot change at runtime).
212
232
  const altEnabledRef = useRef(null);
213
233
  if (altEnabledRef.current === null) {
214
- altEnabledRef.current = !!(process.stdout && process.stdout.isTTY) && !process.env.LAZYCLAW_NO_ALT;
234
+ altEnabledRef.current = computeAltEnabled(process.env, process.stdout && process.stdout.isTTY);
215
235
  }
216
236
  const altEnabled = altEnabledRef.current;
217
237
 
@@ -265,15 +285,20 @@ export function ReplApp({ splashProps, runTurn, runTurnFactory, slashCommands, o
265
285
  const controller = new AbortController();
266
286
  setState((s) => onUserInput(s, { text: trimmed, controller }));
267
287
  try {
268
- const result = await onSlashCommand(trimmed);
288
+ // Pass the signal so the host can abort a long slash op (e.g. /loop)
289
+ // when the user hits Esc (onEscape aborts state.controller).
290
+ const result = await onSlashCommand(trimmed, controller.signal);
269
291
  if (result === 'EXIT') { exit(); return; }
270
292
  if (typeof result === 'string' && result.length > 0) {
271
293
  setState((s) => onStreamChunk(s, { chunk: result }));
272
294
  }
273
295
  setState((s) => onTurnComplete(s, { reason: 'done' }));
296
+ // A slash like /provider or /model mutates the host's active
297
+ // provider/model — refresh the StatusBar so it isn't stale.
298
+ refreshStatus();
274
299
  } catch (err) {
275
300
  setState((s) => onTurnComplete(s, {
276
- reason: err && err.name === 'AbortError' ? 'aborted' : 'error',
301
+ reason: err && err.name === 'AbortError' ? 'aborted' : 'error', error: err?.message || String(err),
277
302
  }));
278
303
  }
279
304
  return;
@@ -283,12 +308,13 @@ export function ReplApp({ splashProps, runTurn, runTurnFactory, slashCommands, o
283
308
  try {
284
309
  await runTurnRef.current(trimmed, controller.signal);
285
310
  setState((s) => onTurnComplete(s, { reason: 'done' }));
311
+ refreshStatus();
286
312
  } catch (err) {
287
313
  setState((s) => onTurnComplete(s, {
288
314
  reason: err && err.name === 'AbortError' ? 'aborted' : 'error',
289
315
  }));
290
316
  }
291
- }, [exit, onSlashCommand]);
317
+ }, [exit, onSlashCommand, refreshStatus]);
292
318
 
293
319
  // Auto-submit queued mid-stream-interrupt message (spec §5.8). Read
294
320
  // state.nextTurnFirstMessage so the effect re-fires when promoted.
@@ -425,9 +451,10 @@ export function ReplApp({ splashProps, runTurn, runTurnFactory, slashCommands, o
425
451
  });
426
452
  }, [modalView.length]);
427
453
  const onModalConfirm = useCallback(() => {
428
- const picked = modalView[modalIdx];
429
- closeModal(picked ? picked.id : null);
430
- }, [modalView, modalIdx, closeModal]);
454
+ // A `freeText` row resolves to { id, query } so the dispatcher can use
455
+ // the typed filter buffer as a custom value (e.g. an unlisted model id).
456
+ closeModal(resolveModalPick(modalView[modalIdx], modalQuery));
457
+ }, [modalView, modalIdx, modalQuery, closeModal]);
431
458
  const onModalCancel = useCallback(() => { closeModal(null); }, [closeModal]);
432
459
  const onModalQuery = useCallback((next) => {
433
460
  setModalQuery(next || '');
@@ -466,17 +493,16 @@ export function ReplApp({ splashProps, runTurn, runTurnFactory, slashCommands, o
466
493
  altEnabled
467
494
  ? React.createElement(
468
495
  Box,
469
- { flexDirection: 'column', flexGrow: 1, flexShrink: 1, overflow: 'hidden' },
470
- // v5.4.3 once any user/assistant turn has been committed,
471
- // drop the splash from the visible scrollback. Inside the
472
- // alt-buffer canvas the splash cannot scroll off naturally
473
- // (no native scrollback), so leaving it visible pushes the
474
- // newest content past the StatusBar+Editor on tall outputs
475
- // like /help. The splash still flashes on first mount.
476
- (state.scrollback.length > 1
477
- ? state.scrollback.filter((it) => it.kind !== 'splash')
478
- : state.scrollback
479
- ).map((item) =>
496
+ // v5.5 — `justifyContent: 'flex-end'` pins the newest content to
497
+ // the bottom of the fixed-height alt canvas; older lines (the
498
+ // splash / sloth + manual) overflow off the TOP and are clipped
499
+ // naturally, exactly like a real scrollback. This replaces the
500
+ // v5.4.3 hack that hard-dropped the splash after the first turn
501
+ // (which made the manual + character abruptly vanish the moment
502
+ // you ran any command). The splash now scrolls off only once
503
+ // there's enough content to push it past the top edge.
504
+ { flexDirection: 'column', flexGrow: 1, flexShrink: 1, overflow: 'hidden', justifyContent: 'flex-end' },
505
+ state.scrollback.map((item) =>
480
506
  React.createElement(ScrollbackItem, { key: item.id, item })
481
507
  ),
482
508
  // Live region — partial assistant stream (inside the scroll
@@ -567,10 +593,15 @@ export function ReplApp({ splashProps, runTurn, runTurnFactory, slashCommands, o
567
593
  );
568
594
  }
569
595
 
570
- // ScrollbackItem renders each <Static/> child. Splash renders via the
571
- // real <Splash/> component (preserves gradient wordmark colorization);
572
- // everything else is plain Text.
573
- export function ScrollbackItem({ item }) {
596
+ // ScrollbackItem renders each scrollback child. Splash renders via the real
597
+ // <Splash/> component (preserves gradient wordmark colorization); everything
598
+ // else is plain Text.
599
+ //
600
+ // Wrapped in React.memo: scrollback item objects are stable across renders,
601
+ // so when only the editor buffer changes (every keystroke) the memo skips
602
+ // re-rendering every committed line. Without this the whole alt-buffer
603
+ // scrollback re-rendered on each keypress — the source of the typing flicker.
604
+ export const ScrollbackItem = React.memo(function ScrollbackItem({ item }) {
574
605
  if (item.kind === 'splash') {
575
606
  return React.createElement(Splash, item.splashProps);
576
607
  }
@@ -586,7 +617,7 @@ export function ScrollbackItem({ item }) {
586
617
  }
587
618
  // 'assistant' (default)
588
619
  return React.createElement(Text, { color: theme.fg }, item.text);
589
- }
620
+ });
590
621
 
591
622
  // StatusBar — single row, provider · model · ctx · streaming indicator.
592
623
  // Kept intentionally minimal in v5.3; token gauges land separately once
@@ -25,6 +25,7 @@ export const SLASH_COMMANDS = [
25
25
  { cmd: '/trainer', help: 'view or set trainer provider/model: /trainer show|set <p:m>|clear' },
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
+ { cmd: '/menu', help: 'browse the full subcommand catalog (command palette)' },
28
29
  { cmd: '/loop', help: 'repeat one prompt: /loop "fix lint" [--max N] [--until "<regex>"]' },
29
30
  { cmd: '/goal', help: 'register/switch goal: /goal add NAME | /goal list' },
30
31
  { cmd: '/memory', help: 'show layered memory: /memory [core|recent|episodic [topic]]' },
@@ -32,7 +33,7 @@ export const SLASH_COMMANDS = [
32
33
  { cmd: '/dream', help: 'consolidate recent memory into per-topic episodic files' },
33
34
  { cmd: '/agent', help: 'spawn or switch agent: /agent NAME' },
34
35
  { cmd: '/team', help: 'list, create, or join a team' },
35
- { cmd: '/task', help: 'list/show/transcript/abandon/done/remove (start/tick: CLI)' },
36
+ { cmd: '/task', help: 'multi-agent tasks: start/tick/list/show/transcript/abandon/done/remove' },
36
37
  { cmd: '/handoff', help: 'hand current task off to another agent' },
37
38
  { cmd: '/exit', help: 'leave the chat' },
38
39
  { cmd: '/quit', help: 'alias for /exit' },