lazyclaw 5.3.2 → 5.4.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.
package/cli.mjs CHANGED
@@ -14,6 +14,10 @@ import { applyChatWindow as _applyChatWindow, CHAT_WINDOW_TURNS, CHAT_WINDOW_TOK
14
14
  // v5 Group C (C7) — shared chat-turn streaming closure. Single source
15
15
  // of truth for both the ink REPL path and the legacy readline path.
16
16
  import { makeRunTurn as _chatRunTurnFactory } from './tui/run_turn.mjs';
17
+ // v5.4: full slash-command dispatcher (24 commands) for the Ink branch.
18
+ // Lifted from the legacy handleSlash so the Ink REPL stops shipping the
19
+ // "not yet wired" placeholder.
20
+ import { dispatchSlash as _dispatchSlash, parseSlashLine as _parseSlashLine } from './tui/slash_dispatcher.mjs';
17
21
 
18
22
  async function loadEngine() {
19
23
  return import('./workflow/persistent.mjs');
@@ -2646,19 +2650,37 @@ async function cmdChat(flags = {}) {
2646
2650
  }).catch(() => {});
2647
2651
  } catch { /* swallow */ }
2648
2652
  };
2653
+ // v5.4: chars-sent counter for the Ink chat path. Mirrors the legacy
2654
+ // path's `charsSent` so /usage in Ink reports the same number.
2655
+ let _inkCharsSent = 0;
2649
2656
  const _inkCtx = {
2650
2657
  cfg,
2651
2658
  cfgDir: _inkCfgDir,
2652
2659
  sandboxSpec: _inkSandboxSpec,
2653
2660
  syntheticChatSessionId: _inkSyntheticChatSessionId,
2661
+ version: readVersionFromRepo(),
2662
+ registryMod: _registryMod,
2663
+ sessionsMod,
2664
+ // Pre-imported so dispatcher avoids a dynamic import per /skill call.
2665
+ skillsMod,
2654
2666
  getMessages: () => _inkMessages,
2667
+ setMessages: (next) => { _inkMessages = Array.isArray(next) ? next : []; },
2655
2668
  getProv: () => prov,
2669
+ setProv: (next) => { prov = next; },
2656
2670
  getActiveProvName: () => activeProvName,
2671
+ setActiveProvName: (name) => { activeProvName = name; },
2657
2672
  getActiveModel: () => activeModel,
2673
+ setActiveModel: (name) => { activeModel = name; },
2658
2674
  getSessionId: () => _inkSessionId,
2675
+ setSessionId: (id) => { _inkSessionId = id; },
2676
+ getCharsSent: () => _inkCharsSent,
2677
+ setCharsSent: (n) => { _inkCharsSent = Number(n) || 0; },
2678
+ getRunningUsage: () => _inkRunningUsage,
2679
+ setRunningUsage: (u) => { _inkRunningUsage = u; },
2659
2680
  persistTurn: _inkPersistTurn,
2660
2681
  accumulateUsage: _inkAccumulateUsage,
2661
2682
  resolveAuthKey: (providerName) => _resolveAuthKey(cfg, providerName),
2683
+ onCharsSent: (n) => { _inkCharsSent += Number(n) || 0; },
2662
2684
  };
2663
2685
  // v5.0.10: write streamed chunks straight to process.stdout. Ink
2664
2686
  // owns the screen, so interleaved stdout writes can produce some
@@ -2669,54 +2691,34 @@ async function cmdChat(flags = {}) {
2669
2691
  ctx: _inkCtx,
2670
2692
  writeFn: (chunk) => process.stdout.write(chunk),
2671
2693
  });
2672
- // Minimal slash dispatcher for the Ink branch. Covers the read-only
2673
- // info commands so the user sees something useful instead of having
2674
- // their slash command sent to the model as a prompt. /exit + /quit
2675
- // are intercepted inside ReplApp before this fires. Returning a
2676
- // string causes ReplApp to render the result into scrollback.
2677
- //
2678
- // The full set of mutating commands (/model, /provider, /skill,
2679
- // /personality, ...) still lives in the legacy readline path and
2680
- // remains accessible via LAZYCLAW_NO_INK=1. Wiring those through
2681
- // the Ink branch is a follow-up; today we at least stop sending
2682
- // them as prompts.
2694
+ // v5.4: full slash-command dispatch via tui/slash_dispatcher.mjs.
2695
+ // Dispatcher returns a string (rendered to scrollback by ReplApp),
2696
+ // 'EXIT' (caller unmounts), or void (streamed via write). /exit and
2697
+ // /quit are also intercepted earlier inside ReplApp.handleSubmit so
2698
+ // either path terminates cleanly.
2683
2699
  const _inkSlashHandler = async (line) => {
2684
- const cmd = line.split(/\s+/)[0];
2685
- switch (cmd) {
2686
- case '/help': {
2687
- const lines = ['slash commands:'];
2688
- for (const c of SLASH_COMMANDS) lines.push(` ${c.cmd.padEnd(14)} — ${c.help}`);
2689
- return lines.join('\n') + '\n';
2690
- }
2691
- case '/status': {
2692
- const out = {
2693
- provider: activeProvName,
2694
- model: activeModel,
2695
- keyMasked: _registryMod.maskApiKey(cfg['api-key']),
2696
- messageCount: _inkMessages.length,
2697
- };
2698
- return JSON.stringify(out) + '\n';
2699
- }
2700
- case '/version': {
2701
- return `lazyclaw ${readVersionFromRepo()} (node ${process.version}, ${process.platform})\n`;
2702
- }
2703
- case '/exit':
2704
- case '/quit':
2705
- // ReplApp intercepts these before onSlashCommand fires, but
2706
- // return EXIT defensively in case that contract ever changes.
2707
- return 'EXIT';
2708
- default:
2709
- // Mutating commands still require the legacy readline path.
2710
- // Telling the user explicitly beats silently sending the
2711
- // slash text to the model as a prompt.
2712
- return `${cmd} is not yet wired into the ink REPL — set LAZYCLAW_NO_INK=1 and restart to use it.\n`;
2713
- }
2700
+ const { cmd, args } = _parseSlashLine(line);
2701
+ return _dispatchSlash(cmd, args, _inkCtx, (chunk) => {
2702
+ try { process.stdout.write(chunk); } catch { /* swallow */ }
2703
+ });
2714
2704
  };
2705
+ // v5.4 alt-buffer splash hand-off — when the alt-screen will mount,
2706
+ // pre-print the splash to the PRIMARY buffer so it survives in the
2707
+ // user's normal scrollback after chat exit (mirrors Claude CLI /
2708
+ // opencode behavior). The Static splash item would otherwise live
2709
+ // only inside the alt canvas and disappear on unmount. When alt is
2710
+ // disabled (non-TTY pipelines, LAZYCLAW_NO_ALT), keep the legacy
2711
+ // in-tree splash so the pre-existing visual is unchanged.
2712
+ const _altWillMount = !!process.stdout.isTTY && !process.env.LAZYCLAW_NO_ALT;
2713
+ if (_altWillMount) {
2714
+ try { process.stdout.write(renderSplashToString(splashProps) + '\n'); } catch { /* swallow */ }
2715
+ }
2715
2716
  const ink = render(/* @__PURE__ */ React.createElement(ReplApp, {
2716
- splashProps,
2717
+ splashProps: _altWillMount ? null : splashProps,
2718
+ statusInfo: { provider: activeProvName, model: activeModel },
2717
2719
  runTurn: _inkRunTurn,
2718
2720
  onSlashCommand: _inkSlashHandler,
2719
- }));
2721
+ }), { exitOnCtrlC: true, patchConsole: true });
2720
2722
  await ink.waitUntilExit();
2721
2723
  return;
2722
2724
  } catch (e) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lazyclaw",
3
- "version": "5.3.2",
3
+ "version": "5.4.0",
4
4
  "description": "Lazy, elegant terminal CLI for chatting with Claude / OpenAI / Gemini / Ollama, orchestrating multi-step LLM workflows, and running multi-agent Slack teams with cross-task memory. Banner-on-launch, slash-command ghost autocomplete, persistent sessions, local HTTP gateway.",
5
5
  "keywords": [
6
6
  "claude",
@@ -87,6 +87,7 @@
87
87
  "better-sqlite3": "^11.10.0",
88
88
  "chalk": "^5.3.0",
89
89
  "ink": "^5.0.1",
90
+ "ink-testing-library": "^4.0.0",
90
91
  "node-ssh": "^13.2.0",
91
92
  "string-width": "^7.2.0",
92
93
  "undici": "^6.21.0"
package/tui/editor.mjs CHANGED
@@ -217,6 +217,49 @@ export function Editor({
217
217
  }, [state.lastSubmit]);
218
218
 
219
219
  const lines = state.buffer.split('\n');
220
+ // Manual cell-aware wrapping. Ink's <Text wrap="wrap"> uses wrap-ansi,
221
+ // which IS string-width aware, but the box's width:'100%' resolves
222
+ // against ink-testing-library's stdout shim (and some real terminals
223
+ // when columns is reset by SIGWINCH late) at 100 cols regardless of
224
+ // the actual viewport — so wide CJK buffers visibly bleed past the
225
+ // box right edge in narrow terminals. Pre-wrap to the actual cell
226
+ // budget so Ink never has to guess.
227
+ const TERM = Math.max(20, process.stdout.columns || 80);
228
+ // Box overhead: 1 border + 1 padX on each side = 4 cells; first row
229
+ // also reserves PROMPT_WIDTH; continuation rows reserve CONTINUATION_WIDTH.
230
+ function wrapToBudget(text, firstBudget, contBudget) {
231
+ if (!text) return [''];
232
+ const out = [];
233
+ let line = '';
234
+ let lineW = 0;
235
+ let budget = firstBudget;
236
+ for (const ch of text) {
237
+ const w = stringWidth(ch);
238
+ if (lineW + w > budget) {
239
+ out.push(line);
240
+ line = ch;
241
+ lineW = w;
242
+ budget = contBudget;
243
+ } else {
244
+ line += ch;
245
+ lineW += w;
246
+ }
247
+ }
248
+ out.push(line);
249
+ return out;
250
+ }
251
+ const innerCells = Math.max(8, TERM - 4); // 2 border + 2 padX
252
+ const renderedLines = [];
253
+ for (let li = 0; li < lines.length; li++) {
254
+ const wrapped = wrapToBudget(lines[li], innerCells - PROMPT_WIDTH, innerCells - CONTINUATION_WIDTH);
255
+ for (let wi = 0; wi < wrapped.length; wi++) {
256
+ const isFirstLogical = li === 0 && wi === 0;
257
+ renderedLines.push({
258
+ prefix: isFirstLogical ? theme.accent(PROMPT_PREFIX) : CONTINUATION_GUTTER,
259
+ text: wrapped[wi],
260
+ });
261
+ }
262
+ }
220
263
  return React.createElement(
221
264
  Box,
222
265
  {
@@ -225,17 +268,12 @@ export function Editor({
225
268
  paddingX: 1,
226
269
  flexDirection: 'column',
227
270
  flexShrink: 0,
228
- // Pin to full terminal width. Ink's wrap-ansi (which is
229
- // string-width aware) then has the correct cell budget for
230
- // wrapping long CJK buffers — fixes the right-edge truncation
231
- // perceived on Hangul / Han input. See `displayWidth`/
232
- // `cursorDisplayCol` above for the public width helpers.
233
- width: '100%',
271
+ width: TERM,
234
272
  },
235
- lines.map((ln, i) => React.createElement(
273
+ renderedLines.map((row, i) => React.createElement(
236
274
  Text,
237
- { key: i },
238
- i === 0 ? theme.accent(PROMPT_PREFIX) + ln : CONTINUATION_GUTTER + ln,
275
+ { key: i, wrap: 'truncate' },
276
+ row.prefix + row.text,
239
277
  )),
240
278
  );
241
279
  }
package/tui/repl.mjs CHANGED
@@ -29,13 +29,66 @@
29
29
  // sticky layout; ReplApp injects writeFn → scrollback.
30
30
  //
31
31
  import React, { useState, useEffect, useRef, useCallback, useMemo } from 'react';
32
- import { Box, Static, Text, useApp } from 'ink';
32
+ import { Box, Static, Text, useApp, useStdout } from 'ink';
33
33
  import { Splash, renderSplashToString } from './splash.mjs';
34
34
  import { Editor } from './editor.mjs';
35
35
  import { SlashPopup, filterSlashCommands } from './slash_popup.mjs';
36
36
  import { SLASH_COMMANDS } from './slash_commands.mjs';
37
37
  import { theme } from './theme.mjs';
38
38
 
39
+ // ─── Alt-buffer mount (DEC 1049) ─────────────────────────────────────────
40
+ //
41
+ // Wraps the React tree with mount/unmount side-effects that enable the
42
+ // terminal alternate screen buffer. Three-layer cleanup so the user never
43
+ // gets stranded on the alt canvas:
44
+ // 1. React unmount → useEffect return-fn writes \x1b[?1049l
45
+ // 2. Rude shutdown (SIGINT/SIGTERM/SIGHUP/'exit') → same escape via
46
+ // process-level listeners that we install + remove on unmount.
47
+ // 3. cursor-visible safety on unmount (\x1b[?25h) in case anything
48
+ // below us turned it off.
49
+ //
50
+ // We deliberately do NOT install an uncaughtException handler — Ink
51
+ // already installs one and re-throws; ours would swallow the stack
52
+ // trace (violates §1 Truthfulness / no silent catch).
53
+ //
54
+ // `enabled` is false for non-TTY pipelines, CI, ink-testing-library, and
55
+ // the LAZYCLAW_NO_ALT escape hatch. When false this is a pass-through —
56
+ // no escape sequences leak into stdout.
57
+ export const ALT_BUFFER_ENTER = '\x1b[?1049h';
58
+ export const ALT_BUFFER_LEAVE = '\x1b[?1049l';
59
+ export const CURSOR_VISIBLE = '\x1b[?25h';
60
+
61
+ export function FullScreen({ enabled, children }) {
62
+ useEffect(() => {
63
+ if (!enabled) return undefined;
64
+ // Mount: enter alternate screen buffer.
65
+ try { process.stdout.write(ALT_BUFFER_ENTER); } catch { /* swallow — stdout closed */ }
66
+
67
+ // Rude-shutdown listeners. Each writes 1049l + cursor-visible so the
68
+ // terminal is restored even if React never gets a chance to unmount
69
+ // (e.g. parent process kills us with SIGTERM).
70
+ const restore = () => {
71
+ try { process.stdout.write(ALT_BUFFER_LEAVE + CURSOR_VISIBLE); } catch {}
72
+ };
73
+ const onExit = () => { restore(); };
74
+ const onSignal = () => { restore(); };
75
+ process.once('exit', onExit);
76
+ process.once('SIGINT', onSignal);
77
+ process.once('SIGTERM', onSignal);
78
+ process.once('SIGHUP', onSignal);
79
+
80
+ return () => {
81
+ // React unmount: restore primary buffer.
82
+ restore();
83
+ process.removeListener('exit', onExit);
84
+ process.removeListener('SIGINT', onSignal);
85
+ process.removeListener('SIGTERM', onSignal);
86
+ process.removeListener('SIGHUP', onSignal);
87
+ };
88
+ }, [enabled]);
89
+ return children;
90
+ }
91
+
39
92
  // ─── Pure state ──────────────────────────────────────────────────────────
40
93
  //
41
94
  // makeReplState stays callable with zero args (existing tests rely on it).
@@ -130,7 +183,13 @@ export function consumeNextTurnFirstMessage(state) {
130
183
  // - runTurnFactory(writeFn) → runTurn(text, signal) (sticky layout)
131
184
  // - runTurn(text, signal) (legacy, stdout)
132
185
  // Legacy mode is preserved verbatim for the existing cli.mjs callsite.
133
- export function ReplApp({ splashProps, runTurn, runTurnFactory, slashCommands, onSlashCommand }) {
186
+ export function ReplApp({ splashProps, runTurn, runTurnFactory, slashCommands, onSlashCommand, statusInfo }) {
187
+ // statusInfo lets the host supply provider/model/ctx to the StatusBar
188
+ // independently of splashProps. Needed for the alt-buffer hand-off
189
+ // where splashProps is pre-printed to the primary buffer (not the
190
+ // alt canvas) and nulled out here to suppress the in-tree Static
191
+ // splash item — but the StatusBar still needs provider/model strings.
192
+ const _status = statusInfo || splashProps || {};
134
193
  // Splash is rendered ONCE as scrollback[0] via <Static>. Build it lazily
135
194
  // so SSR-style imports without a TTY don't crash on process.stdout.
136
195
  const splashItemRef = useRef(null);
@@ -142,6 +201,28 @@ export function ReplApp({ splashProps, runTurn, runTurnFactory, slashCommands, o
142
201
  const [state, setState] = useState(() => makeReplState({ splashItem: splashItemRef.current }));
143
202
  const { exit } = useApp();
144
203
 
204
+ // Alt-buffer eligibility — TTY only, opt-out via LAZYCLAW_NO_ALT=1.
205
+ // Captured in a ref so the value is stable across renders (it cannot
206
+ // change at runtime; isTTY + env are read once on mount).
207
+ const altEnabledRef = useRef(null);
208
+ if (altEnabledRef.current === null) {
209
+ altEnabledRef.current = !!(process.stdout && process.stdout.isTTY) && !process.env.LAZYCLAW_NO_ALT;
210
+ }
211
+ const altEnabled = altEnabledRef.current;
212
+
213
+ // Pin the outer column height to rows-1 in alt-buffer mode so Ink's
214
+ // sticky-bottom Editor actually pins. Non-alt mode keeps the legacy
215
+ // content-sized layout so existing tests + non-TTY fallbacks behave
216
+ // identically. Listen for SIGWINCH-driven resize events.
217
+ const { stdout } = useStdout();
218
+ const [rows, setRows] = useState(() => (stdout && stdout.rows) || 24);
219
+ useEffect(() => {
220
+ if (!stdout) return undefined;
221
+ const onResize = () => setRows((stdout.rows) || 24);
222
+ stdout.on('resize', onResize);
223
+ return () => { stdout.off('resize', onResize); };
224
+ }, [stdout]);
225
+
145
226
  // writeFn for run_turn: route chunks into React state instead of stdout.
146
227
  // Only used when runTurnFactory is provided; the legacy `runTurn` prop
147
228
  // keeps writing wherever its caller wired it.
@@ -274,51 +355,89 @@ export function ReplApp({ splashProps, runTurn, runTurnFactory, slashCommands, o
274
355
  const showSlashPopup =
275
356
  bufferPeek.startsWith('/') && filtered.length > 0 && !_exactOnly;
276
357
 
358
+ // Outer column height: pinned to rows-1 in alt-buffer mode so the
359
+ // Editor truly sticks to the bottom. Non-alt keeps content-sized layout
360
+ // (legacy behavior — required for existing snapshot tests + non-TTY).
361
+ const outerHeight = altEnabled ? Math.max(1, rows - 1) : undefined;
362
+
277
363
  return React.createElement(
278
- Box,
279
- { flexDirection: 'column' },
280
- // 1) Scrollback (write-once via Ink Static)
364
+ FullScreen,
365
+ { enabled: altEnabled },
281
366
  React.createElement(
282
- Static,
283
- { items: state.scrollback },
284
- (item) => React.createElement(ScrollbackItem, { key: item.id, item })
285
- ),
286
- // 2) Live region partial assistant stream
287
- state.liveAssistant
288
- ? React.createElement(
289
- Box,
290
- { flexDirection: 'column' },
291
- React.createElement(Text, { color: theme.fg }, state.liveAssistant)
292
- )
293
- : null,
294
- // 3) Slash popup — flex sibling above the StatusBar; Ink can't
295
- // absolutely position so this is the "just above input" pattern.
296
- showSlashPopup
297
- ? React.createElement(SlashPopup, {
298
- buffer: bufferPeek,
299
- commands: filtered,
300
- selectedIndex: selectedSuggestion,
367
+ Box,
368
+ { flexDirection: 'column', height: outerHeight },
369
+ // 1) Scrollback (write-once via Ink Static).
370
+ // In alt-buffer mode the Static lives inside a flex-grow inner
371
+ // Box so it absorbs slack rather than pushing the editor out of
372
+ // the viewport. In legacy mode it stays sibling-flat so the
373
+ // structural test (Editor must be last sibling) still passes.
374
+ altEnabled
375
+ ? React.createElement(
376
+ Box,
377
+ { flexDirection: 'column', flexGrow: 1, flexShrink: 1, overflow: 'hidden' },
378
+ React.createElement(
379
+ Static,
380
+ { items: state.scrollback },
381
+ (item) => React.createElement(ScrollbackItem, { key: item.id, item })
382
+ ),
383
+ // Live region — partial assistant stream (inside the scroll
384
+ // region so it grows naturally above the status bar).
385
+ state.liveAssistant
386
+ ? React.createElement(
387
+ Box,
388
+ { flexDirection: 'column' },
389
+ React.createElement(Text, { color: theme.fg }, state.liveAssistant)
390
+ )
391
+ : null,
392
+ )
393
+ : React.createElement(
394
+ Static,
395
+ { items: state.scrollback },
396
+ (item) => React.createElement(ScrollbackItem, { key: item.id, item })
397
+ ),
398
+ // Live region (legacy path only — alt path already rendered it inside the inner Box).
399
+ !altEnabled && state.liveAssistant
400
+ ? React.createElement(
401
+ Box,
402
+ { flexDirection: 'column' },
403
+ React.createElement(Text, { color: theme.fg }, state.liveAssistant)
404
+ )
405
+ : null,
406
+ // 3) Slash popup — flex sibling above the StatusBar; Ink can't
407
+ // absolutely position so this is the "just above input" pattern.
408
+ showSlashPopup
409
+ ? React.createElement(SlashPopup, {
410
+ buffer: bufferPeek,
411
+ commands: filtered,
412
+ selectedIndex: selectedSuggestion,
413
+ })
414
+ : null,
415
+ // 4) Status bar (sticky, single row above input). flexShrink:0 so
416
+ // it isn't squeezed when the scrollback grows.
417
+ React.createElement(StatusBar, {
418
+ provider: _status.provider,
419
+ model: _status.model,
420
+ streaming: state.streaming,
421
+ ctxUsed: _status.ctxUsed,
422
+ ctxTotal: _status.ctxTotal,
423
+ }),
424
+ // 5) Editor — sticky bottom, content-sized. Wrapped in a flexShrink:0
425
+ // Box so Yoga doesn't squeeze the input row when scrollback fills.
426
+ React.createElement(
427
+ Box,
428
+ { flexShrink: 0, flexDirection: 'column' },
429
+ React.createElement(Editor, {
430
+ history: state.history,
431
+ onSubmit: handleSubmit,
432
+ onEscape: onEscapeKey,
433
+ onBufferChange: handleBufferChange,
434
+ slashSuggestions: showSlashPopup ? filtered : null,
435
+ slashSelectedIndex: selectedSuggestion,
436
+ onSlashMove: handleSlashMove,
437
+ onSlashDismiss: handleSlashDismiss,
301
438
  })
302
- : null,
303
- // 4) Status bar (sticky, single row above input)
304
- React.createElement(StatusBar, {
305
- provider: splashProps && splashProps.provider,
306
- model: splashProps && splashProps.model,
307
- streaming: state.streaming,
308
- ctxUsed: splashProps && splashProps.ctxUsed,
309
- ctxTotal: splashProps && splashProps.ctxTotal,
310
- }),
311
- // 5) Editor — sticky bottom, content-sized
312
- React.createElement(Editor, {
313
- history: state.history,
314
- onSubmit: handleSubmit,
315
- onEscape: onEscapeKey,
316
- onBufferChange: handleBufferChange,
317
- slashSuggestions: showSlashPopup ? filtered : null,
318
- slashSelectedIndex: selectedSuggestion,
319
- onSlashMove: handleSlashMove,
320
- onSlashDismiss: handleSlashDismiss,
321
- })
439
+ )
440
+ )
322
441
  );
323
442
  }
324
443
 
@@ -0,0 +1,619 @@
1
+ // tui/slash_dispatcher.mjs — single source of slash-command routing for the
2
+ // Ink chat REPL (v5.4). Lifted from cli.mjs's legacy readline handler so the
3
+ // Ink branch stops shipping the "not yet wired" placeholder and so future
4
+ // channels (Slack/Telegram inline commands, /handoff cross-channel control)
5
+ // can share one dispatch table.
6
+ //
7
+ // Contract:
8
+ // dispatchSlash(cmd, args, ctx, write) → Promise<string|'EXIT'|void>
9
+ // · returns 'EXIT' — caller should unmount/exit
10
+ // · returns a string — caller writes it to scrollback verbatim
11
+ // · returns void — handler already streamed via `write(...)`
12
+ // · throws — caller surfaces as an error scrollback item
13
+ //
14
+ // `ctx` shape (see slash_dispatcher.test.mjs for the canonical mock):
15
+ // {
16
+ // cfg, cfgDir, version, syntheticChatSessionId,
17
+ // getMessages / setMessages,
18
+ // getActiveProvName / setActiveProvName,
19
+ // getActiveModel / setActiveModel,
20
+ // getProv / setProv,
21
+ // getSessionId / setSessionId,
22
+ // getCharsSent / setCharsSent,
23
+ // getRunningUsage / setRunningUsage,
24
+ // persistTurn, accumulateUsage,
25
+ // resolveAuthKey,
26
+ // registryMod, // optional — falls back to dynamic import
27
+ // sessionsMod, // optional — falls back to dynamic import
28
+ // skillsMod, // optional — falls back to dynamic import
29
+ // }
30
+ //
31
+ // Interactive sub-menus (provider/model pickers, /personality picker) are
32
+ // readline-coupled in cli.mjs. In Ink we surface a hint instead of crashing;
33
+ // operators can pass an arg form (e.g. `/provider openai`) or fall back to
34
+ // LAZYCLAW_NO_INK=1. Re-implementing these as Ink overlays is a v5.5 item.
35
+
36
+ import { SLASH_COMMANDS } from './slash_commands.mjs';
37
+
38
+ // ─── helpers ─────────────────────────────────────────────────────────────
39
+
40
+ /**
41
+ * Split a raw slash line into { cmd, args }. The cmd retains its leading
42
+ * slash; args is everything after the first whitespace, with trailing
43
+ * whitespace stripped. Empty args yields ''.
44
+ */
45
+ export function parseSlashLine(line) {
46
+ const raw = (line || '').replace(/\s+$/, '');
47
+ const m = raw.match(/^(\/\S+)(\s+(.*))?$/);
48
+ if (!m) return { cmd: raw, args: '' };
49
+ return { cmd: m[1], args: (m[3] || '').trim() };
50
+ }
51
+
52
+ // Tiny utility — split args on whitespace, drop empties. Used by sub-command
53
+ // handlers that don't need the loop-engine's full quote-aware splitter.
54
+ function splitWhitespace(s) {
55
+ return (s || '').split(/\s+/).filter(Boolean);
56
+ }
57
+
58
+ // Best-effort dynamic import. Returns the resolved ctx field if the caller
59
+ // pre-injected it (test hot path), else loads the real module. Throwing is
60
+ // fine — handlers wrap calls in try/catch where appropriate.
61
+ async function _mod(ctx, key, importer) {
62
+ if (ctx && ctx[key]) return ctx[key];
63
+ return importer();
64
+ }
65
+
66
+ // ─── handlers ────────────────────────────────────────────────────────────
67
+
68
+ async function _help() {
69
+ const lines = ['slash commands:'];
70
+ for (const c of SLASH_COMMANDS) lines.push(` ${c.cmd.padEnd(14)} — ${c.help}`);
71
+ return lines.join('\n');
72
+ }
73
+
74
+ async function _status(_args, ctx) {
75
+ const registry = await _mod(ctx, 'registryMod', () => import('../providers/registry.mjs'));
76
+ const out = {
77
+ provider: ctx.getActiveProvName(),
78
+ model: ctx.getActiveModel(),
79
+ keyMasked: registry.maskApiKey(ctx.cfg && ctx.cfg['api-key']),
80
+ messageCount: ctx.getMessages().length,
81
+ sessionId: ctx.getSessionId() || null,
82
+ };
83
+ return JSON.stringify(out);
84
+ }
85
+
86
+ async function _version(_args, ctx) {
87
+ const v = ctx.version || '0.0.0';
88
+ return `lazyclaw ${v} (node ${process.version}, ${process.platform})`;
89
+ }
90
+
91
+ async function _usage(_args, ctx) {
92
+ const msgs = ctx.getMessages();
93
+ const runningUsage = ctx.getRunningUsage && ctx.getRunningUsage();
94
+ const out = {
95
+ messageCount: msgs.length,
96
+ charsSent: (ctx.getCharsSent && ctx.getCharsSent()) || 0,
97
+ };
98
+ if (runningUsage) out.tokens = runningUsage;
99
+ if (runningUsage && ctx.cfg && ctx.cfg.rates && typeof ctx.cfg.rates === 'object') {
100
+ try {
101
+ const { costFromUsage } = await import('../providers/rates.mjs');
102
+ const r = costFromUsage(
103
+ { provider: ctx.getActiveProvName(), model: ctx.getActiveModel(), usage: runningUsage },
104
+ ctx.cfg.rates,
105
+ );
106
+ if (r) out.cost = r;
107
+ } catch { /* never let cost-card lookup fail the slash */ }
108
+ }
109
+ return JSON.stringify(out);
110
+ }
111
+
112
+ async function _newReset(_args, ctx) {
113
+ if (ctx.setMessages) ctx.setMessages([]);
114
+ if (ctx.setCharsSent) ctx.setCharsSent(0);
115
+ if (ctx.setRunningUsage) ctx.setRunningUsage(null);
116
+ const sid = ctx.getSessionId && ctx.getSessionId();
117
+ if (sid) {
118
+ try {
119
+ const sm = await _mod(ctx, 'sessionsMod', () => import('../sessions.mjs'));
120
+ sm.resetSession(sid, ctx.cfgDir);
121
+ } catch (e) {
122
+ return `cleared in-memory; session reset failed: ${e?.message || e}`;
123
+ }
124
+ }
125
+ return 'cleared — new conversation';
126
+ }
127
+
128
+ async function _provider(args, ctx) {
129
+ const registry = await _mod(ctx, 'registryMod', () => import('../providers/registry.mjs'));
130
+ const lookup = (name) => {
131
+ if (typeof registry.lookupProv === 'function') return registry.lookupProv(name);
132
+ return registry.PROVIDERS ? registry.PROVIDERS[name] : null;
133
+ };
134
+ if (!args) {
135
+ return `provider: ${ctx.getActiveProvName()}\n(interactive picker not available in Ink chat — pass an arg: /provider <name>)`;
136
+ }
137
+ const next = lookup(args);
138
+ if (!next) {
139
+ const known = registry.PROVIDERS ? Object.keys(registry.PROVIDERS).join(', ') : '?';
140
+ return `unknown provider: ${args} (known: ${known})`;
141
+ }
142
+ if (ctx.setActiveProvName) ctx.setActiveProvName(args);
143
+ if (ctx.setProv) ctx.setProv(next);
144
+ return `provider → ${args}`;
145
+ }
146
+
147
+ async function _model(args, ctx) {
148
+ const registry = await _mod(ctx, 'registryMod', () => import('../providers/registry.mjs'));
149
+ if (!args) {
150
+ return `model: ${ctx.getActiveModel() || '(default)'}\n(interactive picker not available in Ink chat — pass an arg: /model <name>)`;
151
+ }
152
+ const { parseSlashProviderModel } = registry;
153
+ const parsed = typeof parseSlashProviderModel === 'function'
154
+ ? parseSlashProviderModel(args)
155
+ : { provider: null, model: args };
156
+ if (parsed.provider) {
157
+ const lookup = (name) => (typeof registry.lookupProv === 'function'
158
+ ? registry.lookupProv(name)
159
+ : (registry.PROVIDERS ? registry.PROVIDERS[name] : null));
160
+ const next = lookup(parsed.provider);
161
+ if (!next) return `unknown provider: ${parsed.provider}`;
162
+ if (ctx.setActiveProvName) ctx.setActiveProvName(parsed.provider);
163
+ if (ctx.setProv) ctx.setProv(next);
164
+ }
165
+ const finalModel = parsed.model || args;
166
+ if (ctx.setActiveModel) ctx.setActiveModel(finalModel);
167
+ return `model → ${finalModel}${parsed.provider ? ` (provider → ${parsed.provider})` : ''}`;
168
+ }
169
+
170
+ async function _skill(args, ctx) {
171
+ // `/skill name1,name2` — replace the active system message with a
172
+ // composition. No-arg → clear system message. Mirrors cli.mjs:3046.
173
+ const names = args.split(',').map((s) => s.trim()).filter(Boolean);
174
+ const messages = ctx.getMessages().slice(); // mutable copy
175
+ const sysIdx = messages.findIndex((m) => m.role === 'system');
176
+ const sid = ctx.getSessionId && ctx.getSessionId();
177
+ const sessionsMod = await _mod(ctx, 'sessionsMod', () => import('../sessions.mjs'));
178
+
179
+ if (names.length === 0) {
180
+ if (sysIdx >= 0) messages.splice(sysIdx, 1);
181
+ if (ctx.setMessages) ctx.setMessages(messages);
182
+ if (sid) {
183
+ try {
184
+ sessionsMod.resetSession(sid, ctx.cfgDir);
185
+ for (const m of messages) sessionsMod.appendTurn(sid, m.role, m.content, ctx.cfgDir);
186
+ } catch { /* swallow — disk failure shouldn't lose in-memory state */ }
187
+ }
188
+ return 'cleared system prompt (no active skills)';
189
+ }
190
+ try {
191
+ const skillsMod = await _mod(ctx, 'skillsMod', () => import('../skills.mjs'));
192
+ const sys = skillsMod.composeSystemPrompt(names, ctx.cfgDir);
193
+ if (!sys) return 'no skill content composed (empty input?)';
194
+ const nextMsg = { role: 'system', content: sys };
195
+ if (sysIdx >= 0) messages[sysIdx] = nextMsg;
196
+ else messages.unshift(nextMsg);
197
+ if (ctx.setMessages) ctx.setMessages(messages);
198
+ if (sid) {
199
+ try {
200
+ sessionsMod.resetSession(sid, ctx.cfgDir);
201
+ for (const m of messages) sessionsMod.appendTurn(sid, m.role, m.content, ctx.cfgDir);
202
+ } catch { /* swallow */ }
203
+ }
204
+ return `active skills: ${names.join(', ')}`;
205
+ } catch (e) {
206
+ return `skill error: ${e?.message || e}`;
207
+ }
208
+ }
209
+
210
+ async function _tools(_args) {
211
+ let registry;
212
+ try {
213
+ registry = await import('../mas/tools/registry.mjs');
214
+ } catch (e) {
215
+ return `tools unavailable: ${e?.message || e}`;
216
+ }
217
+ const groups = registry.byCategory ? registry.byCategory() : {};
218
+ const cats = Object.keys(groups).sort();
219
+ if (cats.length === 0) return '(no tools registered)';
220
+ const lines = ['available tools (by category):'];
221
+ for (const cat of cats) {
222
+ const items = groups[cat] || [];
223
+ lines.push(` ${cat}:`);
224
+ for (const t of items) lines.push(` · ${t.name}${t.description ? ' — ' + t.description : ''}`);
225
+ }
226
+ return lines.join('\n');
227
+ }
228
+
229
+ async function _recall(args, ctx) {
230
+ if (!args) return 'usage: /recall <query>';
231
+ let mem;
232
+ try {
233
+ mem = await import('../memory.mjs');
234
+ } catch (e) {
235
+ return `recall unavailable: ${e?.message || e}`;
236
+ }
237
+ try {
238
+ const text = mem.recall(args, { topN: 5 }, ctx.cfgDir);
239
+ if (!text || !text.trim()) return `no matches for "${args}"`;
240
+ return text;
241
+ } catch (e) {
242
+ return `recall error: ${e?.message || e}`;
243
+ }
244
+ }
245
+
246
+ async function _memory(args, ctx) {
247
+ let mem;
248
+ try { mem = await import('../memory.mjs'); }
249
+ catch (e) { return `memory unavailable: ${e?.message || e}`; }
250
+ const tokens = splitWhitespace(args);
251
+ const which = tokens[0] || 'core';
252
+ if (which === 'core') {
253
+ const body = mem.loadCore(ctx.cfgDir);
254
+ return body || '(empty core memory)';
255
+ }
256
+ if (which === 'recent') {
257
+ const items = mem.loadRecent(20, ctx.cfgDir);
258
+ return JSON.stringify(items, null, 2);
259
+ }
260
+ if (which === 'episodic') {
261
+ const topic = tokens[1];
262
+ if (topic) {
263
+ const body = mem.loadEpisodic(topic, ctx.cfgDir);
264
+ return body || `(no episodic file "${topic}")`;
265
+ }
266
+ return JSON.stringify(mem.listEpisodic(ctx.cfgDir), null, 2);
267
+ }
268
+ return 'usage: /memory [core|recent|episodic [topic]]';
269
+ }
270
+
271
+ async function _dream(_args, ctx, write) {
272
+ let mem;
273
+ try { mem = await import('../memory.mjs'); }
274
+ catch (e) { return `dream unavailable: ${e?.message || e}`; }
275
+ if (typeof write === 'function') {
276
+ try { write(' ↯ dreaming…\n'); } catch { /* swallow */ }
277
+ }
278
+ try {
279
+ const r = await mem.dream(ctx.getSessionId && ctx.getSessionId(), {
280
+ provider: ctx.getProv(),
281
+ model: ctx.getActiveModel(),
282
+ apiKey: ctx.resolveAuthKey ? ctx.resolveAuthKey(ctx.getActiveProvName()) : null,
283
+ }, ctx.cfgDir);
284
+ return `✓ wrote ${r.topics.length} episodic file(s): ${r.topics.join(', ') || '(none)'}`;
285
+ } catch (e) {
286
+ return `dream error: ${e?.message || e}`;
287
+ }
288
+ }
289
+
290
+ async function _agent(args, ctx) {
291
+ let agentsMod, loopMod;
292
+ try {
293
+ agentsMod = await import('../agents.mjs');
294
+ loopMod = await import('../loop-engine.mjs');
295
+ } catch (e) { return `/agent unavailable: ${e?.message || e}`; }
296
+ let tokens;
297
+ try { tokens = loopMod.splitArgs(args); }
298
+ catch (e) { return `/agent error: ${e?.message || e}`; }
299
+ const sub = tokens[0];
300
+ const rest = tokens.slice(1);
301
+ const aname = rest[0];
302
+ try {
303
+ if (!sub || sub === 'list') {
304
+ const agents = agentsMod.listAgents(ctx.cfgDir);
305
+ if (agents.length === 0) return 'no agents registered. /agent add <name> [...] to create.';
306
+ return agents.map((a) => {
307
+ const provLine = a.model ? `${a.provider}/${a.model}` : a.provider;
308
+ return `• ${a.name} — ${a.displayName} — ${provLine} — tools=[${(a.tools || []).join(',')}]`;
309
+ }).join('\n');
310
+ }
311
+ if (sub === 'show') {
312
+ if (!aname) return 'usage: /agent show <name>';
313
+ const a = agentsMod.getAgent(aname, ctx.cfgDir);
314
+ if (!a) return `no agent "${aname}"`;
315
+ return JSON.stringify(a, null, 2);
316
+ }
317
+ if (sub === 'add') {
318
+ if (!aname) return 'usage: /agent add <name> [role text…]';
319
+ const roleText = rest.slice(1).join(' ').trim();
320
+ const a = agentsMod.registerAgent({ name: aname, role: roleText }, ctx.cfgDir);
321
+ return `✓ added agent ${a.name} (tools=${(a.tools || []).join(',')})`;
322
+ }
323
+ if (sub === 'remove' || sub === 'rm' || sub === 'delete') {
324
+ if (!aname) return 'usage: /agent remove <name>';
325
+ agentsMod.removeAgent(aname, ctx.cfgDir);
326
+ return `✓ removed agent ${aname}`;
327
+ }
328
+ return `/agent: unknown sub "${sub}" — list|show|add|remove`;
329
+ } catch (e) {
330
+ return `/agent error: ${e?.message || e}`;
331
+ }
332
+ }
333
+
334
+ async function _team(args, ctx) {
335
+ let teamsMod, loopMod;
336
+ try {
337
+ teamsMod = await import('../teams.mjs');
338
+ loopMod = await import('../loop-engine.mjs');
339
+ } catch (e) { return `/team unavailable: ${e?.message || e}`; }
340
+ let tokens;
341
+ try { tokens = loopMod.splitArgs(args); }
342
+ catch (e) { return `/team error: ${e?.message || e}`; }
343
+ const sub = tokens[0];
344
+ const rest = tokens.slice(1);
345
+ const tname = rest[0];
346
+ try {
347
+ if (!sub || sub === 'list') {
348
+ const teams = teamsMod.listTeams(ctx.cfgDir);
349
+ if (teams.length === 0) return 'no teams registered. /team add <name> --agents a,b --lead a [--channel #x]';
350
+ return teams.map((t) => {
351
+ const chLine = t.slackChannel ? ` — ${t.slackChannel}` : '';
352
+ return `• ${t.name} — ${t.displayName} — lead=${t.lead} — agents=[${t.agents.join(',')}]${chLine}`;
353
+ }).join('\n');
354
+ }
355
+ if (sub === 'show') {
356
+ if (!tname) return 'usage: /team show <name>';
357
+ const t = teamsMod.getTeam(tname, ctx.cfgDir);
358
+ if (!t) return `no team "${tname}"`;
359
+ return JSON.stringify(t, null, 2);
360
+ }
361
+ if (sub === 'add') {
362
+ if (!tname) return 'usage: /team add <name> --agents a,b,c [--lead a] [--channel #x]';
363
+ let agentsCsv = null, lead = null, channel = '';
364
+ for (let i = 1; i < rest.length; i++) {
365
+ const t = rest[i];
366
+ if (t === '--agents') agentsCsv = rest[++i] || '';
367
+ else if (t === '--lead') lead = rest[++i] || null;
368
+ else if (t === '--channel') channel = rest[++i] || '';
369
+ else return `/team error: unknown token "${t}"`;
370
+ }
371
+ if (!agentsCsv) return '/team add: --agents is required';
372
+ const agentsList = teamsMod.parseListFlag(agentsCsv);
373
+ const ch = channel ? await teamsMod.resolveSlackChannel(channel, {
374
+ botToken: process.env.SLACK_BOT_TOKEN || null,
375
+ apiBase: process.env.SLACK_API_BASE || 'https://slack.com/api',
376
+ logger: () => {},
377
+ }) : '';
378
+ const team = teamsMod.registerTeam({ name: tname, agents: agentsList, lead, slackChannel: ch }, ctx.cfgDir);
379
+ return `✓ added team ${team.name} (lead=${team.lead}, agents=${team.agents.join(',')})`;
380
+ }
381
+ if (sub === 'remove' || sub === 'rm' || sub === 'delete') {
382
+ if (!tname) return 'usage: /team remove <name>';
383
+ teamsMod.removeTeam(tname, ctx.cfgDir);
384
+ return `✓ removed team ${tname}`;
385
+ }
386
+ return `/team: unknown sub "${sub}" — list|show|add|remove`;
387
+ } catch (e) {
388
+ return `/team error: ${e?.message || e}`;
389
+ }
390
+ }
391
+
392
+ async function _loop(args, ctx, write) {
393
+ // v5.4 minimal port: parses + reports. The full streaming loop in
394
+ // cli.mjs:3091 needs an in-Ink writeFn + abort wiring; we ship a faithful
395
+ // single-shot iteration via loop-engine.mjs to avoid silent regressions.
396
+ // For multi-iter the operator can still set LAZYCLAW_NO_INK=1.
397
+ let loopMod;
398
+ try { loopMod = await import('../loop-engine.mjs'); }
399
+ catch (e) { return `loop unavailable: ${e?.message || e}`; }
400
+ if (!args) {
401
+ return [
402
+ 'usage: /loop <prompt> [--max N] [--until "<regex>"]',
403
+ ` default --max ${loopMod.LOOP_MAX_DEFAULT}, ceiling ${loopMod.LOOP_MAX_CEILING}`,
404
+ ` session: ${ctx.getSessionId && ctx.getSessionId() || '(none — turns will not be persisted)'}`,
405
+ ' note: Ink chat runs /loop without mid-loop Ctrl-C abort; set LAZYCLAW_NO_INK=1 for the full loop UX.',
406
+ ].join('\n');
407
+ }
408
+ let parsed;
409
+ try { parsed = loopMod.parseLoopArgs(args); }
410
+ catch (e) { return `loop error: ${e?.message || e}`; }
411
+ let untilRe = null;
412
+ try { untilRe = loopMod.compileUntil(parsed.until); }
413
+ catch (e) { return `loop error: ${e?.message || e}`; }
414
+
415
+ const prov = ctx.getProv();
416
+ if (!prov || typeof prov.sendMessage !== 'function') {
417
+ return 'loop error: no active provider';
418
+ }
419
+
420
+ const sendOnce = async (msgs, signal) => {
421
+ let acc = '';
422
+ for await (const chunk of prov.sendMessage(msgs, {
423
+ apiKey: ctx.resolveAuthKey ? ctx.resolveAuthKey(ctx.getActiveProvName()) : null,
424
+ model: ctx.getActiveModel(),
425
+ signal,
426
+ onUsage: ctx.accumulateUsage,
427
+ })) {
428
+ if (typeof write === 'function') { try { write(chunk); } catch {} }
429
+ acc += chunk;
430
+ }
431
+ return acc;
432
+ };
433
+
434
+ const messages = ctx.getMessages();
435
+ const ac = new AbortController();
436
+ try {
437
+ const result = await loopMod.runLoop({
438
+ prompt: parsed.prompt,
439
+ max: parsed.max,
440
+ until: untilRe,
441
+ messages,
442
+ sendOnce,
443
+ persist: (role, content) => ctx.persistTurn && ctx.persistTurn(role, content),
444
+ onIteration: ({ i, max }) => {
445
+ if (typeof write === 'function') {
446
+ try { write(` ↻ loop iteration ${i}/${max}\n`); } catch {}
447
+ }
448
+ },
449
+ signal: ac.signal,
450
+ });
451
+ if (ctx.setCharsSent && ctx.getCharsSent) {
452
+ ctx.setCharsSent(ctx.getCharsSent() + parsed.prompt.length * result.iterations);
453
+ }
454
+ const tail = result.stoppedBy === 'until' ? ' (stopped by --until)'
455
+ : result.stoppedBy === 'abort' ? ' (aborted)' : '';
456
+ return `✓ loop done — ${result.iterations}/${parsed.max} iteration(s)${tail}`;
457
+ } catch (err) {
458
+ return `loop error: ${err?.message || String(err)}`;
459
+ }
460
+ }
461
+
462
+ async function _goal(args, ctx) {
463
+ let goalsMod, loopMod, sessionsMod;
464
+ try {
465
+ goalsMod = await import('../goals.mjs');
466
+ loopMod = await import('../loop-engine.mjs');
467
+ sessionsMod = await _mod(ctx, 'sessionsMod', () => import('../sessions.mjs'));
468
+ } catch (e) { return `/goal unavailable: ${e?.message || e}`; }
469
+ if (!args) {
470
+ const items = goalsMod.listGoals(ctx.cfgDir).filter((g) => g.status === 'active');
471
+ if (!items.length) return 'no active goals';
472
+ return items.map((g) =>
473
+ ` ${g.name}${g.description ? ' — ' + g.description : ''}${g.schedule ? ' (cron: ' + g.schedule + ')' : ''}`
474
+ ).join('\n');
475
+ }
476
+ let tokens;
477
+ try { tokens = loopMod.splitArgs(args); }
478
+ catch (e) { return `goal error: ${e?.message || e}`; }
479
+ const sub = tokens[0];
480
+ const rest = tokens.slice(1);
481
+
482
+ if (sub === 'add') {
483
+ let name = null, desc = '', cron = null;
484
+ for (let i = 0; i < rest.length; i++) {
485
+ const t = rest[i];
486
+ if (t === '--desc') desc = rest[++i] || '';
487
+ else if (t === '--cron') cron = rest[++i] || null;
488
+ else if (t.startsWith('--')) return `goal error: unknown flag ${t}`;
489
+ else if (!name) name = t;
490
+ else return `goal error: unexpected arg "${t}"`;
491
+ }
492
+ if (!name) return 'usage: /goal add <name> [--desc "..."] [--cron "<spec>"]';
493
+ try {
494
+ const g = goalsMod.registerGoal({ name, description: desc, schedule: cron }, ctx.cfgDir);
495
+ // Cron attach is cli.mjs-internal (_attachGoalCron); Ink chat skips it
496
+ // and the operator can attach via the `lazyclaw goal add --cron` CLI.
497
+ const cronNote = cron ? ' (cron attach via Ink chat is not wired in v5.4 — use the CLI form)' : '';
498
+ return `✓ goal ${g.name} added (status: active${cron ? `, cron: ${cron}` : ''})${cronNote}`;
499
+ } catch (e) { return `goal error: ${e?.message || e}`; }
500
+ }
501
+ if (sub === 'list') return JSON.stringify(goalsMod.listGoals(ctx.cfgDir), null, 2);
502
+ if (sub === 'show') {
503
+ const name = rest[0];
504
+ if (!name) return 'usage: /goal show <name>';
505
+ const g = goalsMod.getGoal(name, ctx.cfgDir);
506
+ if (!g) return `no goal "${name}"`;
507
+ return JSON.stringify(g, null, 2);
508
+ }
509
+ if (sub === 'close') {
510
+ const name = rest[0];
511
+ const outcome = rest[1] || 'done';
512
+ if (!name) return 'usage: /goal close <name> [done|abandoned]';
513
+ try {
514
+ const g = goalsMod.closeGoal(name, outcome, ctx.cfgDir);
515
+ return `✓ goal ${g.name} closed (status: ${g.status})`;
516
+ } catch (e) { return `goal error: ${e?.message || e}`; }
517
+ }
518
+ // single-arg branch: switch
519
+ const goalName = sub;
520
+ const g = goalsMod.getGoal(goalName, ctx.cfgDir);
521
+ if (!g) return `no goal "${goalName}" — try: /goal add ${goalName} --desc "..."`;
522
+ if (g.status !== 'active') return `goal "${goalName}" is ${g.status}; cannot switch`;
523
+ if (ctx.setSessionId) ctx.setSessionId(g.sessionId);
524
+ let prior = [];
525
+ try { prior = sessionsMod.loadTurns(g.sessionId, ctx.cfgDir); }
526
+ catch { prior = []; }
527
+ const nextMsgs = prior.map((t) => ({ role: t.role, content: t.content }));
528
+ const sysIdx = nextMsgs.findIndex((m) => m.role === 'system');
529
+ const goalNote = `## Goal: ${g.description || g.name}`;
530
+ if (sysIdx >= 0) nextMsgs[sysIdx] = { role: 'system', content: `${goalNote}\n\n${nextMsgs[sysIdx].content}` };
531
+ else nextMsgs.unshift({ role: 'system', content: goalNote });
532
+ if (ctx.setMessages) ctx.setMessages(nextMsgs);
533
+ return `✓ switched to goal: ${g.name} (session: ${g.sessionId}, ${prior.length} prior turn(s))`;
534
+ }
535
+
536
+ async function _handoff(args, ctx) {
537
+ const parts = splitWhitespace(args);
538
+ if (parts.length < 2) return 'usage: /handoff <target-channel> <externalId> [--note=...]';
539
+ const target = parts[0];
540
+ const externalId = parts[1];
541
+ const note = (parts.find((p) => p.startsWith('--note=')) || '').slice(7);
542
+ try {
543
+ const { openThreads } = await import('../channels/threads.mjs');
544
+ const { runHandoff } = await import('../channels/handoff.mjs');
545
+ const threads = openThreads(ctx.cfgDir);
546
+ const replState = globalThis.__lazyclawReplState || {};
547
+ const cur = replState.channel && replState.externalId
548
+ ? threads.findByExternal(replState.channel, replState.externalId)
549
+ : null;
550
+ if (!cur) {
551
+ return `handoff: no thread bound to ${replState.channel || '(none)'}:${replState.externalId || '(none)'}`;
552
+ }
553
+ const next = await runHandoff({
554
+ threads, channels: replState.channels || {},
555
+ threadId: cur.threadId, target, externalId, note,
556
+ });
557
+ replState.channel = next.channel;
558
+ replState.externalId = next.externalId;
559
+ return `handoff -> ${next.channel}:${next.externalId} (session ${next.sessionId})`;
560
+ } catch (e) {
561
+ return `handoff failed: ${e.code || 'ERR'}: ${e.message}`;
562
+ }
563
+ }
564
+
565
+ async function _personality(_args) {
566
+ // cmdPersonality lives in cli.mjs and is readline-coupled (reads from
567
+ // stdin via the global rl). Lifting it cleanly is a v5.5 follow-up; for
568
+ // v5.4 we surface the CLI alternative so operators aren't stranded.
569
+ return 'personality: interactive picker not yet wired into Ink chat — use `lazyclaw personality list|set <name>` from the shell, or restart with LAZYCLAW_NO_INK=1.';
570
+ }
571
+
572
+ async function _task() {
573
+ return 'task: slash form lands in v5.5 — use the `lazyclaw task` CLI for now.';
574
+ }
575
+
576
+ async function _trainer() {
577
+ return 'trainer: configure via config.json (cfg.trainer) or `lazyclaw trainer` CLI — slash form lands in v5.5.';
578
+ }
579
+
580
+ // ─── dispatch table ──────────────────────────────────────────────────────
581
+
582
+ export const SLASH_HANDLERS = new Map([
583
+ ['/help', _help],
584
+ ['/status', _status],
585
+ ['/version', _version],
586
+ ['/usage', _usage],
587
+ ['/new', _newReset],
588
+ ['/reset', _newReset],
589
+ ['/provider', _provider],
590
+ ['/model', _model],
591
+ ['/skill', _skill],
592
+ ['/skills', _skill],
593
+ ['/tools', _tools],
594
+ ['/recall', _recall],
595
+ ['/memory', _memory],
596
+ ['/dream', _dream],
597
+ ['/agent', _agent],
598
+ ['/team', _team],
599
+ ['/loop', _loop],
600
+ ['/goal', _goal],
601
+ ['/handoff', _handoff],
602
+ ['/personality', _personality],
603
+ ['/task', _task],
604
+ ['/trainer', _trainer],
605
+ ['/exit', async () => 'EXIT'],
606
+ ['/quit', async () => 'EXIT'],
607
+ ]);
608
+
609
+ /**
610
+ * Primary entry point. Resolves the command name to a handler in
611
+ * SLASH_HANDLERS and invokes it. Unknown commands return a friendly
612
+ * "unknown" string (caller renders to scrollback) rather than throwing,
613
+ * so the user sees feedback instead of an error toast.
614
+ */
615
+ export async function dispatchSlash(cmd, args, ctx, write) {
616
+ const handler = SLASH_HANDLERS.get(cmd);
617
+ if (!handler) return `unknown slash command: ${cmd} (try /help)`;
618
+ return handler(args || '', ctx || {}, write);
619
+ }